Files
summercms/modules/wristband/oidc_authorize.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

539 lines
17 KiB
Go

// OpenID Connect authorization endpoint (19.4 D-04, D-14), ported from the
// PHP provider's AuthorizeController: start parks a validated request and
// hands the browser to resume (signed in) or to the login page (anonymous);
// resume answers silently when consent already covers the request and asks
// the host to render a consent page otherwise; decide and switch account
// close the loop.
//
// Unknown clients, unregistered redirect URIs and inactive clients are
// rendered through the host and never redirected (T-19.4-01). Later
// failures redirect to the trusted redirect_uri with a PHP http_build_query
// encoded query and no iss parameter.
package wristband
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"unicode/utf8"
"uuid"
)
const resumePathPrefix = "/oauth/authorize/resume/"
// maxOpaqueLength is the PHP validator's cap on state, nonce and scope.
const maxOpaqueLength = 255
// errAuthorizeRender carries one of the AuthorizeError* codes from the
// shared consent logic to the HTML handlers. The JSON operations report
// all of them as ErrAuthRequestExpired.
type errAuthorizeRender string
func (e errAuthorizeRender) Error() string { return "wristband: authorize " + string(e) }
// oidcAuthorize is the OIDC flavour of Authorize: GET starts a request,
// POST is the consent decision.
func (s *Server) oidcAuthorize(w http.ResponseWriter, r *http.Request) {
if !s.oidcReady() {
w.WriteHeader(http.StatusInternalServerError)
return
}
if r.Method == http.MethodPost {
s.oidcDecide(w, r)
return
}
s.oidcStart(w, r)
}
// oidcAuthorizeData is a validated authorization request.
type oidcAuthorizeData struct {
Scopes []string
State *string
Nonce *string
Challenge string
}
// oidcStart handles GET /oauth/authorize in the PHP order: client, redirect
// URI, active flag (all rendered), then the request parameters (redirected
// errors), then the parked request and the handoff. resource, prompt,
// max_age and login_hint are ignored, as in the PHP provider.
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := r.URL.Query()
clientID := lastValue(q, "client_id")
redirectURI := lastValue(q, "redirect_uri")
var client *OIDCClientRecord
if clientID != "" {
err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
c, err := tx.OIDCClientByClientID(ctx, clientID)
client = c
return err
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
if client == nil || client.Kind != ClientKindOIDC {
s.renderAuthorizeError(w, r, AuthorizeErrorUnknownClient)
return
}
if !matchOIDCRedirectURI(client.RedirectURIs, redirectURI, client.IsDev) {
s.renderAuthorizeError(w, r, AuthorizeErrorRedirectMismatch)
return
}
if !client.IsActive {
s.renderAuthorizeError(w, r, AuthorizeErrorInactiveClient)
return
}
data, errCode, desc := s.validateOIDCAuthorize(q, client)
if errCode != "" {
s.oidcErrorRedirect(w, redirectURI, errCode, desc, lastValue(q, "state"))
return
}
rec := &AuthRequestRecord{
RequestID: uuid.NewV4().String(),
ClientID: client.ClientID,
RedirectURI: redirectURI,
Scopes: data.Scopes,
State: data.State,
Nonce: data.Nonce,
CodeChallenge: data.Challenge,
CodeChallengeMethod: "S256",
ExpiresAt: s.now().Add(s.oidc.AuthRequestTTL),
}
if err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
return tx.CreateAuthRequest(ctx, rec)
}); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
resume := resumePathPrefix + rec.RequestID
if _, ok := s.signedInUser(r); ok {
s.redirectNoStore(w, resume)
return
}
s.redirectNoStore(w, s.loginHandoffURL(resume))
}
// validateOIDCAuthorize ports AuthorizeRequestValidator::validate with its
// order and messages. It returns an error code and description, or the
// validated data.
func (s *Server) validateOIDCAuthorize(q url.Values, client *OIDCClientRecord) (oidcAuthorizeData, string, string) {
if lastValue(q, "response_type") != "code" {
return oidcAuthorizeData{}, "unsupported_response_type", "This provider only supports the authorization code response type."
}
challenge := lastValue(q, "code_challenge")
if !isPKCEChallenge(challenge) {
return oidcAuthorizeData{}, "invalid_request", "A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest."
}
if lastValue(q, "code_challenge_method") != "S256" {
return oidcAuthorizeData{}, "invalid_request", "The code challenge method is required and must be S256."
}
state := lastValuePtr(q, "state")
nonce := lastValuePtr(q, "nonce")
if (state != nil && utf8.RuneCountInString(*state) > maxOpaqueLength) ||
(nonce != nil && utf8.RuneCountInString(*nonce) > maxOpaqueLength) {
return oidcAuthorizeData{}, "invalid_request", "The state and nonce parameters must be strings of at most 255 characters."
}
scopeRaw := lastValue(q, "scope")
if strings.TrimSpace(scopeRaw) == "" || utf8.RuneCountInString(scopeRaw) > maxOpaqueLength {
return oidcAuthorizeData{}, "invalid_scope", "A scope parameter is required."
}
requested := parseOIDCScopes(scopeRaw)
if !stringSliceContains(requested, "openid") {
return oidcAuthorizeData{}, "invalid_scope", "The scope parameter must include openid."
}
supported := s.oidcSupportedScopes()
for _, sc := range requested {
if !stringSliceContains(supported, sc) {
return oidcAuthorizeData{}, "invalid_scope", "One or more requested scopes are not supported."
}
}
allowed := clientAllowedScopes(client)
for _, sc := range requested {
if !stringSliceContains(allowed, sc) {
return oidcAuthorizeData{}, "invalid_scope", "One or more requested scopes are not allowed for this client."
}
}
return oidcAuthorizeData{
Scopes: intersectScopes(requested, allowed, supported),
State: state,
Nonce: nonce,
Challenge: challenge,
}, "", ""
}
// clientAllowedScopes is the client's non-empty scopes; offline_access
// additionally needs OfflineAccess on the client (D-17).
func clientAllowedScopes(client *OIDCClientRecord) []string {
out := []string{}
for _, sc := range client.Scopes {
if sc == "" || (sc == "offline_access" && !client.OfflineAccess) {
continue
}
out = append(out, sc)
}
return out
}
// Resume handles GET /oauth/authorize/resume/{request} on an OIDC server
// (404 on an MCP server). The request ID comes from r.PathValue("request").
// A missing, expired or consumed request is rendered as expired_request; an
// anonymous user goes to the login page again; a signed-in user whose
// consent already covers the request gets the code redirect at once,
// otherwise the host renders a consent page with a fresh one-shot consent
// token.
func (s *Server) Resume(w http.ResponseWriter, r *http.Request) {
if s.flavour != flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
if !s.oidcReady() {
w.WriteHeader(http.StatusInternalServerError)
return
}
ctx := r.Context()
requestID := r.PathValue("request")
req, err := s.loadAuthRequest(ctx, requestID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if !s.authRequestUsable(req) {
s.renderAuthorizeError(w, r, AuthorizeErrorExpiredRequest)
return
}
userID, ok := s.signedInUser(r)
if !ok {
s.redirectNoStore(w, s.loginHandoffURL(resumePathPrefix+requestID))
return
}
var view ConsentView
var target string
var failure errAuthorizeRender
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 != "" {
failure = code
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
}
if consent != nil && len(missingScopes(req.Scopes, consent.Scopes)) == 0 {
now := s.now()
consumed, err := tx.ConsumeAuthRequest(ctx, req.ID, now)
if err != nil {
return err
}
if !consumed {
failure = AuthorizeErrorExpiredRequest
return nil
}
if err := tx.TouchConsent(ctx, consent.ID, now); err != nil {
return err
}
code, err := s.mintCode(ctx, tx, req, userID, req.Scopes)
if err != nil {
return err
}
target = codeRedirect(req, code)
return nil
}
v, err := s.prepareConsent(ctx, tx, req, client, userID, consent)
view = v
return err
})
switch {
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
case failure != "":
s.renderAuthorizeError(w, r, string(failure))
case target != "":
s.redirectNoStore(w, target)
default:
setRenderHeaders(w)
s.host.RenderConsent(w, r, view)
}
}
// oidcDecide handles POST /oauth/authorize: request_id, consent_token and
// decision ("allow" exactly allows, anything else denies) from the form,
// the user from the host. Every failure of the request or the consent
// token is rendered as expired_request.
func (s *Server) oidcDecide(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
requestID := inputValue(r, "request_id")
token := inputValue(r, "consent_token")
allow := inputValue(r, "decision") == "allow"
userID, ok := s.signedInUser(r)
if !ok {
s.renderAuthorizeError(w, r, AuthorizeErrorExpiredRequest)
return
}
target, err := s.decideConsent(r.Context(), requestID, userID, token, allow)
var failure errAuthorizeRender
switch {
case errors.As(err, &failure):
s.renderAuthorizeError(w, r, string(failure))
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
default:
s.redirectNoStore(w, target)
}
}
// usableClient loads the request's client and reports the rendered error
// code when it is gone or inactive.
func (s *Server) usableClient(ctx context.Context, tx OIDCTx, clientID string) (*OIDCClientRecord, errAuthorizeRender, error) {
client, err := tx.OIDCClientByClientID(ctx, clientID)
if err != nil {
return nil, "", err
}
if client == nil || client.Kind != ClientKindOIDC {
return nil, AuthorizeErrorUnknownClient, nil
}
if !client.IsActive {
return nil, AuthorizeErrorInactiveClient, nil
}
return client, "", nil
}
// loadAuthRequest reads a parked request by its request ID (nil when
// missing).
func (s *Server) loadAuthRequest(ctx context.Context, requestID string) (*AuthRequestRecord, error) {
if requestID == "" {
return nil, nil
}
var req *AuthRequestRecord
err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
rec, err := tx.AuthRequestByRequestID(ctx, requestID)
req = rec
return err
})
return req, err
}
// authRequestUsable mirrors AuthRequest::isUsable: present, not expired,
// not consumed.
func (s *Server) authRequestUsable(req *AuthRequestRecord) bool {
return req != nil && req.ConsumedAt == nil && req.ExpiresAt.After(s.now())
}
// signedInUser asks the host; an error counts as anonymous.
func (s *Server) signedInUser(r *http.Request) (uint, bool) {
id, ok, err := s.host.SignedInUser(r)
if err != nil || !ok {
return 0, false
}
return id, true
}
// loginHandoffURL is rtrim(LoginURL, "/") plus a single redirect parameter
// carrying the resume path (D-04): the OIDC query never travels through the
// login page.
func (s *Server) loginHandoffURL(resumePath string) string {
login := strings.TrimRight(s.oidc.LoginURL, "/")
if login == "" {
login = "/"
}
sep := "?"
if strings.Contains(login, "?") {
sep = "&"
}
return login + sep + "redirect=" + phpURLEncode(resumePath)
}
// setRenderHeaders sets the headers of every rendered authorize page
// (clickjacking and caching protection, T-19.4-01).
func setRenderHeaders(w http.ResponseWriter) {
h := w.Header()
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
h.Set("Cache-Control", "no-store, private")
h.Set("Referrer-Policy", "no-referrer")
}
func (s *Server) renderAuthorizeError(w http.ResponseWriter, r *http.Request, code string) {
setRenderHeaders(w)
s.host.RenderAuthorizeError(w, r, code)
}
// redirectNoStore writes a 302 with the Symfony redirect body.
func (s *Server) redirectNoStore(w http.ResponseWriter, target string) {
w.Header().Set("Cache-Control", "no-store, private")
writeRedirectHTML(w, http.StatusFound, target)
}
// oidcErrorRedirect ports AuthorizeController::redirectWithError: error,
// error_description (dropped when empty) and state (dropped when empty),
// http_build_query encoded, no iss.
func (s *Server) oidcErrorRedirect(w http.ResponseWriter, redirectURI, errCode, description, state string) {
s.redirectNoStore(w, errorRedirectURL(redirectURI, errCode, description, state))
}
func errorRedirectURL(redirectURI, errCode, description, state string) string {
pairs := [][2]string{{"error", errCode}}
if description != "" {
pairs = append(pairs, [2]string{"error_description", description})
}
if state != "" {
pairs = append(pairs, [2]string{"state", state})
}
return appendPHPQuery(redirectURI, pairs)
}
// codeRedirect is the success redirect: code, then state when the request
// carried a non-empty one.
func codeRedirect(req *AuthRequestRecord, code string) string {
pairs := [][2]string{{"code", code}}
if req.State != nil && *req.State != "" {
pairs = append(pairs, [2]string{"state", *req.State})
}
return appendPHPQuery(req.RedirectURI, pairs)
}
// mintCode creates an authorization code for the request (CodePrefix plus
// base64url of 32 random bytes, stored hashed, CodeTTL) and returns it.
func (s *Server) mintCode(ctx context.Context, tx OIDCTx, req *AuthRequestRecord, userID uint, scopes []string) (string, error) {
raw, err := s.randomBytes(32)
if err != nil {
return "", err
}
code := s.oidc.CodePrefix + raw
rec := &OIDCCodeRecord{
CodeHash: sha256Hex(code),
ClientID: req.ClientID,
UserID: userID,
RedirectURI: req.RedirectURI,
Scopes: scopes,
CodeChallenge: req.CodeChallenge,
CodeChallengeMethod: req.CodeChallengeMethod,
Nonce: req.Nonce,
ExpiresAt: s.now().Add(s.oidc.CodeTTL),
}
if err := tx.CreateCode(ctx, rec); err != nil {
return "", err
}
return code, nil
}
// isPKCEChallenge matches ^[A-Za-z0-9_-]{43,128}$.
func isPKCEChallenge(c string) bool {
if len(c) < 43 || len(c) > 128 {
return false
}
for i := 0; i < len(c); i++ {
b := c[i]
if !((b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' || b == '_') {
return false
}
}
return true
}
// parseOIDCScopes ports ScopeCatalog::parse: split on ASCII whitespace,
// drop duplicates, keep order.
func parseOIDCScopes(raw string) []string {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\v' || r == '\f'
})
out := make([]string, 0, len(parts))
for _, p := range parts {
if !stringSliceContains(out, p) {
out = append(out, p)
}
}
return out
}
// oidcSupportedScopes is OIDCOptions.ScopesSupported plus offline_access
// when refresh is enabled.
func (s *Server) oidcSupportedScopes() []string {
scopes := s.oidc.ScopesSupported
if s.oidc.RefreshEnabled && !stringSliceContains(scopes, "offline_access") {
scopes = append(append([]string(nil), scopes...), "offline_access")
}
return scopes
}
// intersectScopes ports ScopeCatalog::intersect: requested order, no
// duplicates, only supported scopes the client may have.
func intersectScopes(requested, allowed, supported []string) []string {
out := []string{}
for _, sc := range requested {
if sc == "" || stringSliceContains(out, sc) || !stringSliceContains(supported, sc) || !stringSliceContains(allowed, sc) {
continue
}
out = append(out, sc)
}
return out
}
// missingScopes is requested minus granted (Consent::missing).
func missingScopes(requested, granted []string) []string {
out := []string{}
for _, sc := range requested {
if !stringSliceContains(granted, sc) && !stringSliceContains(out, sc) {
out = append(out, sc)
}
}
return out
}
// lastValue returns the last value of a repeated parameter (PHP parse_str
// keeps the last one); array-style keys such as state[] are other keys and
// so count as absent.
func lastValue(q url.Values, key string) string {
vs := q[key]
if len(vs) == 0 {
return ""
}
return vs[len(vs)-1]
}
// lastValuePtr is lastValue with nil for an absent parameter.
func lastValuePtr(q url.Values, key string) *string {
vs := q[key]
if len(vs) == 0 {
return nil
}
v := vs[len(vs)-1]
return &v
}
// inputValue mirrors Laravel's $request->input() for a parsed form: the
// body wins over the query string, the last value of each wins.
func inputValue(r *http.Request, key string) string {
if vs := r.PostForm[key]; len(vs) > 0 {
return vs[len(vs)-1]
}
return lastValue(r.URL.Query(), key)
}
// redirectHost is the host of a redirect URI for the consent page.
func redirectHost(uri string) string {
u, err := url.Parse(uri)
if err != nil {
return ""
}
return u.Hostname()
}