- 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
329 lines
10 KiB
Go
329 lines
10 KiB
Go
// OpenID Connect token endpoint (19.4 SC-2, D-14, D-17), ported from the
|
|
// PHP provider's TokenController: grant check, client authentication
|
|
// (Basic first, urldecoded, then the form), then code redemption in one
|
|
// transaction that burns the code before any other check (T-19.4-03). The
|
|
// answer carries an opaque access token and an id_token signed by the
|
|
// active key.
|
|
package wristband
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
oidcBasicRealm = `Basic realm="oauth"`
|
|
msgClientAuthFailed = "Client authentication failed."
|
|
msgCodeParamsMissing = "code, redirect_uri and code_verifier are required."
|
|
msgInvalidGrant = "The authorization code is invalid, expired, already used, or does not match this request."
|
|
msgUnsupportedGrant = "Only the authorization_code grant is supported."
|
|
)
|
|
|
|
// dummySecretHash is compared against when the client is unknown, so the
|
|
// unknown and the wrong-secret cases cost the same (T-19.4-06).
|
|
var dummySecretHash = strings.Repeat("0", 64)
|
|
|
|
type oidcTokenBody struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
IDToken string `json:"id_token"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
|
|
type oidcErrorBody struct {
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
|
|
// oidcNoStore is the header set of every token answer, errors included.
|
|
// Symfony rewrites the PHP provider's bare no-store to "no-store, private".
|
|
func oidcNoStore() map[string]string {
|
|
return map[string]string{"Cache-Control": "no-store, private", "Pragma": "no-cache"}
|
|
}
|
|
|
|
func writeOIDCTokenError(w http.ResponseWriter, status int, code, description string) {
|
|
writeExactJSON(w, status, oidcErrorBody{Error: code, ErrorDescription: description}, oidcNoStore())
|
|
}
|
|
|
|
// oidcTokenOutcome is the result of a grant inside its transaction.
|
|
type oidcTokenOutcome struct {
|
|
body oidcTokenBody
|
|
reason string // non-empty: invalid_grant with this log reason
|
|
}
|
|
|
|
// oidcToken is the OIDC flavour of Token.
|
|
func (s *Server) oidcToken(w http.ResponseWriter, r *http.Request) {
|
|
if !s.oidcReady() {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
grant := inputValue(r, "grant_type")
|
|
if grant != "authorization_code" {
|
|
writeOIDCTokenError(w, http.StatusBadRequest, "unsupported_grant_type", msgUnsupportedGrant)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
clientID, secret := oidcClientCredentials(r)
|
|
client, err := s.authenticateOIDCClient(ctx, clientID, secret)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if client == nil {
|
|
s.logTokenFailure(r, "invalid_client", clientID)
|
|
w.Header().Set("WWW-Authenticate", oidcBasicRealm)
|
|
writeOIDCTokenError(w, http.StatusUnauthorized, "invalid_client", msgClientAuthFailed)
|
|
return
|
|
}
|
|
|
|
code := inputValue(r, "code")
|
|
redirectURI := inputValue(r, "redirect_uri")
|
|
verifier := inputValue(r, "code_verifier")
|
|
if code == "" || redirectURI == "" || verifier == "" {
|
|
s.logTokenFailure(r, "invalid_request", clientID)
|
|
writeOIDCTokenError(w, http.StatusBadRequest, "invalid_request", msgCodeParamsMissing)
|
|
return
|
|
}
|
|
|
|
key, err := s.keys.ActiveSigningKey(ctx)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
out, err := s.redeemCode(ctx, client, key, code, redirectURI, verifier)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if out.reason != "" {
|
|
s.logTokenFailure(r, "invalid_grant:"+out.reason, clientID)
|
|
writeOIDCTokenError(w, http.StatusBadRequest, "invalid_grant", msgInvalidGrant)
|
|
return
|
|
}
|
|
writeExactJSON(w, http.StatusOK, out.body, oidcNoStore())
|
|
}
|
|
|
|
// oidcClientCredentials ports TokenController::clientCredentials: an
|
|
// Authorization header whose scheme is "Basic " in any case, strictly
|
|
// base64, split on the first colon, both halves urldecoded; an empty id
|
|
// falls back to the client_id and client_secret form fields.
|
|
func oidcClientCredentials(r *http.Request) (string, string) {
|
|
h := r.Header.Get("Authorization")
|
|
if len(h) >= 6 && strings.EqualFold(h[:6], "basic ") {
|
|
payload := h[6:]
|
|
decoded, err := base64.StdEncoding.Strict().DecodeString(payload)
|
|
if err != nil {
|
|
decoded, err = base64.RawStdEncoding.Strict().DecodeString(payload)
|
|
}
|
|
if err == nil {
|
|
if id, sec, ok := strings.Cut(string(decoded), ":"); ok {
|
|
id = phpURLDecode(id)
|
|
if id != "" {
|
|
return id, phpURLDecode(sec)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return inputValue(r, "client_id"), inputValue(r, "client_secret")
|
|
}
|
|
|
|
// authenticateOIDCClient returns the client when the credentials are
|
|
// valid, nil otherwise. Unknown, inactive, non-OIDC, a secret without the
|
|
// configured prefix and a wrong secret all fail alike; an unknown client
|
|
// still runs one constant-time comparison (T-19.4-06).
|
|
func (s *Server) authenticateOIDCClient(ctx context.Context, clientID, secret string) (*OIDCClientRecord, error) {
|
|
var client *OIDCClientRecord
|
|
if clientID != "" {
|
|
if err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
|
|
c, err := tx.OIDCClientByClientID(ctx, clientID)
|
|
client = c
|
|
return err
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if client == nil {
|
|
constantEqual(dummySecretHash, sha256Hex(secret))
|
|
return nil, nil
|
|
}
|
|
if client.Kind != ClientKindOIDC || !client.IsActive || !s.verifyClientSecret(client, secret) {
|
|
return nil, nil
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// verifyClientSecret ports Client::verifySecret: a non-empty secret with
|
|
// the configured prefix whose SHA-256 hex matches in constant time.
|
|
func (s *Server) verifyClientSecret(client *OIDCClientRecord, secret string) bool {
|
|
if secret == "" || client.SecretHash == nil || *client.SecretHash == "" {
|
|
return false
|
|
}
|
|
if !strings.HasPrefix(secret, s.oidc.ClientSecretPrefix) {
|
|
return false
|
|
}
|
|
return constantEqual(*client.SecretHash, sha256Hex(secret))
|
|
}
|
|
|
|
// redeemCode runs the authorization_code grant in one OIDC transaction.
|
|
// The code is consumed first; a replay of an already consumed code revokes
|
|
// the tokens issued from it and the revocation commits although the answer
|
|
// is invalid_grant (T-19.4-03).
|
|
func (s *Server) redeemCode(ctx context.Context, client *OIDCClientRecord, key SigningKey, code, redirectURI, verifier string) (oidcTokenOutcome, error) {
|
|
var out oidcTokenOutcome
|
|
err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
|
|
now := s.now()
|
|
if !strings.HasPrefix(code, s.oidc.CodePrefix) {
|
|
out.reason = "unknown_or_expired"
|
|
return nil
|
|
}
|
|
hash := sha256Hex(code)
|
|
rec, err := tx.ConsumeCode(ctx, hash, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec == nil {
|
|
spent, err := tx.CodeByHash(ctx, hash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if spent != nil && spent.ConsumedAt != nil {
|
|
if err := tx.RevokeAccessTokensForCode(ctx, spent.ID, now); err != nil {
|
|
return err
|
|
}
|
|
out.reason = "replay"
|
|
return nil
|
|
}
|
|
out.reason = "unknown_or_expired"
|
|
return nil
|
|
}
|
|
switch {
|
|
case rec.ClientID != client.ClientID:
|
|
out.reason = "client_mismatch"
|
|
return nil
|
|
case rec.RedirectURI != redirectURI:
|
|
out.reason = "redirect_uri_mismatch"
|
|
return nil
|
|
case !verifyOIDCPkce(verifier, rec.CodeChallenge, rec.CodeChallengeMethod):
|
|
out.reason = "pkce"
|
|
return nil
|
|
}
|
|
identity, ok, err := tx.IdentityClaims(ctx, rec.UserID, rec.Scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
out.reason = "no_user"
|
|
return nil
|
|
}
|
|
|
|
access, err := s.mintAccessToken(ctx, tx, client.ClientID, rec.UserID, &rec.ID, rec.Scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sub, err := subjectFor(ctx, tx, client, rec.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nonce := ""
|
|
if rec.Nonce != nil {
|
|
nonce = *rec.Nonce
|
|
}
|
|
idToken, err := signIDToken(key, idTokenInput{
|
|
Issuer: s.oidcIssuer(),
|
|
Subject: sub,
|
|
Audience: client.ClientID,
|
|
TTL: s.oidc.IDTokenTTL,
|
|
Now: now,
|
|
Nonce: nonce,
|
|
AccessToken: access.raw,
|
|
Identity: identity,
|
|
Allowed: s.oidc.IdentityClaims,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
consent, err := tx.ConsentFor(ctx, rec.UserID, client.ClientID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if consent != nil {
|
|
if err := tx.TouchConsent(ctx, consent.ID, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
out.body = oidcTokenBody{
|
|
AccessToken: access.raw,
|
|
TokenType: "Bearer",
|
|
ExpiresIn: int64(s.oidc.AccessTokenTTL.Seconds()),
|
|
IDToken: idToken,
|
|
Scope: strings.Join(rec.Scopes, " "),
|
|
}
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// mintedAccessToken is a freshly created access token.
|
|
type mintedAccessToken struct {
|
|
raw string
|
|
id uint
|
|
}
|
|
|
|
// mintAccessToken creates an opaque access token (AccessTokenPrefix plus
|
|
// base64url of 32 random bytes, stored hashed, AccessTokenTTL).
|
|
func (s *Server) mintAccessToken(ctx context.Context, tx OIDCTx, clientID string, userID uint, codeID *uint, scopes []string) (mintedAccessToken, error) {
|
|
raw, err := s.randomBytes(32)
|
|
if err != nil {
|
|
return mintedAccessToken{}, err
|
|
}
|
|
token := s.oidc.AccessTokenPrefix + raw
|
|
rec := &OIDCAccessTokenRecord{
|
|
TokenHash: sha256Hex(token),
|
|
ClientID: clientID,
|
|
UserID: userID,
|
|
AuthCodeID: codeID,
|
|
Scopes: scopes,
|
|
ExpiresAt: s.now().Add(s.oidc.AccessTokenTTL),
|
|
}
|
|
if err := tx.CreateAccessToken(ctx, rec); err != nil {
|
|
return mintedAccessToken{}, err
|
|
}
|
|
return mintedAccessToken{raw: token, id: rec.ID}, nil
|
|
}
|
|
|
|
// verifyOIDCPkce ports PkceVerifier::verify: S256 only, a non-empty
|
|
// challenge, a verifier of 43 to 128 characters from A-Z a-z 0-9 - . _ ~,
|
|
// compared in constant time (T-19.4-02).
|
|
func verifyOIDCPkce(verifier, challenge, method string) bool {
|
|
if method != "S256" || challenge == "" {
|
|
return false
|
|
}
|
|
if len(verifier) < 43 || len(verifier) > 128 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(verifier); i++ {
|
|
c := verifier[i]
|
|
if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~') {
|
|
return false
|
|
}
|
|
}
|
|
return constantEqual(s256Challenge(verifier), challenge)
|
|
}
|
|
|
|
// logTokenFailure logs a rejected exchange with the client ID, the client
|
|
// IP and a fixed reason only; codes, tokens and secrets are never logged
|
|
// (T-19.4-09).
|
|
func (s *Server) logTokenFailure(r *http.Request, reason, clientID string) {
|
|
if clientID == "" {
|
|
clientID = "(absent)"
|
|
}
|
|
slog.Warn("wristband: oidc token exchange rejected", "reason", reason, "client_id", clientID, "ip", clientIP(r))
|
|
}
|