package oidc import ( "context" "errors" "log/slog" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/ory/fosite" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) const parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:" type authorizationHandler struct { provider fosite.OAuth2Provider authorizationService *authorizationService baseURL string } func newAuthorizationHandler( provider fosite.OAuth2Provider, authorizationService *authorizationService, baseURL string, ) *authorizationHandler { return &authorizationHandler{ provider: provider, authorizationService: authorizationService, baseURL: baseURL, } } func (h *authorizationHandler) authorize(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("userID") authenticationMethod := c.GetString("authenticationMethod") authenticationTime, _ := c.Get("authenticationTime") typedAuthenticationTime, _ := authenticationTime.(time.Time) reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) // A request that resumes an interaction only carries the interaction ID; the original // parameters are restored from the stored session so they never travel through the // front channel. interactionID := c.Query("interaction") if interactionID != "" { query, err := h.authorizationService.interactionRequestQuery(ctx, interactionID) if err != nil { slog.WarnContext(ctx, "Failed to restore authorize request from interaction session", "error", err.Error()) h.writeAuthorizeError(ctx, c, fosite.NewAuthorizeRequest(), err) return } c.Request.URL.RawQuery = query.Encode() } // Treat the request as a pushed authorization request only when the request_uri carries the // PAR prefix. Without this, a client required to use PAR could bypass that requirement by // sending an arbitrary (non-prefixed) request_uri, which fosite silently ignores. hasPushedAuthorizationRequest := strings.HasPrefix(c.Query("request_uri"), parRequestURIPrefix) ar, err := h.provider.NewAuthorizeRequest(ctx, c.Request) if err != nil { slog.ErrorContext(ctx, "Failed to create authorize request", "error", err.Error()) h.writeAuthorizeError(ctx, c, ar, err) return } authorization, err := h.authorizationService.authorize(ctx, authorizeInput{ userID: userID, authenticationMethod: authenticationMethod, authenticationTime: typedAuthenticationTime, requester: ar, hasPushedAuthorizationRequest: hasPushedAuthorizationRequest, reauthenticationToken: reauthenticationToken, interactionID: interactionID, requestParams: authorizeRequestParams(ar), meta: requestMetaFromGin(c), }) if err != nil { slog.ErrorContext(ctx, "Failed to authorize request", "error", err.Error()) h.writeAuthorizeError(ctx, c, ar, err) return } if authorization.RequiresInteraction { c.Redirect(http.StatusFound, "/interaction?interaction="+authorization.InteractionID) return } response, err := h.provider.NewAuthorizeResponse(ctx, ar, authorization.Session) if err != nil { slog.ErrorContext(ctx, "Failed to create authorize response", "error", err.Error()) h.writeAuthorizeError(ctx, c, ar, err) return } response.AddParameter("iss", h.baseURL) // fosite renders an auto-submitting HTML page for response_mode=form_post, which needs a relaxed CSP h.relaxCSPForFormPost(c, ar) h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response) } type interactionIDInput struct { ID string `path:"id"` } type completeInteractionInput struct { ID string `path:"id"` Body completeInteractionRequest } func (h *authorizationHandler) getInteractionSession(ctx context.Context, input *interactionIDInput) (*httpapi.BodyOutput[interactionSessionForUser], error) { interactionSession, err := h.authorizationService.getInteractionSession(ctx, input.ID) if err != nil { return nil, err } return &httpapi.BodyOutput[interactionSessionForUser]{Body: interactionSession}, nil } func (h *authorizationHandler) completeInteraction(ctx context.Context, input *completeInteractionInput) (*httpapi.BodyOutput[completeInteractionResponse], error) { reauthenticationToken := "" if requestCookie, err := httpapi.Cookie(ctx, cookie.ReauthenticationTokenCookieName); err == nil { reauthenticationToken = requestCookie.Value } response, err := h.authorizationService.completeInteractionStep(ctx, input.ID, httpapi.UserID(ctx), input.Body.Step, reauthenticationToken, httpapi.AuthenticationTime(ctx), requestMetaFromContext(ctx)) if err != nil { return nil, err } return &httpapi.BodyOutput[completeInteractionResponse]{Body: response}, nil } func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) { if ar.IsRedirectURIValid() { // Send the error to the client // fosite delivers the error through response_mode=form_post as well, so it needs the same CSP relaxation as the success path h.relaxCSPForFormPost(c, ar) h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err) return } // If no redirect URI is available, we can't send the error to the client, // so we redirect to a generic error page instead. errorMessage := "An unknown error occurred during the authorization request." if err, ok := errors.AsType[*fosite.RFC6749Error](err); ok { if err.HintField != "" { errorMessage = err.HintField } else if err.DescriptionField != "" { errorMessage = err.DescriptionField } } c.Redirect(http.StatusFound, "/interaction/error?error="+errorMessage) } func requestMetaFromGin(c *gin.Context) requestMeta { return requestMeta{ IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent(), } } func requestMetaFromContext(ctx context.Context) requestMeta { return requestMeta{IPAddress: httpapi.ClientIP(ctx), UserAgent: httpapi.UserAgent(ctx)} } func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]string { params := make(map[string]string) for key, values := range requester.GetRequestForm() { // The raw "request" object is dropped alongside "request_uri": its claims are already merged // into the form, and replaying the JWT on interaction resume would re-validate its "exp" // against the resume time, failing logins that took longer than the object's lifetime. if len(values) == 0 || key == "request" || key == "request_uri" || key == "interaction" { continue } params[key] = values[0] } return params } // relaxCSPForFormPost loosens the per-request Content-Security-Policy when the response is delivered via response_mode=form_post func (h *authorizationHandler) relaxCSPForFormPost(c *gin.Context, ar fosite.AuthorizeRequester) { if ar.GetResponseMode() != fosite.ResponseModeFormPost || ar.GetRedirectURI() == nil { return } c.Header("Content-Security-Policy", utils.BuildFormPostCSP(utils.GetCSPNonce(c), ar.GetRedirectURI().String(), formPostScriptCSPHash)) }