diff --git a/internal/auth/delivery/http/auth_http.go b/internal/auth/delivery/http/auth_http.go index ba80009..2182a99 100644 --- a/internal/auth/delivery/http/auth_http.go +++ b/internal/auth/delivery/http/auth_http.go @@ -19,11 +19,12 @@ import ( "source.toby3d.me/website/indieauth/internal/client" "source.toby3d.me/website/indieauth/internal/common" "source.toby3d.me/website/indieauth/internal/domain" + "source.toby3d.me/website/indieauth/internal/profile" "source.toby3d.me/website/indieauth/web" ) type ( - AuthAuthorizeRequest struct { + AuthAuthorizationRequest struct { // Indicates to the authorization server that an authorization // code should be returned as the response. ResponseType domain.ResponseType `form:"response_type"` // code @@ -92,14 +93,23 @@ type ( } AuthExchangeResponse struct { - Me *domain.Me `json:"me"` + Me *domain.Me `json:"me"` + Profile *AuthProfileResponse `json:"profile,omitempty"` + } + + AuthProfileResponse struct { + Email *domain.Email `json:"email,omitempty"` + Photo *domain.URL `json:"photo,omitempty"` + URL *domain.URL `json:"url,omitempty"` + Name string `json:"name,omitempty"` } NewRequestHandlerOptions struct { - Auth auth.UseCase - Clients client.UseCase - Config *domain.Config - Matcher language.Matcher + Auth auth.UseCase + Clients client.UseCase + Config *domain.Config + Matcher language.Matcher + Profiles profile.UseCase } RequestHandler struct { @@ -175,7 +185,7 @@ func (h *RequestHandler) handleAuthorize(ctx *http.RequestCtx) { Printer: message.NewPrinter(tag), } - req := NewAuthAuthorizeRequest() + req := NewAuthAuthorizationRequest() if err := req.bind(ctx); err != nil { ctx.SetStatusCode(http.StatusBadRequest) web.WriteTemplate(ctx, &web.ErrorPage{ @@ -256,11 +266,11 @@ func (h *RequestHandler) handleVerify(ctx *http.RequestCtx) { code, err := h.useCase.Generate(ctx, auth.GenerateOptions{ ClientID: req.ClientID, + Me: req.Me, RedirectURI: req.RedirectURI, - CodeChallenge: req.CodeChallenge, CodeChallengeMethod: req.CodeChallengeMethod, Scope: req.Scope, - Me: req.Me, + CodeChallenge: req.CodeChallenge, }) if err != nil { ctx.SetStatusCode(http.StatusInternalServerError) @@ -295,7 +305,7 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) { return } - me, err := h.useCase.Exchange(ctx, auth.ExchangeOptions{ + me, profile, err := h.useCase.Exchange(ctx, auth.ExchangeOptions{ Code: req.Code, ClientID: req.ClientID, RedirectURI: req.RedirectURI, @@ -309,13 +319,24 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) { return } + var userInfo *AuthProfileResponse + if profile != nil { + userInfo = &AuthProfileResponse{ + Email: profile.GetEmail(), + Photo: profile.GetPhoto(), + URL: profile.GetURL(), + Name: profile.GetName(), + } + } + _ = encoder.Encode(&AuthExchangeResponse{ - Me: me, + Me: me, + Profile: userInfo, }) } -func NewAuthAuthorizeRequest() *AuthAuthorizeRequest { - return &AuthAuthorizeRequest{ +func NewAuthAuthorizationRequest() *AuthAuthorizationRequest { + return &AuthAuthorizationRequest{ ClientID: new(domain.ClientID), CodeChallenge: "", CodeChallengeMethod: domain.CodeChallengeMethodUndefined, @@ -327,7 +348,7 @@ func NewAuthAuthorizeRequest() *AuthAuthorizeRequest { } } -func (r *AuthAuthorizeRequest) bind(ctx *http.RequestCtx) error { +func (r *AuthAuthorizationRequest) bind(ctx *http.RequestCtx) error { indieAuthError := new(domain.Error) if err := form.Unmarshal(ctx.QueryArgs(), r); err != nil { if errors.As(err, indieAuthError) { diff --git a/internal/auth/delivery/http/auth_http_test.go b/internal/auth/delivery/http/auth_http_test.go index aa6caac..c3e62a7 100644 --- a/internal/auth/delivery/http/auth_http_test.go +++ b/internal/auth/delivery/http/auth_http_test.go @@ -18,6 +18,7 @@ import ( clientrepo "source.toby3d.me/website/indieauth/internal/client/repository/memory" clientucase "source.toby3d.me/website/indieauth/internal/client/usecase" "source.toby3d.me/website/indieauth/internal/domain" + "source.toby3d.me/website/indieauth/internal/profile" profilerepo "source.toby3d.me/website/indieauth/internal/profile/repository/memory" "source.toby3d.me/website/indieauth/internal/session" sessionrepo "source.toby3d.me/website/indieauth/internal/session/repository/memory" @@ -31,6 +32,7 @@ type Dependencies struct { clientService client.UseCase config *domain.Config matcher language.Matcher + profiles profile.Repository sessions session.Repository store *sync.Map } @@ -103,7 +105,8 @@ func NewDependencies(tb testing.TB) Dependencies { store := new(sync.Map) clients := clientrepo.NewMemoryClientRepository(store) sessions := sessionrepo.NewMemorySessionRepository(store, config) - authService := ucase.NewAuthUseCase(sessions, config) + profiles := profilerepo.NewMemoryProfileRepository(store) + authService := ucase.NewAuthUseCase(sessions, profiles, config) clientService := clientucase.NewClientUseCase(clients) return Dependencies{ @@ -113,6 +116,7 @@ func NewDependencies(tb testing.TB) Dependencies { config: config, matcher: matcher, sessions: sessions, + profiles: profiles, store: store, } } diff --git a/internal/auth/usecase.go b/internal/auth/usecase.go index e76edad..99782e9 100644 --- a/internal/auth/usecase.go +++ b/internal/auth/usecase.go @@ -25,7 +25,7 @@ type ( UseCase interface { Generate(ctx context.Context, opts GenerateOptions) (string, error) - Exchange(ctx context.Context, opts ExchangeOptions) (*domain.Me, error) + Exchange(ctx context.Context, opts ExchangeOptions) (*domain.Me, *domain.Profile, error) } ) diff --git a/internal/auth/usecase/auth_ucase.go b/internal/auth/usecase/auth_ucase.go index 226d6ad..e38dba9 100644 --- a/internal/auth/usecase/auth_ucase.go +++ b/internal/auth/usecase/auth_ucase.go @@ -6,6 +6,7 @@ import ( "source.toby3d.me/website/indieauth/internal/auth" "source.toby3d.me/website/indieauth/internal/domain" + "source.toby3d.me/website/indieauth/internal/profile" "source.toby3d.me/website/indieauth/internal/random" "source.toby3d.me/website/indieauth/internal/session" ) @@ -13,28 +14,48 @@ import ( type authUseCase struct { config *domain.Config sessions session.Repository + profiles profile.Repository } // NewAuthUseCase creates a new authentication use case. -func NewAuthUseCase(sessions session.Repository, config *domain.Config) auth.UseCase { +func NewAuthUseCase(sessions session.Repository, profiles profile.Repository, config *domain.Config) auth.UseCase { return &authUseCase{ config: config, sessions: sessions, + profiles: profiles, } } -func (useCase *authUseCase) Generate(ctx context.Context, opts auth.GenerateOptions) (string, error) { - code, err := random.String(useCase.config.Code.Length) +func (uc *authUseCase) Generate(ctx context.Context, opts auth.GenerateOptions) (string, error) { + code, err := random.String(uc.config.Code.Length) if err != nil { return "", fmt.Errorf("cannot generate random code: %w", err) } - if err = useCase.sessions.Create(ctx, &domain.Session{ + var userInfo *domain.Profile + + // NOTE(toby3d): We request information about the profile only if there + // is a corresponding Scope. However, the availability of this + // information in the token is not guaranteed and is completely optional: + // https://indieauth.net/source/#profile-information + if opts.Scope.Has(domain.ScopeProfile) { + userInfo, _ = uc.profiles.Get(ctx, opts.Me) + + // NOTE(toby3d): 'email' Scope depends on 'profile' + // Scope. Hide the email field if this information has + // not been requested. + if userInfo != nil && userInfo.Email != nil && !opts.Scope.Has(domain.ScopeEmail) { + userInfo.Email = nil + } + } + + if err = uc.sessions.Create(ctx, &domain.Session{ ClientID: opts.ClientID, Code: code, CodeChallenge: opts.CodeChallenge, CodeChallengeMethod: opts.CodeChallengeMethod, Me: opts.Me, + Profile: userInfo, RedirectURI: opts.RedirectURI, Scope: opts.Scope, }); err != nil { @@ -44,24 +65,25 @@ func (useCase *authUseCase) Generate(ctx context.Context, opts auth.GenerateOpti return code, nil } -func (useCase *authUseCase) Exchange(ctx context.Context, opts auth.ExchangeOptions) (*domain.Me, error) { - session, err := useCase.sessions.GetAndDelete(ctx, opts.Code) +func (uc *authUseCase) Exchange(ctx context.Context, opts auth.ExchangeOptions) (*domain.Me, *domain.Profile, error) { + session, err := uc.sessions.GetAndDelete(ctx, opts.Code) if err != nil { - return nil, fmt.Errorf("cannot find session in store: %w", err) + return nil, nil, fmt.Errorf("cannot find session in store: %w", err) } if opts.ClientID.String() != session.ClientID.String() { - return nil, auth.ErrMismatchClientID + return nil, nil, auth.ErrMismatchClientID } if opts.RedirectURI.String() != session.RedirectURI.String() { - return nil, auth.ErrMismatchRedirectURI + return nil, nil, auth.ErrMismatchRedirectURI } - if session.CodeChallenge != "" && session.CodeChallengeMethod != domain.CodeChallengeMethodUndefined && + if session.CodeChallenge != "" && + session.CodeChallengeMethod != domain.CodeChallengeMethodUndefined && !session.CodeChallengeMethod.Validate(session.CodeChallenge, opts.CodeVerifier) { - return nil, auth.ErrMismatchPKCE + return nil, nil, auth.ErrMismatchPKCE } - return session.Me, nil + return session.Me, session.Profile, nil } diff --git a/internal/user/repository/http/http_user.go b/internal/user/repository/http/http_user.go index aebf01a..c2a93b1 100644 --- a/internal/user/repository/http/http_user.go +++ b/internal/user/repository/http/http_user.go @@ -59,20 +59,15 @@ func (repo *httpUserRepository) Get(ctx context.Context, me *domain.Me) (*domain Me: resolvedMe, Micropub: nil, Microsub: nil, - Profile: &domain.Profile{ - Email: make([]*domain.Email, 0), - Name: make([]string, 0), - Photo: make([]*domain.URL, 0), - URL: make([]*domain.URL, 0), - }, - TicketEndpoint: nil, - TokenEndpoint: nil, + Profile: domain.NewProfile(), + TicketEndpoint: nil, + TokenEndpoint: nil, } if metadata, err := util.ExtractMetadata(resp, repo.client); err == nil { user.AuthorizationEndpoint = metadata.AuthorizationEndpoint - user.Micropub = metadata.Micropub - user.Microsub = metadata.Microsub + user.Micropub = metadata.MicropubEndpoint + user.Microsub = metadata.MicrosubEndpoint user.TicketEndpoint = metadata.TicketEndpoint user.TokenEndpoint = metadata.TokenEndpoint }