diff --git a/internal/ticket/usecase/ticket_ucase.go b/internal/ticket/usecase/ticket_ucase.go index edcc6f6..fa7aec0 100644 --- a/internal/ticket/usecase/ticket_ucase.go +++ b/internal/ticket/usecase/ticket_ucase.go @@ -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 } diff --git a/internal/token/delivery/http/token_http.go b/internal/token/delivery/http/token_http.go index a5d4f37..c4d8f42 100644 --- a/internal/token/delivery/http/token_http.go +++ b/internal/token/delivery/http/token_http.go @@ -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 +} diff --git a/internal/token/delivery/http/token_http_test.go b/internal/token/delivery/http/token_http_test.go index b29597e..5ba7eb5 100644 --- a/internal/token/delivery/http/token_http_test.go +++ b/internal/token/delivery/http/token_http_test.go @@ -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) diff --git a/main.go b/main.go index 562986a..613f5e8 100644 --- a/main.go +++ b/main.go @@ -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,