// Consent for the OpenID Connect flavour: the one-shot consent token // (PHP ConsentCsrf) and the decision logic shared by the HTML handlers and // the JSON operations a host's own consent page uses. package wristband import ( "context" "errors" "net/http" "strconv" ) // consentDigest binds a consent token to one user: SHA-256 hex of // token + "|" + user ID, byte-compatible with PHP ConsentCsrf::bind, so a // digest copied from the PHP tables stays valid (T-19.4-04). func consentDigest(token string, userID uint) string { return sha256Hex(token + "|" + strconv.FormatUint(uint64(userID), 10)) } // prepareConsent mints a new consent token for the request (replacing any // stored digest) and builds the consent view listing the scopes the user // has not consented to yet; openid is never listed. func (s *Server) prepareConsent(ctx context.Context, tx OIDCTx, req *AuthRequestRecord, client *OIDCClientRecord, userID uint, consent *ConsentRecord) (ConsentView, error) { showing := req.Scopes if consent != nil { showing = missingScopes(req.Scopes, consent.Scopes) } listed := []string{} for _, sc := range showing { if sc != "openid" && !stringSliceContains(listed, sc) { listed = append(listed, sc) } } token, err := s.randomBytes(32) if err != nil { return ConsentView{}, err } digest := consentDigest(token, userID) if err := tx.SetConsentTokenHash(ctx, req.ID, &digest); err != nil { return ConsentView{}, err } return ConsentView{ RequestID: req.RequestID, ConsentToken: token, ClientID: client.ClientID, ClientName: client.Name, Scopes: listed, UserID: userID, RedirectHost: redirectHost(req.RedirectURI), Incremental: consent != nil, }, nil } // decideConsent verifies and burns the consent token, then denies // (consume, access_denied redirect) or allows (consume first, merge the // consent, mint the code) in one OIDC transaction. It returns the redirect // URL, or an errAuthorizeRender carrying the code to render. The token is // cleared even when a later check fails: the transaction commits whenever // the answer is a protocol error. func (s *Server) decideConsent(ctx context.Context, requestID string, userID uint, token string, allow bool) (string, error) { if requestID == "" { return "", errAuthorizeRender(AuthorizeErrorExpiredRequest) } var target string var failure errAuthorizeRender err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { req, err := tx.AuthRequestByRequestIDForUpdate(ctx, requestID) if err != nil { return err } if !s.authRequestUsable(req) { failure = AuthorizeErrorExpiredRequest return nil } ok, err := burnConsentToken(ctx, tx, req, userID, token) if err != nil { return err } if !ok { failure = AuthorizeErrorExpiredRequest return nil } client, code, err := s.usableClient(ctx, tx, req.ClientID) if err != nil { return err } if code != "" { failure = code return nil } now := s.now() if !allow { if _, err := tx.ConsumeAuthRequest(ctx, req.ID, now); err != nil { return err } state := "" if req.State != nil { state = *req.State } target = errorRedirectURL(req.RedirectURI, "access_denied", "", state) return nil } granted := intersectScopes(req.Scopes, clientAllowedScopes(client), s.oidcSupportedScopes()) consumed, err := tx.ConsumeAuthRequest(ctx, req.ID, now) if err != nil { return err } if !consumed { failure = AuthorizeErrorExpiredRequest return nil } if err := tx.SaveConsent(ctx, userID, client.ClientID, granted, now); err != nil { return err } raw, err := s.mintCode(ctx, tx, req, userID, granted) if err != nil { return err } target = codeRedirect(req, raw) return nil }) if err != nil { return "", err } if failure != "" { return "", failure } return target, nil } // burnConsentToken verifies the presented consent token against the // request's digest in constant time and clears the digest on success, so a // token works once (PHP ConsentCsrf::verify). The caller read req under a // row lock. func burnConsentToken(ctx context.Context, tx OIDCTx, req *AuthRequestRecord, userID uint, token string) (bool, error) { if req.ConsentTokenHash == nil || *req.ConsentTokenHash == "" || token == "" { return false, nil } if !constantEqual(*req.ConsentTokenHash, consentDigest(token, userID)) { return false, nil } if err := tx.SetConsentTokenHash(ctx, req.ID, nil); err != nil { return false, err } return true, nil } // ConsentRequest is the JSON-mode counterpart of Resume for a host whose // consent page lives in its own frontend (D-03): for the signed-in userID // it allocates the subject, mints a fresh one-shot consent token and // returns the view listing the scopes not consented to yet. It never issues // a code silently; Resume does that before the host's page is reached. A // missing, expired or consumed request, or a gone or inactive client, is // ErrAuthRequestExpired. func (s *Server) ConsentRequest(ctx context.Context, requestID string, userID uint) (ConsentView, error) { if !s.oidcReady() { return ConsentView{}, errors.New("wristband: OIDC backend is not configured") } req, err := s.loadAuthRequest(ctx, requestID) if err != nil { return ConsentView{}, err } if !s.authRequestUsable(req) { return ConsentView{}, ErrAuthRequestExpired } var view ConsentView var failed bool err = s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { client, code, err := s.usableClient(ctx, tx, req.ClientID) if err != nil { return err } if code != "" { failed = true return nil } if _, err := subjectFor(ctx, tx, client, userID); err != nil { return err } consent, err := tx.ConsentFor(ctx, userID, req.ClientID) if err != nil { return err } view, err = s.prepareConsent(ctx, tx, req, client, userID, consent) return err }) if err != nil { return ConsentView{}, err } if failed { return ConsentView{}, ErrAuthRequestExpired } return view, nil } // DecideConsent is the JSON-mode counterpart of the consent form POST: it // verifies and burns consentToken for userID, then denies (the // access_denied redirect URL) or allows (consent merged, code minted, the // code redirect URL). Every request or token failure is // ErrAuthRequestExpired (T-19.4-04). func (s *Server) DecideConsent(ctx context.Context, requestID string, userID uint, consentToken string, allow bool) (string, error) { if !s.oidcReady() { return "", errors.New("wristband: OIDC backend is not configured") } target, err := s.decideConsent(ctx, requestID, userID, consentToken, allow) var failure errAuthorizeRender if errors.As(err, &failure) { return "", ErrAuthRequestExpired } return target, err } // SwitchAccount handles POST /oauth/authorize/switch-account on an OIDC // server (404 on an MCP server): request_id and consent_token from the // form. It verifies and burns the consent token for the signed-in user // (any failure renders expired_request), asks the host to end the session // and sends the browser back to the resume path, where the next user signs // in. The request is not consumed. Without a signed-in user it goes // straight back to the resume path. func (s *Server) SwitchAccount(w http.ResponseWriter, r *http.Request) { if s.flavour != flavourOIDC { w.WriteHeader(http.StatusNotFound) return } if !s.oidcReady() { w.WriteHeader(http.StatusInternalServerError) return } _ = r.ParseForm() ctx := r.Context() req, err := s.loadAuthRequest(ctx, inputValue(r, "request_id")) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if !s.authRequestUsable(req) { s.renderAuthorizeError(w, r, AuthorizeErrorExpiredRequest) return } resume := resumePathPrefix + req.RequestID userID, ok := s.signedInUser(r) if !ok { s.redirectNoStore(w, resume) return } token := inputValue(r, "consent_token") var verified bool err = s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { locked, err := tx.AuthRequestByRequestIDForUpdate(ctx, req.RequestID) if err != nil { return err } if !s.authRequestUsable(locked) { return nil } verified, err = burnConsentToken(ctx, tx, locked, userID, token) return err }) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if !verified { s.renderAuthorizeError(w, r, AuthorizeErrorExpiredRequest) return } // Ending the session is best effort, as in the PHP provider: the // browser returns to the resume path either way. _ = s.host.EndSession(w, r) s.redirectNoStore(w, resume) }