👽 Updated token endpoinds logic due IndieAuth spec changes

This commit is contained in:
Maxim Lebedev 2022-02-17 21:15:40 +05:00
parent f7aa6f9995
commit 6594a532fc
Signed by: toby3d
GPG Key ID: 1F14E25B7C119FC5
4 changed files with 206 additions and 85 deletions

View File

@ -3,6 +3,7 @@ package usecase
import (
"context"
"fmt"
"time"
json "github.com/goccy/go-json"
http "github.com/valyala/fasthttp"
@ -15,11 +16,19 @@ import (
type (
//nolint: tagliatelle // https://indieauth.net/source/#access-token-response
Response struct {
Me *domain.Me `json:"me"`
Scope domain.Scopes `json:"scope"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
AccessToken struct {
Me *domain.Me `json:"me"`
Profile *Profile `json:"profile,omitempty"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in,omitempty"`
}
Profile struct {
Email *domain.Email `json:"email,omitempty"`
Photo *domain.URL `json:"photo,omitempty"`
URL *domain.URL `json:"url,omitempty"`
Name string `json:"name,omitempty"`
}
ticketUseCase struct {
@ -126,18 +135,21 @@ func (useCase *ticketUseCase) Redeem(ctx context.Context, tkt *domain.Ticket) (*
return nil, fmt.Errorf("cannot exchange ticket on token_endpoint: %w", err)
}
data := new(Response)
data := new(AccessToken)
if err := json.Unmarshal(resp.Body(), data); err != nil {
return nil, fmt.Errorf("cannot unmarshal access token response: %w", err)
}
// TODO(toby3d): should this also include client_id?
// https://github.com/indieweb/indieauth/issues/85
return &domain.Token{
ClientID: nil,
AccessToken: data.AccessToken,
Me: data.Me,
Scope: data.Scope,
CreatedAt: time.Now().UTC(),
Expiry: time.Unix(data.ExpiresIn, 0),
Scope: nil, // TODO(toby3d)
// TODO(toby3d): should this also include client_id?
// https://github.com/indieweb/indieauth/issues/85
ClientID: nil,
Me: data.Me,
AccessToken: data.AccessToken,
RefreshToken: "", // TODO(toby3d)
}, nil
}

View File

@ -2,10 +2,11 @@ package http
import (
"errors"
"strings"
"path"
"github.com/fasthttp/router"
json "github.com/goccy/go-json"
"github.com/lestrrat-go/jwx/jwa"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/form"
@ -25,7 +26,22 @@ type (
CodeVerifier string `form:"code_verifier"`
}
TokenRevokeRequest struct {
TokenRefreshRequest struct {
GrantType domain.GrantType `form:"grant_type"` // refresh_token
// The refresh token previously offered to the client.
RefreshToken string `form:"refresh_token"`
// The client ID that was used when the refresh token was issued.
ClientID *domain.ClientID `form:"client_id"`
// The client may request a token with the same or fewer scopes
// than the original access token. If omitted, is treated as
// equal to the original scopes granted.
Scope domain.Scopes `form:"scope,omitempty"`
}
TokenRevocationRequest struct {
Action domain.Action `form:"action"`
Token string `form:"token"`
}
@ -35,39 +51,86 @@ type (
Ticket string `form:"ticket"`
}
TokenIntrospectRequest struct {
Token string `form:"token"`
}
//nolint: tagliatelle // https://indieauth.net/source/#access-token-response
TokenExchangeResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope domain.Scopes `json:"scope"`
Me *domain.Me `json:"me"`
Profile *TokenProfileResponse `json:"profile,omitempty"`
// The OAuth 2.0 Bearer Token RFC6750.
AccessToken string `json:"access_token"`
// The canonical user profile URL for the user this access token
// corresponds to.
Me string `json:"me"`
// The user's profile information.
Profile *TokenProfileResponse `json:"profile,omitempty"`
// The lifetime in seconds of the access token.
ExpiresIn int64 `json:"expires_in,omitempty"`
// The refresh token, which can be used to obtain new access
// tokens.
RefreshToken string `json:"refresh_token"`
}
TokenProfileResponse struct {
Name string `json:"name,omitempty"`
URL *domain.URL `json:"url,omitempty"`
Photo *domain.URL `json:"photo,omitempty"`
Email *domain.Email `json:"email,omitempty"`
// Name the user wishes to provide to the client.
Name string `json:"name,omitempty"`
// URL of the user's website.
URL string `json:"url,omitempty"`
// A photo or image that the user wishes clients to use as a
// profile image.
Photo string `json:"photo,omitempty"`
// The email address a user wishes to provide to the client.
Email string `json:"email,omitempty"`
}
//nolint: tagliatelle // https://indieauth.net/source/#access-token-verification-response
TokenVerificationResponse struct {
Me *domain.Me `json:"me"`
ClientID *domain.ClientID `json:"client_id"`
Scope domain.Scopes `json:"scope"`
TokenIntrospectResponse struct {
// Boolean indicator of whether or not the presented token is
// currently active.
Active bool `json:"active"`
// The profile URL of the user corresponding to this token.
Me string `json:"me"`
// The client ID associated with this token.
ClientID string `json:"client_id"`
// A space-separated list of scopes associated with this token.
Scope string `json:"scope"`
// Integer timestamp, measured in the number of seconds since
// January 1 1970 UTC, indicating when this token will expire.
Exp int64 `json:"exp,omitempty"`
// Integer timestamp, measured in the number of seconds since
// January 1 1970 UTC, indicating when this token was originally
// issued.
Iat int64 `json:"iat,omitempty"`
}
TokenInvalidIntrospectResponse struct {
Active bool `json:"active"`
}
TokenRevocationResponse struct{}
RequestHandler struct {
config *domain.Config
tokens token.UseCase
tickets ticket.UseCase
}
)
func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase) *RequestHandler {
func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase, config *domain.Config) *RequestHandler {
return &RequestHandler{
config: config,
tokens: tokens,
tickets: tickets,
}
@ -75,37 +138,61 @@ func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase) *RequestHan
func (h *RequestHandler) Register(r *router.Router) {
chain := middleware.Chain{
middleware.JWTWithConfig(middleware.JWTConfig{
AuthScheme: "Bearer",
ContextKey: "token",
SigningKey: []byte(h.config.JWT.Secret),
SigningMethod: jwa.SignatureAlgorithm(h.config.JWT.Algorithm),
Skipper: func(ctx *http.RequestCtx) bool {
matched, _ := path.Match("/token*", string(ctx.Path()))
return matched
},
SuccessHandler: nil,
TokenLookup: middleware.SourceHeader + ":" + http.HeaderAuthorization +
"," + middleware.SourceParam + ":" + "token",
}),
middleware.LogFmt(),
}
r.GET("/token", chain.RequestHandler(h.handleValidate))
r.POST("/token", chain.RequestHandler(h.handleAction))
r.POST("/introspect", chain.RequestHandler(h.handleIntrospect))
r.POST("/revocation", chain.RequestHandler(h.handleRevokation))
}
func (h *RequestHandler) handleValidate(ctx *http.RequestCtx) {
func (h *RequestHandler) handleIntrospect(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
ctx.SetStatusCode(http.StatusOK)
encoder := json.NewEncoder(ctx)
tkt, err := h.tokens.Verify(ctx, strings.TrimPrefix(string(ctx.Request.Header.Peek(http.HeaderAuthorization)),
"Bearer "))
if err != nil || tkt == nil {
ctx.SetStatusCode(http.StatusUnauthorized)
req := new(TokenIntrospectRequest)
if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
_ = encoder.Encode(domain.NewError(
domain.ErrorCodeUnauthorizedClient,
err.Error(),
"https://indieauth.net/source/#access-token-verification",
))
_ = encoder.Encode(err)
return
}
_ = encoder.Encode(&TokenVerificationResponse{
ClientID: tkt.ClientID,
Me: tkt.Me,
Scope: tkt.Scope,
tkn, err := h.tokens.Verify(ctx, req.Token)
if err != nil || tkn == nil {
// WARN(toby3d): If the token is not valid, the endpoint still
// MUST return a 200 Response.
_ = encoder.Encode(&TokenInvalidIntrospectResponse{
Active: false,
})
return
}
_ = encoder.Encode(&TokenIntrospectResponse{
Active: true,
ClientID: tkn.ClientID.String(),
Exp: tkn.Expiry.Unix(),
Iat: tkn.CreatedAt.Unix(),
Me: tkn.Me.String(),
Scope: tkn.Scope.String(),
})
}
@ -133,14 +220,13 @@ func (h *RequestHandler) handleAction(ctx *http.RequestCtx) {
switch action {
case domain.ActionRevoke:
h.handleRevoke(ctx)
h.handleRevokation(ctx)
case domain.ActionTicket:
h.handleTicket(ctx)
}
}
}
//nolint: funlen
func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
@ -174,11 +260,11 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
}
resp := &TokenExchangeResponse{
AccessToken: token.AccessToken,
TokenType: "Bearer",
Scope: token.Scope,
Me: token.Me,
Profile: nil,
AccessToken: token.AccessToken,
ExpiresIn: token.Expiry.Unix(),
Me: token.Me.String(),
Profile: nil,
RefreshToken: "", // TODO(toby3d)
}
if profile == nil {
@ -187,34 +273,35 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
return
}
resp.Profile = new(TokenProfileResponse)
if len(profile.Name) > 0 {
resp.Profile.Name = profile.Name[0]
resp.Profile = &TokenProfileResponse{
Name: profile.GetName(),
URL: "",
Photo: "",
Email: "",
}
if len(profile.URL) > 0 {
resp.Profile.URL = profile.URL[0]
if url := profile.GetURL(); url != nil {
resp.Profile.URL = url.String()
}
if len(profile.Photo) > 0 {
resp.Profile.Photo = profile.Photo[0]
if photo := profile.GetPhoto(); photo != nil {
resp.Profile.Photo = photo.String()
}
if len(profile.Email) > 0 {
resp.Profile.Email = profile.Email[0]
if email := profile.GetEmail(); email != nil {
resp.Profile.Email = email.String()
}
_ = encoder.Encode(resp)
}
func (h *RequestHandler) handleRevoke(ctx *http.RequestCtx) {
func (h *RequestHandler) handleRevokation(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
ctx.SetStatusCode(http.StatusOK)
encoder := json.NewEncoder(ctx)
req := new(TokenRevokeRequest)
req := new(TokenRevocationRequest)
if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
@ -266,12 +353,12 @@ func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) {
return
}
_ = encoder.Encode(TokenExchangeResponse{
AccessToken: tkn.AccessToken,
TokenType: "Bearer",
Scope: tkn.Scope,
Me: tkn.Me,
Profile: nil, // TODO(toby3d)
_ = encoder.Encode(&TokenExchangeResponse{
AccessToken: tkn.AccessToken,
Me: tkn.Me.String(),
Profile: nil,
ExpiresIn: tkn.Expiry.Unix(),
RefreshToken: "", // TODO(toby3d)
})
}
@ -292,7 +379,7 @@ func (r *TokenExchangeRequest) bind(ctx *http.RequestCtx) error {
return nil
}
func (r *TokenRevokeRequest) bind(ctx *http.RequestCtx) error {
func (r *TokenRevocationRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.PostArgs(), r); err != nil {
if errors.As(err, indieAuthError) {
@ -325,3 +412,20 @@ func (r *TokenTicketRequest) bind(ctx *http.RequestCtx) error {
return nil
}
func (r *TokenIntrospectRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.PostArgs(), r); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#access-token-verification-request",
)
}
return nil
}

View File

@ -42,23 +42,23 @@ func TestExchange(t *testing.T) {
}
*/
func TestVerification(t *testing.T) {
func TestIntrospection(t *testing.T) {
t.Parallel()
deps := NewDependencies(t)
r := router.New()
delivery.NewRequestHandler(deps.tokenService, deps.ticketService).Register(r)
delivery.NewRequestHandler(deps.tokenService, deps.ticketService, deps.config).Register(r)
client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup)
const requestURL = "https://app.example.com/token"
const requestURL = "https://app.example.com/introspect"
req := httptest.NewRequest(http.MethodGet, requestURL, nil)
req := httptest.NewRequest(http.MethodPost, requestURL, []byte("token="+deps.token.AccessToken))
defer http.ReleaseRequest(req)
req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON)
deps.token.SetAuthHeader(req)
req.Header.SetContentType(common.MIMEApplicationForm)
resp := http.AcquireResponse()
defer http.ReleaseResponse(resp)
@ -71,16 +71,19 @@ func TestVerification(t *testing.T) {
t.Errorf("GET %s = %d, want %d", requestURL, result, http.StatusOK)
}
result := new(delivery.TokenVerificationResponse)
result := new(delivery.TokenIntrospectResponse)
if err := json.Unmarshal(resp.Body(), result); err != nil {
e := err.(*json.SyntaxError)
t.Logf("%s\noffset: %d", resp.Body(), e.Offset)
t.Fatal(err)
}
deps.token.AccessToken = ""
if result.ClientID.String() != deps.token.ClientID.String() ||
result.Me.String() != deps.token.Me.String() ||
result.Scope.String() != deps.token.Scope.String() {
if result.ClientID != deps.token.ClientID.String() ||
result.Me != deps.token.Me.String() ||
result.Scope != deps.token.Scope.String() {
t.Errorf("GET %s = %+v, want %+v", requestURL, result, deps.token)
}
}
@ -91,19 +94,17 @@ func TestRevocation(t *testing.T) {
deps := NewDependencies(t)
r := router.New()
delivery.NewRequestHandler(deps.tokenService, deps.ticketService).Register(r)
delivery.NewRequestHandler(deps.tokenService, deps.ticketService, deps.config).Register(r)
client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup)
const requestURL = "https://app.example.com/token"
const requestURL = "https://app.example.com/revocation"
req := httptest.NewRequest(http.MethodPost, requestURL, nil)
req := httptest.NewRequest(http.MethodPost, requestURL, []byte("token="+deps.token.AccessToken))
defer http.ReleaseRequest(req)
req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON)
req.Header.SetContentType(common.MIMEApplicationForm)
req.PostArgs().Set("action", domain.ActionRevoke.String())
req.PostArgs().Set("token", deps.token.AccessToken)
resp := http.AcquireResponse()
defer http.ReleaseResponse(resp)

View File

@ -40,6 +40,8 @@ import (
"source.toby3d.me/website/indieauth/internal/domain"
healthhttpdelivery "source.toby3d.me/website/indieauth/internal/health/delivery/http"
metadatahttpdelivery "source.toby3d.me/website/indieauth/internal/metadata/delivery/http"
"source.toby3d.me/website/indieauth/internal/profile"
profilehttprepo "source.toby3d.me/website/indieauth/internal/profile/repository/http"
"source.toby3d.me/website/indieauth/internal/session"
sessionmemoryrepo "source.toby3d.me/website/indieauth/internal/session/repository/memory"
sessionsqlite3repo "source.toby3d.me/website/indieauth/internal/session/repository/sqlite3"
@ -72,6 +74,7 @@ type (
Sessions session.Repository
Tickets ticket.Repository
Tokens token.Repository
Profiles profile.Repository
}
)
@ -187,6 +190,7 @@ func main() {
WriteTimeout: DefaultWriteTimeout,
}
opts.Clients = clienthttprepo.NewHTTPClientRepository(opts.Client)
opts.Profiles = profilehttprepo.NewHTPPClientRepository(opts.Client)
r := router.New() //nolint: varnamelen
NewApp(opts).Register(r)
@ -267,7 +271,7 @@ func main() {
func NewApp(opts NewAppOptions) *App {
return &App{
auth: authucase.NewAuthUseCase(opts.Sessions, config),
auth: authucase.NewAuthUseCase(opts.Sessions, opts.Profiles, config),
clients: clientucase.NewClientUseCase(opts.Clients),
matcher: language.NewMatcher(message.DefaultCatalog.Languages()),
sessions: sessionucase.NewSessionUseCase(opts.Sessions),
@ -323,7 +327,7 @@ func (app *App) Register(r *router.Router) {
},
AuthorizationResponseIssParameterSupported: true,
}).Register(r)
tokenhttpdelivery.NewRequestHandler(app.tokens, app.tickets).Register(r)
tokenhttpdelivery.NewRequestHandler(app.tokens, app.tickets, config).Register(r)
clienthttpdelivery.NewRequestHandler(clienthttpdelivery.NewRequestHandlerOptions{
Client: indieAuthClient,
Config: config,