- 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
272 lines
8.4 KiB
Go
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)
|
|
}
|