Files
summercms/modules/wristband/oidc_consent.go
Jakub Zych dfa3c3e8b4 feat(19.4-01): OIDC authorize, consent and switch account match the PHP provider
- Full validation chain with PHP order and messages; repeated params last-wins
- is_dev loopback port matching (matchOIDCRedirectURI); PHP http_build_query redirects, no iss
- One-shot consent token burned under row lock; deny; SwitchAccount; JSON-mode
  ConsentRequest and DecideConsent
- ClientRecord.Kind with ClientKindMCP/ClientKindOIDC; each flavour refuses the
  other kind at authorize, token and userinfo (D-08)
- Smoke tests: start, resume/decide/switch, kind isolation, PHP encoding
2026-10-19 18:21:11 +02:00

272 lines
8.4 KiB
Go

// 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)
}