- ES256 keys, PKCS#1/SEC1 parsing fallbacks, EC JWKs, mixed RS256/ES256 rings - Pairwise subjects per sector (SubjectSector, ErrSectorRequired) - Opt-in refresh_token grant (OIDCOptions.RefreshEnabled): OfflineAccess clients only, rotation, lineage revocation on replay, fresh id_token without nonce - IssueOIDCClientCredentials with the PHP oas_/oas_cs_ shapes - Smoke tests: token errors, replay revocation, userinfo 401s, mixed key ring, pairwise subjects, refresh opt-in
514 lines
17 KiB
Go
514 lines
17 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."
|
|
|
|
msgUnsupportedGrantRefresh = "Only the authorization_code and refresh_token grants are supported."
|
|
msgRefreshNotAllowed = "The client is not allowed to use the refresh_token grant."
|
|
msgRefreshMissing = "refresh_token is required."
|
|
msgInvalidRefresh = "The refresh token is invalid, expired, revoked, or does not belong to this client."
|
|
msgScopeExceeds = "The requested scope exceeds the scope granted by the resource owner."
|
|
)
|
|
|
|
// 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
|
|
scopeInvalid bool // refresh asked for more than the original grant
|
|
}
|
|
|
|
// 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")
|
|
refresh := grant == "refresh_token" && s.oidc.RefreshEnabled
|
|
if grant != "authorization_code" && !refresh {
|
|
msg := msgUnsupportedGrant
|
|
if s.oidc.RefreshEnabled {
|
|
msg = msgUnsupportedGrantRefresh
|
|
}
|
|
writeOIDCTokenError(w, http.StatusBadRequest, "unsupported_grant_type", msg)
|
|
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
|
|
}
|
|
if refresh {
|
|
s.oidcRefreshGrant(w, r, client, clientID)
|
|
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, " "),
|
|
}
|
|
// D-17: only an OfflineAccess client granted offline_access on a
|
|
// server with refresh enabled receives a refresh token.
|
|
if s.oidc.RefreshEnabled && client.OfflineAccess && stringSliceContains(rec.Scopes, "offline_access") {
|
|
rt, err := s.mintRefreshToken(ctx, tx, client.ClientID, rec.UserID, access.id, rec.Scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out.body.RefreshToken = rt
|
|
}
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// oidcRefreshGrant answers grant_type=refresh_token (D-17, server switch
|
|
// OIDCOptions.RefreshEnabled): only an OfflineAccess client may use it
|
|
// (unauthorized_client otherwise); the presented token rotates once, a
|
|
// replay of a rotated token revokes the whole lineage and its access tokens
|
|
// (T-19.4-12), and the answer carries a fresh id_token without nonce.
|
|
func (s *Server) oidcRefreshGrant(w http.ResponseWriter, r *http.Request, client *OIDCClientRecord, clientID string) {
|
|
if !client.OfflineAccess {
|
|
s.logTokenFailure(r, "unauthorized_client", clientID)
|
|
writeOIDCTokenError(w, http.StatusBadRequest, "unauthorized_client", msgRefreshNotAllowed)
|
|
return
|
|
}
|
|
raw := inputValue(r, "refresh_token")
|
|
if raw == "" {
|
|
s.logTokenFailure(r, "invalid_request", clientID)
|
|
writeOIDCTokenError(w, http.StatusBadRequest, "invalid_request", msgRefreshMissing)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
key, err := s.keys.ActiveSigningKey(ctx)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var requested []string
|
|
if scope := inputValue(r, "scope"); strings.TrimSpace(scope) != "" {
|
|
requested = parseOIDCScopes(scope)
|
|
}
|
|
out, err := s.rotateOIDCRefresh(ctx, client, key, raw, requested)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
switch {
|
|
case out.scopeInvalid:
|
|
s.logTokenFailure(r, "invalid_scope", clientID)
|
|
writeOIDCTokenError(w, http.StatusBadRequest, "invalid_scope", msgScopeExceeds)
|
|
case out.reason != "":
|
|
s.logTokenFailure(r, "invalid_grant:"+out.reason, clientID)
|
|
writeOIDCTokenError(w, http.StatusBadRequest, "invalid_grant", msgInvalidRefresh)
|
|
default:
|
|
writeExactJSON(w, http.StatusOK, out.body, oidcNoStore())
|
|
}
|
|
}
|
|
|
|
// rotateOIDCRefresh rotates a refresh token in one OIDC transaction. A
|
|
// replayed (already rotated) token revokes its lineage, and that revocation
|
|
// commits although the answer is invalid_grant.
|
|
func (s *Server) rotateOIDCRefresh(ctx context.Context, client *OIDCClientRecord, key SigningKey, raw string, requested []string) (oidcTokenOutcome, error) {
|
|
var out oidcTokenOutcome
|
|
err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
|
|
now := s.now()
|
|
if !strings.HasPrefix(raw, s.oidc.RefreshTokenPrefix) {
|
|
out.reason = "unknown"
|
|
return nil
|
|
}
|
|
rec, err := tx.RefreshTokenByHashForUpdate(ctx, sha256Hex(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec == nil || rec.ClientID != client.ClientID || rec.RevokedAt != nil || !rec.ExpiresAt.After(now) {
|
|
out.reason = "unknown_or_expired"
|
|
return nil
|
|
}
|
|
if rec.RotatedToID != nil {
|
|
if err := tx.RevokeRefreshLineage(ctx, rec.ID, now); err != nil {
|
|
return err
|
|
}
|
|
out.reason = "replay"
|
|
return nil
|
|
}
|
|
scopes := rec.Scopes
|
|
if requested != nil {
|
|
for _, sc := range requested {
|
|
if !stringSliceContains(rec.Scopes, sc) {
|
|
out.scopeInvalid = true
|
|
return nil
|
|
}
|
|
}
|
|
scopes = requested
|
|
}
|
|
identity, ok, err := tx.IdentityClaims(ctx, rec.UserID, scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
out.reason = "no_user"
|
|
return nil
|
|
}
|
|
access, err := s.mintAccessToken(ctx, tx, client.ClientID, rec.UserID, nil, scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
successor, successorID, err := s.createRefreshToken(ctx, tx, client.ClientID, rec.UserID, access.id, rec.Scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.MarkRefreshRotated(ctx, rec.ID, successorID); err != nil {
|
|
return err
|
|
}
|
|
sub, err := subjectFor(ctx, tx, client, rec.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idToken, err := signIDToken(key, idTokenInput{
|
|
Issuer: s.oidcIssuer(),
|
|
Subject: sub,
|
|
Audience: client.ClientID,
|
|
TTL: s.oidc.IDTokenTTL,
|
|
Now: now,
|
|
AccessToken: access.raw,
|
|
Identity: identity,
|
|
Allowed: s.oidc.IdentityClaims,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out.body = oidcTokenBody{
|
|
AccessToken: access.raw,
|
|
TokenType: "Bearer",
|
|
ExpiresIn: int64(s.oidc.AccessTokenTTL.Seconds()),
|
|
IDToken: idToken,
|
|
RefreshToken: successor,
|
|
Scope: strings.Join(scopes, " "),
|
|
}
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// mintRefreshToken creates a refresh token linked to an access token and
|
|
// returns it.
|
|
func (s *Server) mintRefreshToken(ctx context.Context, tx OIDCTx, clientID string, userID, accessTokenID uint, scopes []string) (string, error) {
|
|
raw, _, err := s.createRefreshToken(ctx, tx, clientID, userID, accessTokenID, scopes)
|
|
return raw, err
|
|
}
|
|
|
|
// createRefreshToken stores a refresh token (RefreshTokenPrefix plus
|
|
// base64url of 32 random bytes, stored hashed, RefreshTokenTTL) and returns
|
|
// it with its row ID.
|
|
func (s *Server) createRefreshToken(ctx context.Context, tx OIDCTx, clientID string, userID, accessTokenID uint, scopes []string) (string, uint, error) {
|
|
body, err := s.randomBytes(32)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
raw := s.oidc.RefreshTokenPrefix + body
|
|
at := accessTokenID
|
|
rec := &OIDCRefreshTokenRecord{
|
|
TokenHash: sha256Hex(raw),
|
|
ClientID: clientID,
|
|
UserID: userID,
|
|
AccessTokenID: &at,
|
|
Scopes: scopes,
|
|
ExpiresAt: s.now().Add(s.oidc.RefreshTokenTTL),
|
|
}
|
|
if err := tx.CreateRefreshToken(ctx, rec); err != nil {
|
|
return "", 0, err
|
|
}
|
|
return raw, rec.ID, nil
|
|
}
|
|
|
|
// 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))
|
|
}
|