👽 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 ( import (
"context" "context"
"fmt" "fmt"
"time"
json "github.com/goccy/go-json" json "github.com/goccy/go-json"
http "github.com/valyala/fasthttp" http "github.com/valyala/fasthttp"
@ -15,11 +16,19 @@ import (
type ( type (
//nolint: tagliatelle // https://indieauth.net/source/#access-token-response //nolint: tagliatelle // https://indieauth.net/source/#access-token-response
Response struct { AccessToken struct {
Me *domain.Me `json:"me"` Me *domain.Me `json:"me"`
Scope domain.Scopes `json:"scope"` Profile *Profile `json:"profile,omitempty"`
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
TokenType string `json:"token_type"` 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 { 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) 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 { if err := json.Unmarshal(resp.Body(), data); err != nil {
return nil, fmt.Errorf("cannot unmarshal access token response: %w", err) 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{ return &domain.Token{
ClientID: nil, CreatedAt: time.Now().UTC(),
AccessToken: data.AccessToken, Expiry: time.Unix(data.ExpiresIn, 0),
Me: data.Me, Scope: nil, // TODO(toby3d)
Scope: data.Scope, // 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 }, nil
} }

View File

@ -2,10 +2,11 @@ package http
import ( import (
"errors" "errors"
"strings" "path"
"github.com/fasthttp/router" "github.com/fasthttp/router"
json "github.com/goccy/go-json" json "github.com/goccy/go-json"
"github.com/lestrrat-go/jwx/jwa"
http "github.com/valyala/fasthttp" http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/form" "source.toby3d.me/toby3d/form"
@ -25,7 +26,22 @@ type (
CodeVerifier string `form:"code_verifier"` 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"` Action domain.Action `form:"action"`
Token string `form:"token"` Token string `form:"token"`
} }
@ -35,39 +51,86 @@ type (
Ticket string `form:"ticket"` Ticket string `form:"ticket"`
} }
TokenIntrospectRequest struct {
Token string `form:"token"`
}
//nolint: tagliatelle // https://indieauth.net/source/#access-token-response //nolint: tagliatelle // https://indieauth.net/source/#access-token-response
TokenExchangeResponse struct { TokenExchangeResponse struct {
AccessToken string `json:"access_token"` // The OAuth 2.0 Bearer Token RFC6750.
TokenType string `json:"token_type"` AccessToken string `json:"access_token"`
Scope domain.Scopes `json:"scope"`
Me *domain.Me `json:"me"` // The canonical user profile URL for the user this access token
Profile *TokenProfileResponse `json:"profile,omitempty"` // 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 { TokenProfileResponse struct {
Name string `json:"name,omitempty"` // Name the user wishes to provide to the client.
URL *domain.URL `json:"url,omitempty"` Name string `json:"name,omitempty"`
Photo *domain.URL `json:"photo,omitempty"`
Email *domain.Email `json:"email,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 //nolint: tagliatelle // https://indieauth.net/source/#access-token-verification-response
TokenVerificationResponse struct { TokenIntrospectResponse struct {
Me *domain.Me `json:"me"` // Boolean indicator of whether or not the presented token is
ClientID *domain.ClientID `json:"client_id"` // currently active.
Scope domain.Scopes `json:"scope"` 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{} TokenRevocationResponse struct{}
RequestHandler struct { RequestHandler struct {
config *domain.Config
tokens token.UseCase tokens token.UseCase
tickets ticket.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{ return &RequestHandler{
config: config,
tokens: tokens, tokens: tokens,
tickets: tickets, tickets: tickets,
} }
@ -75,37 +138,61 @@ func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase) *RequestHan
func (h *RequestHandler) Register(r *router.Router) { func (h *RequestHandler) Register(r *router.Router) {
chain := middleware.Chain{ 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(), middleware.LogFmt(),
} }
r.GET("/token", chain.RequestHandler(h.handleValidate))
r.POST("/token", chain.RequestHandler(h.handleAction)) 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.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
ctx.SetStatusCode(http.StatusOK) ctx.SetStatusCode(http.StatusOK)
encoder := json.NewEncoder(ctx) encoder := json.NewEncoder(ctx)
tkt, err := h.tokens.Verify(ctx, strings.TrimPrefix(string(ctx.Request.Header.Peek(http.HeaderAuthorization)), req := new(TokenIntrospectRequest)
"Bearer ")) if err := req.bind(ctx); err != nil {
if err != nil || tkt == nil { ctx.SetStatusCode(http.StatusBadRequest)
ctx.SetStatusCode(http.StatusUnauthorized)
_ = encoder.Encode(domain.NewError( _ = encoder.Encode(err)
domain.ErrorCodeUnauthorizedClient,
err.Error(),
"https://indieauth.net/source/#access-token-verification",
))
return return
} }
_ = encoder.Encode(&TokenVerificationResponse{ tkn, err := h.tokens.Verify(ctx, req.Token)
ClientID: tkt.ClientID, if err != nil || tkn == nil {
Me: tkt.Me, // WARN(toby3d): If the token is not valid, the endpoint still
Scope: tkt.Scope, // 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 { switch action {
case domain.ActionRevoke: case domain.ActionRevoke:
h.handleRevoke(ctx) h.handleRevokation(ctx)
case domain.ActionTicket: case domain.ActionTicket:
h.handleTicket(ctx) h.handleTicket(ctx)
} }
} }
} }
//nolint: funlen
func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) { func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8) ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
@ -174,11 +260,11 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
} }
resp := &TokenExchangeResponse{ resp := &TokenExchangeResponse{
AccessToken: token.AccessToken, AccessToken: token.AccessToken,
TokenType: "Bearer", ExpiresIn: token.Expiry.Unix(),
Scope: token.Scope, Me: token.Me.String(),
Me: token.Me, Profile: nil,
Profile: nil, RefreshToken: "", // TODO(toby3d)
} }
if profile == nil { if profile == nil {
@ -187,34 +273,35 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
return return
} }
resp.Profile = new(TokenProfileResponse) resp.Profile = &TokenProfileResponse{
Name: profile.GetName(),
if len(profile.Name) > 0 { URL: "",
resp.Profile.Name = profile.Name[0] Photo: "",
Email: "",
} }
if len(profile.URL) > 0 { if url := profile.GetURL(); url != nil {
resp.Profile.URL = profile.URL[0] resp.Profile.URL = url.String()
} }
if len(profile.Photo) > 0 { if photo := profile.GetPhoto(); photo != nil {
resp.Profile.Photo = profile.Photo[0] resp.Profile.Photo = photo.String()
} }
if len(profile.Email) > 0 { if email := profile.GetEmail(); email != nil {
resp.Profile.Email = profile.Email[0] resp.Profile.Email = email.String()
} }
_ = encoder.Encode(resp) _ = encoder.Encode(resp)
} }
func (h *RequestHandler) handleRevoke(ctx *http.RequestCtx) { func (h *RequestHandler) handleRevokation(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8) ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
ctx.SetStatusCode(http.StatusOK) ctx.SetStatusCode(http.StatusOK)
encoder := json.NewEncoder(ctx) encoder := json.NewEncoder(ctx)
req := new(TokenRevokeRequest) req := new(TokenRevocationRequest)
if err := req.bind(ctx); err != nil { if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusBadRequest) ctx.SetStatusCode(http.StatusBadRequest)
@ -266,12 +353,12 @@ func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) {
return return
} }
_ = encoder.Encode(TokenExchangeResponse{ _ = encoder.Encode(&TokenExchangeResponse{
AccessToken: tkn.AccessToken, AccessToken: tkn.AccessToken,
TokenType: "Bearer", Me: tkn.Me.String(),
Scope: tkn.Scope, Profile: nil,
Me: tkn.Me, ExpiresIn: tkn.Expiry.Unix(),
Profile: nil, // TODO(toby3d) RefreshToken: "", // TODO(toby3d)
}) })
} }
@ -292,7 +379,7 @@ func (r *TokenExchangeRequest) bind(ctx *http.RequestCtx) error {
return nil return nil
} }
func (r *TokenRevokeRequest) bind(ctx *http.RequestCtx) error { func (r *TokenRevocationRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error) indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.PostArgs(), r); err != nil { if err := form.Unmarshal(ctx.PostArgs(), r); err != nil {
if errors.As(err, indieAuthError) { if errors.As(err, indieAuthError) {
@ -325,3 +412,20 @@ func (r *TokenTicketRequest) bind(ctx *http.RequestCtx) error {
return nil 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() t.Parallel()
deps := NewDependencies(t) deps := NewDependencies(t)
r := router.New() 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) client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup) 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) defer http.ReleaseRequest(req)
req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON) req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON)
deps.token.SetAuthHeader(req) req.Header.SetContentType(common.MIMEApplicationForm)
resp := http.AcquireResponse() resp := http.AcquireResponse()
defer http.ReleaseResponse(resp) defer http.ReleaseResponse(resp)
@ -71,16 +71,19 @@ func TestVerification(t *testing.T) {
t.Errorf("GET %s = %d, want %d", requestURL, result, http.StatusOK) 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 { 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) t.Fatal(err)
} }
deps.token.AccessToken = "" deps.token.AccessToken = ""
if result.ClientID.String() != deps.token.ClientID.String() || if result.ClientID != deps.token.ClientID.String() ||
result.Me.String() != deps.token.Me.String() || result.Me != deps.token.Me.String() ||
result.Scope.String() != deps.token.Scope.String() { result.Scope != deps.token.Scope.String() {
t.Errorf("GET %s = %+v, want %+v", requestURL, result, deps.token) t.Errorf("GET %s = %+v, want %+v", requestURL, result, deps.token)
} }
} }
@ -91,19 +94,17 @@ func TestRevocation(t *testing.T) {
deps := NewDependencies(t) deps := NewDependencies(t)
r := router.New() 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) client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup) 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) defer http.ReleaseRequest(req)
req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON) req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON)
req.Header.SetContentType(common.MIMEApplicationForm) req.Header.SetContentType(common.MIMEApplicationForm)
req.PostArgs().Set("action", domain.ActionRevoke.String())
req.PostArgs().Set("token", deps.token.AccessToken)
resp := http.AcquireResponse() resp := http.AcquireResponse()
defer http.ReleaseResponse(resp) defer http.ReleaseResponse(resp)

View File

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