package http import ( "errors" "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/auth/internal/common" "source.toby3d.me/toby3d/auth/internal/domain" "source.toby3d.me/toby3d/auth/internal/ticket" "source.toby3d.me/toby3d/auth/internal/token" "source.toby3d.me/toby3d/form" "source.toby3d.me/toby3d/middleware" ) type ( TokenExchangeRequest struct { ClientID *domain.ClientID `form:"client_id"` RedirectURI *domain.URL `form:"redirect_uri"` GrantType domain.GrantType `form:"grant_type"` Code string `form:"code"` CodeVerifier string `form:"code_verifier"` } 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"` } TokenTicketRequest struct { Action domain.Action `form:"action"` Ticket string `form:"ticket"` } TokenIntrospectRequest struct { Token string `form:"token"` } //nolint: tagliatelle // https://indieauth.net/source/#access-token-response TokenExchangeResponse struct { // 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 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 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, config *domain.Config) *RequestHandler { return &RequestHandler{ config: config, tokens: tokens, tickets: tickets, } } func (h *RequestHandler) Register(r *router.Router) { chain := middleware.Chain{ //nolint: exhaustivestruct 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: "header:" + http.HeaderAuthorization + ":Bearer " + ",param:token", }), middleware.LogFmt(), } r.POST("/token", chain.RequestHandler(h.handleAction)) r.POST("/introspect", chain.RequestHandler(h.handleIntrospect)) r.POST("/revocation", chain.RequestHandler(h.handleRevokation)) } func (h *RequestHandler) handleIntrospect(ctx *http.RequestCtx) { ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8) ctx.SetStatusCode(http.StatusOK) encoder := json.NewEncoder(ctx) req := new(TokenIntrospectRequest) if err := req.bind(ctx); err != nil { ctx.SetStatusCode(http.StatusBadRequest) _ = encoder.Encode(err) return } 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(), }) } func (h *RequestHandler) handleAction(ctx *http.RequestCtx) { ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8) encoder := json.NewEncoder(ctx) switch { case ctx.PostArgs().Has("grant_type"): h.handleExchange(ctx) case ctx.PostArgs().Has("action"): action, err := domain.ParseAction(string(ctx.PostArgs().Peek("action"))) if err != nil { ctx.SetStatusCode(http.StatusBadRequest) _ = encoder.Encode(domain.NewError( domain.ErrorCodeInvalidRequest, err.Error(), "", )) return } switch action { case domain.ActionRevoke: h.handleRevokation(ctx) case domain.ActionTicket: h.handleTicket(ctx) } } } //nolint: funlen func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) { ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8) encoder := json.NewEncoder(ctx) req := new(TokenExchangeRequest) if err := req.bind(ctx); err != nil { ctx.SetStatusCode(http.StatusBadRequest) _ = encoder.Encode(err) return } token, profile, err := h.tokens.Exchange(ctx, token.ExchangeOptions{ ClientID: req.ClientID, RedirectURI: req.RedirectURI, Code: req.Code, CodeVerifier: req.CodeVerifier, }) if err != nil { ctx.SetStatusCode(http.StatusBadRequest) _ = encoder.Encode(domain.NewError( domain.ErrorCodeInvalidRequest, err.Error(), "https://indieauth.net/source/#request", )) return } resp := &TokenExchangeResponse{ AccessToken: token.AccessToken, ExpiresIn: token.Expiry.Unix(), Me: token.Me.String(), Profile: nil, RefreshToken: "", // TODO(toby3d) } if profile == nil { _ = encoder.Encode(resp) return } resp.Profile = &TokenProfileResponse{ Name: profile.GetName(), URL: "", Photo: "", Email: "", } if url := profile.GetURL(); url != nil { resp.Profile.URL = url.String() } if photo := profile.GetPhoto(); photo != nil { resp.Profile.Photo = photo.String() } if email := profile.GetEmail(); email != nil { resp.Profile.Email = email.String() } _ = encoder.Encode(resp) } func (h *RequestHandler) handleRevokation(ctx *http.RequestCtx) { ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8) ctx.SetStatusCode(http.StatusOK) encoder := json.NewEncoder(ctx) req := new(TokenRevocationRequest) if err := req.bind(ctx); err != nil { ctx.SetStatusCode(http.StatusBadRequest) _ = encoder.Encode(err) return } if err := h.tokens.Revoke(ctx, req.Token); err != nil { ctx.SetStatusCode(http.StatusBadRequest) _ = encoder.Encode(domain.NewError( domain.ErrorCodeInvalidRequest, err.Error(), "", )) return } _ = encoder.Encode(&TokenRevocationResponse{}) } func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) { ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8) ctx.SetStatusCode(http.StatusOK) encoder := json.NewEncoder(ctx) req := new(TokenTicketRequest) if err := req.bind(ctx); err != nil { ctx.SetStatusCode(http.StatusBadRequest) _ = encoder.Encode(err) return } tkn, err := h.tickets.Exchange(ctx, req.Ticket) if err != nil { ctx.SetStatusCode(http.StatusInternalServerError) _ = encoder.Encode(domain.NewError( domain.ErrorCodeInvalidRequest, err.Error(), "https://indieauth.net/source/#request", )) return } _ = encoder.Encode(&TokenExchangeResponse{ AccessToken: tkn.AccessToken, Me: tkn.Me.String(), Profile: nil, ExpiresIn: tkn.Expiry.Unix(), RefreshToken: "", // TODO(toby3d) }) } func (r *TokenExchangeRequest) 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/#request", ) } return nil } 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) { return indieAuthError } return domain.NewError( domain.ErrorCodeInvalidRequest, err.Error(), "https://indieauth.net/source/#request", ) } return nil } func (r *TokenTicketRequest) 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/#request", ) } 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 }