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