- 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
539 lines
17 KiB
Go
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()
|
|
}
|