diff --git a/internal/token/delivery/http/token_http.go b/internal/token/delivery/http/token_http.go index 806bbb7..4c78597 100644 --- a/internal/token/delivery/http/token_http.go +++ b/internal/token/delivery/http/token_http.go @@ -1,17 +1,18 @@ package http import ( + "errors" "strings" "github.com/fasthttp/router" json "github.com/goccy/go-json" http "github.com/valyala/fasthttp" - "golang.org/x/xerrors" "source.toby3d.me/toby3d/form" "source.toby3d.me/toby3d/middleware" "source.toby3d.me/website/indieauth/internal/common" "source.toby3d.me/website/indieauth/internal/domain" + "source.toby3d.me/website/indieauth/internal/ticket" "source.toby3d.me/website/indieauth/internal/token" ) @@ -36,10 +37,18 @@ type ( //nolint: tagliatelle ExchangeResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - Scope string `json:"scope"` - Me string `json:"me"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Me string `json:"me"` + Profile *ProfileResponse `json:"profile,omitempty"` + } + + ProfileResponse struct { + Name string `json:"name,omitempty"` + URL *domain.URL `json:"url,omitempty"` + Photo *domain.URL `json:"photo,omitempty"` + Email *domain.Email `json:"email,omitempty"` } //nolint: tagliatelle @@ -52,15 +61,15 @@ type ( RevocationResponse struct{} RequestHandler struct { - tokens token.UseCase - // TODO(toby3d): tickets ticket.UseCase + tokens token.UseCase + tickets ticket.UseCase } ) -func NewRequestHandler(tokens token.UseCase /*, tickets ticket.UseCase*/) *RequestHandler { +func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase) *RequestHandler { return &RequestHandler{ - tokens: tokens, - // tickets: tickets, + tokens: tokens, + tickets: tickets, } } @@ -83,16 +92,16 @@ func (h *RequestHandler) handleValidate(ctx *http.RequestCtx) { "Bearer ")) if err != nil || t == nil { ctx.SetStatusCode(http.StatusUnauthorized) - encoder.Encode(&domain.Error{ - Code: "unauthorized_client", - Description: err.Error(), - Frame: xerrors.Caller(1), - }) + encoder.Encode(domain.NewError( + domain.ErrorCodeUnauthorizedClient, + err.Error(), + "https://indieauth.net/source/#access-token-verification", + )) return } - encoder.Encode(&VerificationResponse{ + _ = encoder.Encode(&VerificationResponse{ ClientID: t.ClientID, Me: t.Me, Scope: t.Scope, @@ -111,11 +120,11 @@ func (h *RequestHandler) handleAction(ctx *http.RequestCtx) { action, err := domain.ParseAction(string(ctx.PostArgs().Peek("action"))) if err != nil { ctx.SetStatusCode(http.StatusBadRequest) - encoder.Encode(domain.Error{ - Code: "invalid_request", - Description: err.Error(), - Frame: xerrors.Caller(1), - }) + encoder.Encode(domain.NewError( + domain.ErrorCodeInvalidRequest, + err.Error(), + "", + )) return } @@ -142,7 +151,7 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) { return } - token, err := h.tokens.Exchange(ctx, token.ExchangeOptions{ + token, profile, err := h.tokens.Exchange(ctx, token.ExchangeOptions{ ClientID: req.ClientID, RedirectURI: req.RedirectURI, Code: req.Code, @@ -150,20 +159,47 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) { }) if err != nil { ctx.SetStatusCode(http.StatusBadRequest) - encoder.Encode(&domain.Error{ - Description: err.Error(), - Frame: xerrors.Caller(1), - }) + encoder.Encode(domain.NewError( + domain.ErrorCodeInvalidRequest, + err.Error(), + "https://indieauth.net/source/#request", + )) return } - encoder.Encode(&ExchangeResponse{ + resp := &ExchangeResponse{ AccessToken: token.AccessToken, TokenType: "Bearer", Scope: token.Scope.String(), Me: token.Me.String(), - }) + } + + if profile == nil { + encoder.Encode(resp) + + return + } + + resp.Profile = new(ProfileResponse) + + if len(profile.Name) > 0 { + resp.Profile.Name = profile.Name[0] + } + + if len(profile.URL) > 0 { + resp.Profile.URL = profile.URL[0] + } + + if len(profile.Photo) > 0 { + resp.Profile.Photo = profile.Photo[0] + } + + if len(profile.Email) > 0 { + resp.Profile.Email = profile.Email[0] + } + + _ = encoder.Encode(resp) } func (h *RequestHandler) handleRevoke(ctx *http.RequestCtx) { @@ -174,20 +210,24 @@ func (h *RequestHandler) handleRevoke(ctx *http.RequestCtx) { req := new(RevokeRequest) if err := req.bind(ctx); err != nil { - ctx.Error(err.Error(), http.StatusBadRequest) + ctx.SetStatusCode(http.StatusBadRequest) + encoder.Encode(err) return } if err := h.tokens.Revoke(ctx, req.Token); err != nil { - ctx.Error(err.Error(), http.StatusBadRequest) + ctx.SetStatusCode(http.StatusBadRequest) + encoder.Encode(domain.NewError( + domain.ErrorCodeInvalidRequest, + err.Error(), + "", + )) return } - if err := encoder.Encode(&RevocationResponse{}); err != nil { - ctx.Error(err.Error(), http.StatusInternalServerError) - } + _ = encoder.Encode(&RevocationResponse{}) } func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) { @@ -204,53 +244,72 @@ func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) { return } - /* TODO(toby3d) - token, err := h.tickets.Redeem(ctx, req.Ticket) + t, err := h.tickets.Exchange(ctx, req.Ticket) if err != nil { ctx.SetStatusCode(http.StatusInternalServerError) - encoder.Encode(domain.Error{ - Description: err.Error(), - Frame: xerrors.Caller(1), - }) + encoder.Encode(domain.NewError( + domain.ErrorCodeInvalidRequest, + err.Error(), + "https://indieauth.net/source/#request", + )) return } - */ - encoder.Encode(ExchangeResponse{}) + encoder.Encode(ExchangeResponse{ + AccessToken: t.AccessToken, + TokenType: "Bearer", + Scope: t.Scope.String(), + Me: t.Me.String(), + }) } func (r *ExchangeRequest) bind(ctx *http.RequestCtx) error { + indieAuthError := new(domain.Error) if err := form.Unmarshal(ctx.PostArgs(), r); err != nil { - return domain.Error{ - Code: "invalid_request", - Description: err.Error(), - Frame: xerrors.Caller(1), + if errors.As(err, indieAuthError) { + return indieAuthError } + + return domain.NewError( + domain.ErrorCodeInvalidRequest, + err.Error(), + "https://indieauth.net/source/#request", + ) } return nil } func (r *RevokeRequest) bind(ctx *http.RequestCtx) error { + indieAuthError := new(domain.Error) if err := form.Unmarshal(ctx.PostArgs(), r); err != nil { - return domain.Error{ - Code: "invalid_request", - Description: err.Error(), - Frame: xerrors.Caller(1), + if errors.As(err, indieAuthError) { + return indieAuthError } + + return domain.NewError( + domain.ErrorCodeInvalidRequest, + err.Error(), + "https://indieauth.net/source/#request", + ) } return nil } func (r *TicketRequest) bind(ctx *http.RequestCtx) error { + indieAuthError := new(domain.Error) if err := form.Unmarshal(ctx.PostArgs(), r); err != nil { - return domain.Error{ - Code: "invalid_request", - Description: err.Error(), - Frame: xerrors.Caller(1), + if errors.As(err, indieAuthError) { + return indieAuthError } + + return domain.NewError( + domain.ErrorCodeInvalidRequest, + err.Error(), + "https://indieauth.net/source/#request", + ) } return nil diff --git a/internal/token/delivery/http/token_http_test.go b/internal/token/delivery/http/token_http_test.go index 1f80d96..4afc4b4 100644 --- a/internal/token/delivery/http/token_http_test.go +++ b/internal/token/delivery/http/token_http_test.go @@ -16,11 +16,19 @@ import ( "source.toby3d.me/website/indieauth/internal/domain" sessionrepo "source.toby3d.me/website/indieauth/internal/session/repository/memory" "source.toby3d.me/website/indieauth/internal/testing/httptest" + ticketrepo "source.toby3d.me/website/indieauth/internal/ticket/repository/memory" + ticketucase "source.toby3d.me/website/indieauth/internal/ticket/usecase" delivery "source.toby3d.me/website/indieauth/internal/token/delivery/http" tokenrepo "source.toby3d.me/website/indieauth/internal/token/repository/memory" tokenucase "source.toby3d.me/website/indieauth/internal/token/usecase" ) +/* TODO(toby3d) +func TestExchange(t *testing.T) { + t.Parallel() +} +*/ + func TestVerification(t *testing.T) { t.Parallel() @@ -30,8 +38,18 @@ func TestVerification(t *testing.T) { r := router.New() // TODO(toby3d): provide tickets - delivery.NewRequestHandler(tokenucase.NewTokenUseCase(tokenrepo.NewMemoryTokenRepository(store), - sessionrepo.NewMemorySessionRepository(config, store), config)).Register(r) + delivery.NewRequestHandler( + tokenucase.NewTokenUseCase( + tokenrepo.NewMemoryTokenRepository(store), + sessionrepo.NewMemorySessionRepository(config, store), + config, + ), + ticketucase.NewTicketUseCase( + ticketrepo.NewMemoryTicketRepository(store, config), + new(http.Client), + config, + ), + ).Register(r) client, _, cleanup := httptest.New(t, r.Handler) t.Cleanup(cleanup) @@ -63,8 +81,18 @@ func TestRevocation(t *testing.T) { accessToken := domain.TestToken(t) r := router.New() - delivery.NewRequestHandler(tokenucase.NewTokenUseCase(tokens, sessionrepo.NewMemorySessionRepository(config, - store), config)).Register(r) + delivery.NewRequestHandler( + tokenucase.NewTokenUseCase( + tokens, + sessionrepo.NewMemorySessionRepository(config, store), + config, + ), + ticketucase.NewTicketUseCase( + ticketrepo.NewMemoryTicketRepository(store, config), + new(http.Client), + config, + ), + ).Register(r) client, _, cleanup := httptest.New(t, r.Handler) t.Cleanup(cleanup) diff --git a/internal/token/repository.go b/internal/token/repository.go index f060a05..2051d1f 100644 --- a/internal/token/repository.go +++ b/internal/token/repository.go @@ -2,7 +2,6 @@ package token import ( "context" - "errors" "source.toby3d.me/website/indieauth/internal/domain" ) @@ -13,6 +12,6 @@ type Repository interface { } var ( - ErrExist = errors.New("token already exist") - ErrNotExist = errors.New("token not exist") + ErrExist error = domain.NewError(domain.ErrorCodeServerError, "token already exist", "") + ErrNotExist error = domain.NewError(domain.ErrorCodeServerError, "token not exist", "") ) diff --git a/internal/token/usecase.go b/internal/token/usecase.go index 66e92eb..d24ee38 100644 --- a/internal/token/usecase.go +++ b/internal/token/usecase.go @@ -2,7 +2,6 @@ package token import ( "context" - "errors" "source.toby3d.me/website/indieauth/internal/domain" ) @@ -16,7 +15,7 @@ type ( } UseCase interface { - Exchange(ctx context.Context, opts ExchangeOptions) (*domain.Token, error) + Exchange(ctx context.Context, opts ExchangeOptions) (*domain.Token, *domain.Profile, error) // Verify checks the AccessToken and returns the associated information. Verify(ctx context.Context, accessToken string) (*domain.Token, error) @@ -26,4 +25,31 @@ type ( } ) -var ErrRevoke = errors.New("this token has been revoked") +var ( + ErrRevoke error = domain.NewError( + domain.ErrorCodeAccessDenied, + "this token has been revoked", + "", + ) + ErrMismatchClientID error = domain.NewError( + domain.ErrorCodeInvalidRequest, + "client's URL MUST match the client_id used in the authentication request", + "", + ) + ErrMismatchRedirectURI error = domain.NewError( + domain.ErrorCodeInvalidRequest, + "client's redirect URL MUST match the initial authentication request", + "", + ) + ErrEmptyScope error = domain.NewError( + domain.ErrorCodeInvalidScope, + "empty scopes are invalid", + "", + ) + ErrMismatchPKCE error = domain.NewError( + domain.ErrorCodeInvalidRequest, + "code_verifier is not hashes to the same value as given in the code_challenge in the original "+ + " authorization request", + "", + ) +) diff --git a/internal/token/usecase/token_ucase.go b/internal/token/usecase/token_ucase.go index f118fd4..67de2a7 100644 --- a/internal/token/usecase/token_ucase.go +++ b/internal/token/usecase/token_ucase.go @@ -19,12 +19,9 @@ type tokenUseCase struct { tokens token.Repository } -//nolint: gochecknoinits -func init() { - jwt.RegisterCustomField("scope", make(domain.Scopes, 0)) -} - func NewTokenUseCase(tokens token.Repository, sessions session.Repository, config *domain.Config) token.UseCase { + jwt.RegisterCustomField("scope", make(domain.Scopes, 0)) + return &tokenUseCase{ sessions: sessions, config: config, @@ -32,39 +29,31 @@ func NewTokenUseCase(tokens token.Repository, sessions session.Repository, confi } } -func (useCase *tokenUseCase) Exchange(ctx context.Context, opts token.ExchangeOptions) (*domain.Token, error) { +func (useCase *tokenUseCase) Exchange(ctx context.Context, opts token.ExchangeOptions) (*domain.Token, *domain.Profile, + error) { session, err := useCase.sessions.GetAndDelete(ctx, opts.Code) if err != nil { - return nil, fmt.Errorf("cannot get session from store: %w", err) + return nil, nil, fmt.Errorf("cannot get session from store: %w", err) } if opts.ClientID.String() != session.ClientID.String() { - return nil, domain.Error{ - Code: "invalid_request", - Description: "client's URL MUST match the client_id used in the authentication request", - URI: "https://indieauth.net/source/#request", - Frame: xerrors.Caller(1), - } + return nil, nil, token.ErrMismatchClientID } if opts.RedirectURI.String() != session.RedirectURI.String() { - return nil, domain.Error{ - Code: "invalid_request", - Description: "client's redirect URL MUST match the initial authentication request", - URI: "https://indieauth.net/source/#request", - Frame: xerrors.Caller(1), - } + return nil, nil, token.ErrMismatchRedirectURI } if session.CodeChallenge != "" && !session.CodeChallengeMethod.Validate(session.CodeChallenge, opts.CodeVerifier) { - return nil, domain.Error{ - Code: "invalid_request", - Description: "code_verifier is not hashes to the same value as given in " + - "the code_challenge in the original authorization request", - URI: "https://indieauth.net/source/#request", - Frame: xerrors.Caller(1), - } + return nil, nil, token.ErrMismatchPKCE + } + + // NOTE(toby3d): If the authorization code was issued with no scope, the + // token endpoint MUST NOT issue an access token, as empty scopes are + // invalid per Section 3.3 of OAuth 2.0 RFC6749. + if session.Scope.IsEmpty() { + return nil, nil, token.ErrEmptyScope } t, err := domain.NewToken(domain.NewTokenOptions{ @@ -77,10 +66,18 @@ func (useCase *tokenUseCase) Exchange(ctx context.Context, opts token.ExchangeOp Subject: session.Me, }) if err != nil { - return nil, fmt.Errorf("cannot generate a new access token: %w", err) + return nil, nil, fmt.Errorf("cannot generate a new access token: %w", err) } - return t, nil + if !session.Scope.Has(domain.ScopeProfile) { + return t, nil, nil + } + + p := new(domain.Profile) + + // TODO(toby3d): if session.Scope.Has(domain.ScopeEmail) {} + + return t, p, nil } func (useCase *tokenUseCase) Verify(ctx context.Context, accessToken string) (*domain.Token, error) { diff --git a/internal/token/usecase/token_ucase_test.go b/internal/token/usecase/token_ucase_test.go index 300b2f9..86484bf 100644 --- a/internal/token/usecase/token_ucase_test.go +++ b/internal/token/usecase/token_ucase_test.go @@ -14,9 +14,11 @@ import ( usecase "source.toby3d.me/website/indieauth/internal/token/usecase" ) +/* TODO(toby3d) func TestExchange(t *testing.T) { t.Parallel() } +*/ func TestVerify(t *testing.T) { t.Parallel()