♻️ Simplify error usage in token package

This commit is contained in:
Maxim Lebedev 2022-01-30 01:43:53 +05:00
parent ed55c8cded
commit 2e30613089
Signed by: toby3d
GPG Key ID: 1F14E25B7C119FC5
6 changed files with 202 additions and 91 deletions

View File

@ -1,17 +1,18 @@
package http package http
import ( import (
"errors"
"strings" "strings"
"github.com/fasthttp/router" "github.com/fasthttp/router"
json "github.com/goccy/go-json" json "github.com/goccy/go-json"
http "github.com/valyala/fasthttp" http "github.com/valyala/fasthttp"
"golang.org/x/xerrors"
"source.toby3d.me/toby3d/form" "source.toby3d.me/toby3d/form"
"source.toby3d.me/toby3d/middleware" "source.toby3d.me/toby3d/middleware"
"source.toby3d.me/website/indieauth/internal/common" "source.toby3d.me/website/indieauth/internal/common"
"source.toby3d.me/website/indieauth/internal/domain" "source.toby3d.me/website/indieauth/internal/domain"
"source.toby3d.me/website/indieauth/internal/ticket"
"source.toby3d.me/website/indieauth/internal/token" "source.toby3d.me/website/indieauth/internal/token"
) )
@ -36,10 +37,18 @@ type (
//nolint: tagliatelle //nolint: tagliatelle
ExchangeResponse struct { ExchangeResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
Scope string `json:"scope"` Scope string `json:"scope"`
Me string `json:"me"` 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 //nolint: tagliatelle
@ -52,15 +61,15 @@ type (
RevocationResponse struct{} RevocationResponse struct{}
RequestHandler struct { RequestHandler struct {
tokens token.UseCase tokens token.UseCase
// TODO(toby3d): tickets ticket.UseCase tickets ticket.UseCase
} }
) )
func NewRequestHandler(tokens token.UseCase /*, tickets ticket.UseCase*/) *RequestHandler { func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase) *RequestHandler {
return &RequestHandler{ return &RequestHandler{
tokens: tokens, tokens: tokens,
// tickets: tickets, tickets: tickets,
} }
} }
@ -83,16 +92,16 @@ func (h *RequestHandler) handleValidate(ctx *http.RequestCtx) {
"Bearer ")) "Bearer "))
if err != nil || t == nil { if err != nil || t == nil {
ctx.SetStatusCode(http.StatusUnauthorized) ctx.SetStatusCode(http.StatusUnauthorized)
encoder.Encode(&domain.Error{ encoder.Encode(domain.NewError(
Code: "unauthorized_client", domain.ErrorCodeUnauthorizedClient,
Description: err.Error(), err.Error(),
Frame: xerrors.Caller(1), "https://indieauth.net/source/#access-token-verification",
}) ))
return return
} }
encoder.Encode(&VerificationResponse{ _ = encoder.Encode(&VerificationResponse{
ClientID: t.ClientID, ClientID: t.ClientID,
Me: t.Me, Me: t.Me,
Scope: t.Scope, Scope: t.Scope,
@ -111,11 +120,11 @@ func (h *RequestHandler) handleAction(ctx *http.RequestCtx) {
action, err := domain.ParseAction(string(ctx.PostArgs().Peek("action"))) action, err := domain.ParseAction(string(ctx.PostArgs().Peek("action")))
if err != nil { if err != nil {
ctx.SetStatusCode(http.StatusBadRequest) ctx.SetStatusCode(http.StatusBadRequest)
encoder.Encode(domain.Error{ encoder.Encode(domain.NewError(
Code: "invalid_request", domain.ErrorCodeInvalidRequest,
Description: err.Error(), err.Error(),
Frame: xerrors.Caller(1), "",
}) ))
return return
} }
@ -142,7 +151,7 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
return return
} }
token, err := h.tokens.Exchange(ctx, token.ExchangeOptions{ token, profile, err := h.tokens.Exchange(ctx, token.ExchangeOptions{
ClientID: req.ClientID, ClientID: req.ClientID,
RedirectURI: req.RedirectURI, RedirectURI: req.RedirectURI,
Code: req.Code, Code: req.Code,
@ -150,20 +159,47 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
}) })
if err != nil { if err != nil {
ctx.SetStatusCode(http.StatusBadRequest) ctx.SetStatusCode(http.StatusBadRequest)
encoder.Encode(&domain.Error{ encoder.Encode(domain.NewError(
Description: err.Error(), domain.ErrorCodeInvalidRequest,
Frame: xerrors.Caller(1), err.Error(),
}) "https://indieauth.net/source/#request",
))
return return
} }
encoder.Encode(&ExchangeResponse{ resp := &ExchangeResponse{
AccessToken: token.AccessToken, AccessToken: token.AccessToken,
TokenType: "Bearer", TokenType: "Bearer",
Scope: token.Scope.String(), Scope: token.Scope.String(),
Me: token.Me.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) { func (h *RequestHandler) handleRevoke(ctx *http.RequestCtx) {
@ -174,20 +210,24 @@ func (h *RequestHandler) handleRevoke(ctx *http.RequestCtx) {
req := new(RevokeRequest) req := new(RevokeRequest)
if err := req.bind(ctx); err != nil { if err := req.bind(ctx); err != nil {
ctx.Error(err.Error(), http.StatusBadRequest) ctx.SetStatusCode(http.StatusBadRequest)
encoder.Encode(err)
return return
} }
if err := h.tokens.Revoke(ctx, req.Token); err != nil { 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 return
} }
if err := encoder.Encode(&RevocationResponse{}); err != nil { _ = encoder.Encode(&RevocationResponse{})
ctx.Error(err.Error(), http.StatusInternalServerError)
}
} }
func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) { func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) {
@ -204,53 +244,72 @@ func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) {
return return
} }
/* TODO(toby3d) t, err := h.tickets.Exchange(ctx, req.Ticket)
token, err := h.tickets.Redeem(ctx, req.Ticket)
if err != nil { if err != nil {
ctx.SetStatusCode(http.StatusInternalServerError) ctx.SetStatusCode(http.StatusInternalServerError)
encoder.Encode(domain.Error{ encoder.Encode(domain.NewError(
Description: err.Error(), domain.ErrorCodeInvalidRequest,
Frame: xerrors.Caller(1), err.Error(),
}) "https://indieauth.net/source/#request",
))
return 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 { func (r *ExchangeRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.PostArgs(), r); err != nil { if err := form.Unmarshal(ctx.PostArgs(), r); err != nil {
return domain.Error{ if errors.As(err, indieAuthError) {
Code: "invalid_request", return indieAuthError
Description: err.Error(),
Frame: xerrors.Caller(1),
} }
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#request",
)
} }
return nil return nil
} }
func (r *RevokeRequest) bind(ctx *http.RequestCtx) error { func (r *RevokeRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.PostArgs(), r); err != nil { if err := form.Unmarshal(ctx.PostArgs(), r); err != nil {
return domain.Error{ if errors.As(err, indieAuthError) {
Code: "invalid_request", return indieAuthError
Description: err.Error(),
Frame: xerrors.Caller(1),
} }
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#request",
)
} }
return nil return nil
} }
func (r *TicketRequest) bind(ctx *http.RequestCtx) error { func (r *TicketRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.PostArgs(), r); err != nil { if err := form.Unmarshal(ctx.PostArgs(), r); err != nil {
return domain.Error{ if errors.As(err, indieAuthError) {
Code: "invalid_request", return indieAuthError
Description: err.Error(),
Frame: xerrors.Caller(1),
} }
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#request",
)
} }
return nil return nil

View File

@ -16,11 +16,19 @@ import (
"source.toby3d.me/website/indieauth/internal/domain" "source.toby3d.me/website/indieauth/internal/domain"
sessionrepo "source.toby3d.me/website/indieauth/internal/session/repository/memory" sessionrepo "source.toby3d.me/website/indieauth/internal/session/repository/memory"
"source.toby3d.me/website/indieauth/internal/testing/httptest" "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" delivery "source.toby3d.me/website/indieauth/internal/token/delivery/http"
tokenrepo "source.toby3d.me/website/indieauth/internal/token/repository/memory" tokenrepo "source.toby3d.me/website/indieauth/internal/token/repository/memory"
tokenucase "source.toby3d.me/website/indieauth/internal/token/usecase" tokenucase "source.toby3d.me/website/indieauth/internal/token/usecase"
) )
/* TODO(toby3d)
func TestExchange(t *testing.T) {
t.Parallel()
}
*/
func TestVerification(t *testing.T) { func TestVerification(t *testing.T) {
t.Parallel() t.Parallel()
@ -30,8 +38,18 @@ func TestVerification(t *testing.T) {
r := router.New() r := router.New()
// TODO(toby3d): provide tickets // TODO(toby3d): provide tickets
delivery.NewRequestHandler(tokenucase.NewTokenUseCase(tokenrepo.NewMemoryTokenRepository(store), delivery.NewRequestHandler(
sessionrepo.NewMemorySessionRepository(config, store), config)).Register(r) 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) client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup) t.Cleanup(cleanup)
@ -63,8 +81,18 @@ func TestRevocation(t *testing.T) {
accessToken := domain.TestToken(t) accessToken := domain.TestToken(t)
r := router.New() r := router.New()
delivery.NewRequestHandler(tokenucase.NewTokenUseCase(tokens, sessionrepo.NewMemorySessionRepository(config, delivery.NewRequestHandler(
store), config)).Register(r) 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) client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup) t.Cleanup(cleanup)

View File

@ -2,7 +2,6 @@ package token
import ( import (
"context" "context"
"errors"
"source.toby3d.me/website/indieauth/internal/domain" "source.toby3d.me/website/indieauth/internal/domain"
) )
@ -13,6 +12,6 @@ type Repository interface {
} }
var ( var (
ErrExist = errors.New("token already exist") ErrExist error = domain.NewError(domain.ErrorCodeServerError, "token already exist", "")
ErrNotExist = errors.New("token not exist") ErrNotExist error = domain.NewError(domain.ErrorCodeServerError, "token not exist", "")
) )

View File

@ -2,7 +2,6 @@ package token
import ( import (
"context" "context"
"errors"
"source.toby3d.me/website/indieauth/internal/domain" "source.toby3d.me/website/indieauth/internal/domain"
) )
@ -16,7 +15,7 @@ type (
} }
UseCase interface { 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 checks the AccessToken and returns the associated information.
Verify(ctx context.Context, accessToken string) (*domain.Token, error) 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",
"",
)
)

View File

@ -19,12 +19,9 @@ type tokenUseCase struct {
tokens token.Repository 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 { func NewTokenUseCase(tokens token.Repository, sessions session.Repository, config *domain.Config) token.UseCase {
jwt.RegisterCustomField("scope", make(domain.Scopes, 0))
return &tokenUseCase{ return &tokenUseCase{
sessions: sessions, sessions: sessions,
config: config, 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) session, err := useCase.sessions.GetAndDelete(ctx, opts.Code)
if err != nil { 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() { if opts.ClientID.String() != session.ClientID.String() {
return nil, domain.Error{ return nil, nil, token.ErrMismatchClientID
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),
}
} }
if opts.RedirectURI.String() != session.RedirectURI.String() { if opts.RedirectURI.String() != session.RedirectURI.String() {
return nil, domain.Error{ return nil, nil, token.ErrMismatchRedirectURI
Code: "invalid_request",
Description: "client's redirect URL MUST match the initial authentication request",
URI: "https://indieauth.net/source/#request",
Frame: xerrors.Caller(1),
}
} }
if session.CodeChallenge != "" && if session.CodeChallenge != "" &&
!session.CodeChallengeMethod.Validate(session.CodeChallenge, opts.CodeVerifier) { !session.CodeChallengeMethod.Validate(session.CodeChallenge, opts.CodeVerifier) {
return nil, domain.Error{ return nil, nil, token.ErrMismatchPKCE
Code: "invalid_request", }
Description: "code_verifier is not hashes to the same value as given in " +
"the code_challenge in the original authorization request", // NOTE(toby3d): If the authorization code was issued with no scope, the
URI: "https://indieauth.net/source/#request", // token endpoint MUST NOT issue an access token, as empty scopes are
Frame: xerrors.Caller(1), // 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{ t, err := domain.NewToken(domain.NewTokenOptions{
@ -77,10 +66,18 @@ func (useCase *tokenUseCase) Exchange(ctx context.Context, opts token.ExchangeOp
Subject: session.Me, Subject: session.Me,
}) })
if err != nil { 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) { func (useCase *tokenUseCase) Verify(ctx context.Context, accessToken string) (*domain.Token, error) {

View File

@ -14,9 +14,11 @@ import (
usecase "source.toby3d.me/website/indieauth/internal/token/usecase" usecase "source.toby3d.me/website/indieauth/internal/token/usecase"
) )
/* TODO(toby3d)
func TestExchange(t *testing.T) { func TestExchange(t *testing.T) {
t.Parallel() t.Parallel()
} }
*/
func TestVerify(t *testing.T) { func TestVerify(t *testing.T) {
t.Parallel() t.Parallel()