feat(19.4-01): OIDC flavour of wristband with an end-to-end tracer

- NewOIDCServer/SetOIDCBackend build a second Server on the same engine;
  Authorize and Token dispatch by flavour, Metadata/Register 404 on OIDC
- OIDCBackend/OIDCTx, KeyStore and OIDCHost seams; MCP Backend untouched
- Discovery (PHP field order), JWKS (stdlib JWK encoding), authorize with
  resume and one-shot consent token, token with an RS256 id_token signed
  through golang-jwt map claims, userinfo
- TestOIDCTracerSmoke verifies the id_token against the served JWKS
This commit is contained in:
2026-10-19 18:18:05 +02:00
parent 756a97c181
commit 659b42aaa8
17 changed files with 3030 additions and 2 deletions

View File

@@ -28,6 +28,7 @@ Use cases:
- Consent operations for the application's own consent screen: `wristband.Server.PendingRequest` (what to show), `wristband.Server.IssueCode` (grant, returning the redirect URL with `code`, `iss` and `state`) and `wristband.Server.DenyPending` (returning an `access_denied` redirect). Missing, foreign, used and expired requests all report the same `wristband.ErrPendingNotFound`.
- Token endpoint (`wristband.Server.Token`): code exchange with PKCE verification, then an access token minted by the application plus a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the linked access tokens. Expired codes and refresh tokens are swept on each call.
- Connected-app revocation: `wristband.Server.Revoke` kills an access token and the refresh lineage attached to it.
- OpenID Connect provider flavour: `wristband.NewOIDCServer` builds a second `wristband.Server` on the same engine with discovery (`wristband.Server.Discovery`), a JWKS endpoint (`wristband.Server.JWKS`), authorization with a resumable request and consent (`wristband.Server.Authorize`, `wristband.Server.Resume`), a token endpoint that adds an `id_token` signed by the active key, and userinfo (`wristband.Server.UserInfo`). Storage arrives through `wristband.OIDCBackend` and `wristband.OIDCTx`, signing keys through `wristband.KeyStore`, and pages and the signed-in user through `wristband.OIDCHost`. The MCP flavour and its interfaces are unchanged.
- Secret handling: client secrets, codes and refresh tokens are random base64url strings, persisted only as SHA-256 hashes and compared in constant time. `wristband.IssueClientCredentials` and `wristband.RejectRedirectURI` expose the same issuing and validation rules to operator tooling that creates clients outside registration.
## Usage
@@ -103,6 +104,35 @@ func Approve(ctx context.Context, w http.ResponseWriter, srv *wristband.Server,
| `wristband.ErrPendingNotFound` | The pending request does not exist for this user or is no longer usable. |
| `wristband.ErrNoGrantableScopes` | `wristband.Server.IssueCode` was called with no scopes. |
| `wristband.ErrClientCapReached` | Returned by `wristband.ClientStore.CreateWithCap` when the client cap is reached. |
| `wristband.NewOIDCServer` | Builds a `wristband.Server` with the OpenID Connect flavour from `wristband.Options` and `wristband.OIDCOptions`. |
| `wristband.Server.SetOIDCBackend` | Attaches the `wristband.OIDCBackend`, the `wristband.KeyStore` and the `wristband.OIDCHost`; errors on an MCP server or a nil argument. |
| `wristband.Server.Discovery` | Handler for `GET /.well-known/openid-configuration` (OIDC servers only). |
| `wristband.Server.JWKS` | Handler for `GET /oauth/jwks`: the active key plus retired keys inside their grace window. |
| `wristband.Server.Resume` | Handler for `GET /oauth/authorize/resume/{request}`: silent code redirect when consent covers the request, otherwise the host's consent page. |
| `wristband.Server.UserInfo` | Handler for `GET /oauth/userinfo`. |
| `wristband.OIDCOptions` | Login URL, lifetimes, refresh switch, advertised scopes and claims, the identity-claim allowlist, subject types and the `oas_` prefixes. |
| `wristband.DefaultOIDCOptions` | The PHP-compatible defaults for `wristband.OIDCOptions`. |
| `wristband.OIDCHost` | The host seam: `wristband.OIDCHost.SignedInUser`, `wristband.OIDCHost.RenderAuthorizeError`, `wristband.OIDCHost.RenderConsent`, `wristband.OIDCHost.EndSession`. |
| `wristband.ConsentView` | What a consent page shows: request, one-shot consent token, client and the scopes not yet consented to. |
| `wristband.AuthorizeErrorUnknownClient` | Rendered error code `unknown_client`; likewise `wristband.AuthorizeErrorRedirectMismatch`, `wristband.AuthorizeErrorInactiveClient` and `wristband.AuthorizeErrorExpiredRequest`. |
| `wristband.ErrAuthRequestExpired` | The parked request is unusable or the consent token did not verify. |
| `wristband.OIDCBackend` | Runs a function inside one transaction with a `wristband.OIDCTx`. |
| `wristband.OIDCTx` | Transaction-scoped bundle of `wristband.OIDCClientStore`, `wristband.AuthRequestStore`, `wristband.OIDCCodeStore`, `wristband.OIDCAccessTokenStore`, `wristband.ConsentStore`, `wristband.SubjectStore`, `wristband.IdentityStore` and `wristband.OIDCRefreshTokenStore`. |
| `wristband.OIDCClientRecord` | An administrator-registered OIDC client: redirect URIs, scopes, active and dev flags, offline access, subject type and sector. |
| `wristband.AuthRequestRecord` | A parked authorization request (no user column) with its consent token digest. |
| `wristband.OIDCCodeRecord` | An authorization code, stored hashed, carrying the nonce. |
| `wristband.OIDCAccessTokenRecord` | An opaque access token, stored hashed, linked to its code. |
| `wristband.OIDCRefreshTokenRecord` | A refresh token, stored hashed, with its rotation link. |
| `wristband.ConsentRecord` | The scopes one user consented to for one client. |
| `wristband.KeyStore` | Supplies the active `wristband.SigningKey` and the published `wristband.PublicKey` ring. |
| `wristband.SigningKey` | Kid, algorithm and signer of the active key. |
| `wristband.PublicKey` | Kid, algorithm and public key of a published key. |
| `wristband.AlgRS256` | The RS256 algorithm name; `wristband.AlgES256` names ES256. |
| `wristband.GenerateSigningKey` | Creates a key and returns its kid, PKCS#8 private PEM and PKIX public PEM. |
| `wristband.ParsePrivateKeyPEM` | Parses a private key PEM into a signer. |
| `wristband.ParsePublicKeyPEM` | Parses a PKIX public key PEM. |
| `wristband.SubjectSector` | Returns a client's subject sector (empty for public clients). |
| `wristband.SubjectTypePublic` | Subject type `public`; `wristband.SubjectTypePairwise` is `pairwise`. |
wristband reads no config keys or environment variables; the application passes a `wristband.Options` value. `wristband.DefaultOptions` sets:
@@ -125,8 +155,8 @@ Always set `wristband.Options.Issuer` and `wristband.Options.Resource` for your
## Dependencies
- SummerCMS modules: none.
- Third-party: none.
- Standard library: `bytes`, `context`, `crypto/rand`, `crypto/sha256`, `crypto/subtle`, `encoding/base64`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `net/http`, `net/url`, `strings`, `time`.
- Third-party: `github.com/golang-jwt/jwt/v5` (id_token signing).
- Standard library: `bytes`, `context`, `crypto`, `crypto/rand`, `crypto/rsa`, `crypto/sha256`, `crypto/subtle`, `crypto/x509`, `encoding/base64`, `encoding/hex`, `encoding/json`, `encoding/pem`, `errors`, `fmt`, `log/slog`, `math/big`, `net/http`, `net/url`, `slices`, `strconv`, `strings`, `time`, `unicode/utf8`, `uuid`.
## Testing

View File

@@ -28,7 +28,14 @@ var authorizeAllowedScopes = map[string]bool{
// redirect response, enforces S256 PKCE syntax, the client's scope ceiling,
// and the RFC 8707 resource check, then persists an opaque pending request
// and redirects to the app's /connect handoff.
//
// On an OIDC server it dispatches to the OIDC flavour before any MCP code
// runs: GET starts an authorization request, POST is the consent decision.
func (s *Server) Authorize(w http.ResponseWriter, r *http.Request) {
if s.flavour == flavourOIDC {
s.oidcAuthorize(w, r)
return
}
if s.backend == nil {
w.WriteHeader(http.StatusInternalServerError)
return

View File

@@ -0,0 +1,520 @@
// 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.
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 {
s.renderAuthorizeError(w, r, AuthorizeErrorUnknownClient)
return
}
if !s.redirectMatches(client, redirectURI) {
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."
}
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."
}
return oidcAuthorizeData{
Scopes: intersectScopes(requested, client.Scopes, s.oidcSupportedScopes()),
State: lastValuePtr(q, "state"),
Nonce: lastValuePtr(q, "nonce"),
Challenge: challenge,
}, "", ""
}
// 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 {
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
}
// redirectMatches reports whether the presented redirect URI is registered
// for the client byte for byte.
func (s *Server) redirectMatches(client *OIDCClientRecord, presented string) bool {
if presented == "" {
return false
}
for _, uri := range client.RedirectURIs {
if uri != "" && constantEqual(uri, presented) {
return true
}
}
return false
}
// 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()
}

View File

@@ -0,0 +1,127 @@
// Consent for the OpenID Connect flavour: the one-shot consent token
// (PHP ConsentCsrf) and the decision logic shared by the HTML handlers and
// the JSON operations a host's own consent page uses.
package wristband
import (
"context"
"strconv"
)
// consentDigest binds a consent token to one user: SHA-256 hex of
// token + "|" + user ID, byte-compatible with PHP ConsentCsrf::bind, so a
// digest copied from the PHP tables stays valid (T-19.4-04).
func consentDigest(token string, userID uint) string {
return sha256Hex(token + "|" + strconv.FormatUint(uint64(userID), 10))
}
// prepareConsent mints a new consent token for the request (replacing any
// stored digest) and builds the consent view listing the scopes the user
// has not consented to yet; openid is never listed.
func (s *Server) prepareConsent(ctx context.Context, tx OIDCTx, req *AuthRequestRecord, client *OIDCClientRecord, userID uint, consent *ConsentRecord) (ConsentView, error) {
showing := req.Scopes
if consent != nil {
showing = missingScopes(req.Scopes, consent.Scopes)
}
listed := []string{}
for _, sc := range showing {
if sc != "openid" && !stringSliceContains(listed, sc) {
listed = append(listed, sc)
}
}
token, err := s.randomBytes(32)
if err != nil {
return ConsentView{}, err
}
digest := consentDigest(token, userID)
if err := tx.SetConsentTokenHash(ctx, req.ID, &digest); err != nil {
return ConsentView{}, err
}
return ConsentView{
RequestID: req.RequestID,
ConsentToken: token,
ClientID: client.ClientID,
ClientName: client.Name,
Scopes: listed,
UserID: userID,
RedirectHost: redirectHost(req.RedirectURI),
Incremental: consent != nil,
}, nil
}
// decideConsent verifies and burns the consent token, then denies
// (consume, access_denied redirect) or allows (consume first, merge the
// consent, mint the code) in one OIDC transaction. It returns the redirect
// URL, or an errAuthorizeRender carrying the code to render. The token is
// cleared even when a later check fails: the transaction commits whenever
// the answer is a protocol error.
func (s *Server) decideConsent(ctx context.Context, requestID string, userID uint, token string, allow bool) (string, error) {
if requestID == "" {
return "", errAuthorizeRender(AuthorizeErrorExpiredRequest)
}
var target string
var failure errAuthorizeRender
err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
req, err := tx.AuthRequestByRequestIDForUpdate(ctx, requestID)
if err != nil {
return err
}
if !s.authRequestUsable(req) {
failure = AuthorizeErrorExpiredRequest
return nil
}
if req.ConsentTokenHash == nil || *req.ConsentTokenHash == "" || token == "" ||
!constantEqual(*req.ConsentTokenHash, consentDigest(token, userID)) {
failure = AuthorizeErrorExpiredRequest
return nil
}
if err := tx.SetConsentTokenHash(ctx, req.ID, nil); err != nil {
return err
}
client, code, err := s.usableClient(ctx, tx, req.ClientID)
if err != nil {
return err
}
if code != "" {
failure = code
return nil
}
now := s.now()
if !allow {
if _, err := tx.ConsumeAuthRequest(ctx, req.ID, now); err != nil {
return err
}
state := ""
if req.State != nil {
state = *req.State
}
target = errorRedirectURL(req.RedirectURI, "access_denied", "", state)
return nil
}
granted := intersectScopes(req.Scopes, client.Scopes, s.oidcSupportedScopes())
consumed, err := tx.ConsumeAuthRequest(ctx, req.ID, now)
if err != nil {
return err
}
if !consumed {
failure = AuthorizeErrorExpiredRequest
return nil
}
if err := tx.SaveConsent(ctx, userID, client.ClientID, granted, now); err != nil {
return err
}
raw, err := s.mintCode(ctx, tx, req, userID, granted)
if err != nil {
return err
}
target = codeRedirect(req, raw)
return nil
})
if err != nil {
return "", err
}
if failure != "" {
return "", failure
}
return target, nil
}

View File

@@ -0,0 +1,168 @@
package wristband
import (
"net/http"
"slices"
"strings"
)
// discoveryDocument is the OpenID Provider Configuration in the PHP
// provider's field order (DiscoveryDocument.php). encoding/json keeps
// declaration order, so this struct is the wire order.
type discoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
JwksURI string `json:"jwks_uri"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
ClaimsSupported []string `json:"claims_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ResponseModesSupported []string `json:"response_modes_supported"`
}
type jwksDocument struct {
Keys []jwkDocument `json:"keys"`
}
// serverMessage is the body of a fail-loud 500 (PHP abort(500, ...)).
type serverMessage struct {
Message string `json:"message"`
}
const (
discoveryCacheControl = "max-age=3600, public"
msgIssuerNotConfigured = "The OpenID Connect issuer is not configured."
msgKeysUnavailable = "Signing keys are unavailable."
)
// oidcIssuer is the configured issuer without trailing slashes (PHP
// rtrim($issuer, '/')).
func (s *Server) oidcIssuer() string {
return strings.TrimRight(s.opts.Issuer, "/")
}
// writeServerMessage answers a fixed 500 message. Underlying errors never
// reach the body (T-19.4-09).
func writeServerMessage(w http.ResponseWriter, message string) {
writeExactJSON(w, http.StatusInternalServerError, serverMessage{Message: message}, map[string]string{"Cache-Control": "no-cache, private"})
}
// Discovery handles GET /.well-known/openid-configuration on an OIDC
// server (404 on an MCP server). It writes the PHP field set in PHP order
// with Cache-Control: max-age=3600, public. subject_types_supported comes
// from OIDCOptions (public and pairwise, D-15) and
// id_token_signing_alg_values_supported lists the algorithms the published
// key ring holds (D-12). refresh_token and offline_access are advertised
// only when OIDCOptions.RefreshEnabled.
func (s *Server) Discovery(w http.ResponseWriter, r *http.Request) {
if s.flavour != flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
iss := s.oidcIssuer()
if iss == "" {
writeServerMessage(w, msgIssuerNotConfigured)
return
}
if !s.oidcReady() {
writeServerMessage(w, msgKeysUnavailable)
return
}
keys, err := s.publishedKeys(r)
if err != nil {
writeServerMessage(w, msgKeysUnavailable)
return
}
var algs []string
for _, alg := range []string{AlgRS256, AlgES256} {
for _, k := range keys {
if k.Alg == alg {
algs = append(algs, alg)
break
}
}
}
if algs == nil {
algs = []string{}
}
grants := []string{"authorization_code"}
scopes := slices.Clone(s.oidc.ScopesSupported)
if s.oidc.RefreshEnabled {
grants = append(grants, "refresh_token")
if !slices.Contains(scopes, "offline_access") {
scopes = append(scopes, "offline_access")
}
}
doc := discoveryDocument{
Issuer: iss,
AuthorizationEndpoint: iss + "/oauth/authorize",
TokenEndpoint: iss + "/oauth/token",
UserinfoEndpoint: iss + "/oauth/userinfo",
JwksURI: iss + "/oauth/jwks",
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: grants,
SubjectTypesSupported: nonNil(s.oidc.SubjectTypesSupported),
IDTokenSigningAlgValuesSupported: algs,
ScopesSupported: nonNil(scopes),
ClaimsSupported: nonNil(s.oidc.ClaimsSupported),
CodeChallengeMethodsSupported: []string{"S256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
ResponseModesSupported: []string{"query"},
}
writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": discoveryCacheControl})
}
// JWKS handles GET /oauth/jwks on an OIDC server (404 on an MCP server):
// the active key plus retired keys inside their grace window, with
// Cache-Control: max-age=3600, public. A key-store failure answers 500
// "Signing keys are unavailable." without the error text (T-19.4-09).
func (s *Server) JWKS(w http.ResponseWriter, r *http.Request) {
if s.flavour != flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
if !s.oidcReady() {
writeServerMessage(w, msgKeysUnavailable)
return
}
keys, err := s.publishedKeys(r)
if err != nil {
writeServerMessage(w, msgKeysUnavailable)
return
}
doc := jwksDocument{Keys: make([]jwkDocument, 0, len(keys))}
for _, k := range keys {
j, err := jwkFor(k)
if err != nil {
writeServerMessage(w, msgKeysUnavailable)
return
}
doc.Keys = append(doc.Keys, j)
}
writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": discoveryCacheControl})
}
// publishedKeys asks for the active key first, so an empty ring gets its
// first key lazily (as the PHP key ring does), then reads the published
// ring.
func (s *Server) publishedKeys(r *http.Request) ([]PublicKey, error) {
ctx := r.Context()
if _, err := s.keys.ActiveSigningKey(ctx); err != nil {
return nil, err
}
return s.keys.PublishedKeys(ctx, s.now())
}
func nonNil(ss []string) []string {
if ss == nil {
return []string{}
}
return ss
}

View File

@@ -0,0 +1,582 @@
package wristband
import (
"context"
"crypto"
"fmt"
"net/http"
"slices"
"sync"
"testing"
"time"
)
// memoryOIDCBackend is the in-memory OIDCBackend/OIDCTx for wristband's
// own tests. One mutex guards the whole transaction closure; it never rolls
// back, which the engine tolerates because it only returns an error from a
// transaction for server failures.
type memoryOIDCBackend struct {
mu sync.Mutex
clients []*OIDCClientRecord
requests []*AuthRequestRecord
codes []*OIDCCodeRecord
tokens []*OIDCAccessTokenRecord
refresh []*OIDCRefreshTokenRecord
consents []*ConsentRecord
subjects map[string]string // "<user>|<sector>" -> sub
identities map[uint]map[string]any
nextID uint
}
func newMemoryOIDCBackend() *memoryOIDCBackend {
return &memoryOIDCBackend{subjects: map[string]string{}, identities: map[uint]map[string]any{}}
}
func (b *memoryOIDCBackend) WithinOIDCTx(ctx context.Context, fn func(OIDCTx) error) error {
b.mu.Lock()
defer b.mu.Unlock()
return fn(&memoryOIDCTx{b: b})
}
func (b *memoryOIDCBackend) id() uint {
b.nextID++
return b.nextID
}
// addClient registers an OIDC client whose secret is secret.
func (b *memoryOIDCBackend) addClient(c OIDCClientRecord, secret string) *OIDCClientRecord {
b.mu.Lock()
defer b.mu.Unlock()
if secret != "" {
h := sha256Hex(secret)
c.SecretHash = &h
}
c.ID = b.id()
cp := c
b.clients = append(b.clients, &cp)
return &cp
}
func (b *memoryOIDCBackend) setIdentity(userID uint, claims map[string]any) {
b.mu.Lock()
defer b.mu.Unlock()
b.identities[userID] = claims
}
func (b *memoryOIDCBackend) subject(userID uint, sector string) string {
b.mu.Lock()
defer b.mu.Unlock()
return b.subjects[fmt.Sprintf("%d|%s", userID, sector)]
}
func (b *memoryOIDCBackend) accessTokens() []OIDCAccessTokenRecord {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]OIDCAccessTokenRecord, len(b.tokens))
for i, t := range b.tokens {
out[i] = *t
}
return out
}
type memoryOIDCTx struct{ b *memoryOIDCBackend }
var _ OIDCTx = (*memoryOIDCTx)(nil)
func (t *memoryOIDCTx) OIDCClientByClientID(ctx context.Context, clientID string) (*OIDCClientRecord, error) {
for _, c := range t.b.clients {
if c.ClientID == clientID {
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) CreateAuthRequest(ctx context.Context, rec *AuthRequestRecord) error {
rec.ID = t.b.id()
cp := *rec
t.b.requests = append(t.b.requests, &cp)
return nil
}
func (t *memoryOIDCTx) findRequest(requestID string) *AuthRequestRecord {
for _, r := range t.b.requests {
if r.RequestID == requestID {
return r
}
}
return nil
}
func (t *memoryOIDCTx) AuthRequestByRequestID(ctx context.Context, requestID string) (*AuthRequestRecord, error) {
if r := t.findRequest(requestID); r != nil {
cp := *r
return &cp, nil
}
return nil, nil
}
func (t *memoryOIDCTx) AuthRequestByRequestIDForUpdate(ctx context.Context, requestID string) (*AuthRequestRecord, error) {
return t.AuthRequestByRequestID(ctx, requestID)
}
func (t *memoryOIDCTx) ConsumeAuthRequest(ctx context.Context, id uint, now time.Time) (bool, error) {
for _, r := range t.b.requests {
if r.ID == id {
if r.ConsumedAt != nil || !r.ExpiresAt.After(now) {
return false, nil
}
n := now
r.ConsumedAt = &n
return true, nil
}
}
return false, nil
}
func (t *memoryOIDCTx) SetConsentTokenHash(ctx context.Context, id uint, hash *string) error {
for _, r := range t.b.requests {
if r.ID == id {
if hash == nil {
r.ConsentTokenHash = nil
} else {
h := *hash
r.ConsentTokenHash = &h
}
}
}
return nil
}
func (t *memoryOIDCTx) CreateCode(ctx context.Context, rec *OIDCCodeRecord) error {
rec.ID = t.b.id()
cp := *rec
t.b.codes = append(t.b.codes, &cp)
return nil
}
func (t *memoryOIDCTx) ConsumeCode(ctx context.Context, codeHash string, now time.Time) (*OIDCCodeRecord, error) {
for _, c := range t.b.codes {
if c.CodeHash == codeHash {
if c.ConsumedAt != nil || !c.ExpiresAt.After(now) {
return nil, nil
}
n := now
c.ConsumedAt = &n
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) CodeByHash(ctx context.Context, codeHash string) (*OIDCCodeRecord, error) {
for _, c := range t.b.codes {
if c.CodeHash == codeHash {
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) CreateAccessToken(ctx context.Context, rec *OIDCAccessTokenRecord) error {
rec.ID = t.b.id()
cp := *rec
t.b.tokens = append(t.b.tokens, &cp)
return nil
}
func (t *memoryOIDCTx) AccessTokenByHash(ctx context.Context, tokenHash string) (*OIDCAccessTokenRecord, error) {
for _, tok := range t.b.tokens {
if tok.TokenHash == tokenHash {
cp := *tok
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) TouchAccessToken(ctx context.Context, id uint, now time.Time) error {
for _, tok := range t.b.tokens {
if tok.ID == id {
n := now
tok.LastUsedAt = &n
}
}
return nil
}
func (t *memoryOIDCTx) revokeAccessToken(id uint, now time.Time) {
for _, tok := range t.b.tokens {
if tok.ID == id && tok.RevokedAt == nil {
n := now
tok.RevokedAt = &n
}
}
}
func (t *memoryOIDCTx) RevokeAccessTokensForCode(ctx context.Context, codeID uint, now time.Time) error {
for _, tok := range t.b.tokens {
if tok.AuthCodeID != nil && *tok.AuthCodeID == codeID {
t.revokeAccessToken(tok.ID, now)
for _, rt := range t.b.refresh {
if rt.AccessTokenID != nil && *rt.AccessTokenID == tok.ID {
if err := t.RevokeRefreshLineage(ctx, rt.ID, now); err != nil {
return err
}
}
}
}
}
return nil
}
func (t *memoryOIDCTx) ConsentFor(ctx context.Context, userID uint, clientID string) (*ConsentRecord, error) {
for _, c := range t.b.consents {
if c.UserID == userID && c.ClientID == clientID {
cp := *c
cp.Scopes = slices.Clone(c.Scopes)
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) SaveConsent(ctx context.Context, userID uint, clientID string, scopes []string, now time.Time) error {
n := now
for _, c := range t.b.consents {
if c.UserID == userID && c.ClientID == clientID {
for _, sc := range scopes {
if !slices.Contains(c.Scopes, sc) {
c.Scopes = append(c.Scopes, sc)
}
}
c.LastUsedAt = &n
return nil
}
}
t.b.consents = append(t.b.consents, &ConsentRecord{
ID: t.b.id(), UserID: userID, ClientID: clientID,
Scopes: slices.Clone(scopes), CreatedAt: now, LastUsedAt: &n,
})
return nil
}
func (t *memoryOIDCTx) TouchConsent(ctx context.Context, id uint, now time.Time) error {
for _, c := range t.b.consents {
if c.ID == id {
n := now
c.LastUsedAt = &n
}
}
return nil
}
func (t *memoryOIDCTx) SubjectFor(ctx context.Context, userID uint, sector, candidate string) (string, error) {
key := fmt.Sprintf("%d|%s", userID, sector)
if sub, ok := t.b.subjects[key]; ok {
return sub, nil
}
t.b.subjects[key] = candidate
return candidate, nil
}
func (t *memoryOIDCTx) IdentityClaims(ctx context.Context, userID uint, scopes []string) (map[string]any, bool, error) {
all, ok := t.b.identities[userID]
if !ok {
return nil, false, nil
}
out := map[string]any{}
if slices.Contains(scopes, "profile") {
for _, k := range []string{"name", "picture", "groups", "communities"} {
if v, ok := all[k]; ok {
out[k] = v
}
}
}
if slices.Contains(scopes, "email") {
for _, k := range []string{"email", "email_verified"} {
if v, ok := all[k]; ok {
out[k] = v
}
}
}
// Extra keys let tests prove the allowlist and protocol-claim guard.
for k, v := range all {
if k == "iss" || k == "aud" || k == "x_extra" {
out[k] = v
}
}
return out, true, nil
}
func (t *memoryOIDCTx) CreateRefreshToken(ctx context.Context, rec *OIDCRefreshTokenRecord) error {
rec.ID = t.b.id()
cp := *rec
t.b.refresh = append(t.b.refresh, &cp)
return nil
}
func (t *memoryOIDCTx) RefreshTokenByHashForUpdate(ctx context.Context, tokenHash string) (*OIDCRefreshTokenRecord, error) {
for _, rt := range t.b.refresh {
if rt.TokenHash == tokenHash {
cp := *rt
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) MarkRefreshRotated(ctx context.Context, id, successorID uint) error {
for _, rt := range t.b.refresh {
if rt.ID == id {
s := successorID
rt.RotatedToID = &s
}
}
return nil
}
func (t *memoryOIDCTx) RevokeRefreshLineage(ctx context.Context, startID uint, now time.Time) error {
next := &startID
for next != nil {
var cur *OIDCRefreshTokenRecord
for _, rt := range t.b.refresh {
if rt.ID == *next {
cur = rt
}
}
if cur == nil {
return nil
}
if cur.RevokedAt == nil {
n := now
cur.RevokedAt = &n
}
if cur.AccessTokenID != nil {
t.revokeAccessToken(*cur.AccessTokenID, now)
}
next = cur.RotatedToID
}
return nil
}
// memoryKeyStore is an in-memory KeyStore built from GenerateSigningKey.
type memoryKeyStore struct {
mu sync.Mutex
keys []*memoryKey
grace time.Duration
// alg is the algorithm of a lazily generated first key.
alg string
}
type memoryKey struct {
kid string
alg string
signer crypto.Signer
public crypto.PublicKey
active bool
retiredAt *time.Time
}
func newMemoryKeyStore() *memoryKeyStore {
return &memoryKeyStore{grace: 24 * time.Hour, alg: AlgRS256}
}
// rotate generates a new active key for alg and retires the old one at
// retiredAt.
func (k *memoryKeyStore) rotate(t *testing.T, alg string, retiredAt time.Time) string {
t.Helper()
k.mu.Lock()
defer k.mu.Unlock()
key, err := generateMemoryKey(alg)
if err != nil {
t.Fatal(err)
}
for _, old := range k.keys {
if old.active {
old.active = false
r := retiredAt
old.retiredAt = &r
}
}
k.keys = append(k.keys, key)
return key.kid
}
func generateMemoryKey(alg string) (*memoryKey, error) {
kid, privPEM, pubPEM, err := GenerateSigningKey(alg)
if err != nil {
return nil, err
}
signer, err := ParsePrivateKeyPEM(privPEM)
if err != nil {
return nil, err
}
pub, err := ParsePublicKeyPEM(pubPEM)
if err != nil {
return nil, err
}
return &memoryKey{kid: kid, alg: alg, signer: signer, public: pub, active: true}, nil
}
func (k *memoryKeyStore) ActiveSigningKey(ctx context.Context) (SigningKey, error) {
k.mu.Lock()
defer k.mu.Unlock()
for _, key := range k.keys {
if key.active {
return SigningKey{Kid: key.kid, Alg: key.alg, Signer: key.signer}, nil
}
}
key, err := generateMemoryKey(k.alg)
if err != nil {
return SigningKey{}, err
}
k.keys = append(k.keys, key)
return SigningKey{Kid: key.kid, Alg: key.alg, Signer: key.signer}, nil
}
func (k *memoryKeyStore) PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error) {
k.mu.Lock()
defer k.mu.Unlock()
var out []PublicKey
for _, key := range k.keys {
if key.active || (key.retiredAt != nil && now.Before(key.retiredAt.Add(k.grace))) {
out = append(out, PublicKey{Kid: key.kid, Alg: key.alg, Key: key.public})
}
}
return out, nil
}
// fakeOIDCHost is a settable OIDCHost that records what the engine asked
// it to render.
type fakeOIDCHost struct {
mu sync.Mutex
userID uint
signedIn bool
views []ConsentView
errors []string
endSessions int
}
func (h *fakeOIDCHost) signIn(userID uint) {
h.mu.Lock()
defer h.mu.Unlock()
h.userID, h.signedIn = userID, true
}
func (h *fakeOIDCHost) signOut() {
h.mu.Lock()
defer h.mu.Unlock()
h.signedIn = false
}
func (h *fakeOIDCHost) SignedInUser(r *http.Request) (uint, bool, error) {
h.mu.Lock()
defer h.mu.Unlock()
return h.userID, h.signedIn, nil
}
func (h *fakeOIDCHost) RenderAuthorizeError(w http.ResponseWriter, r *http.Request, code string) {
h.mu.Lock()
h.errors = append(h.errors, code)
h.mu.Unlock()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(code))
}
func (h *fakeOIDCHost) RenderConsent(w http.ResponseWriter, r *http.Request, view ConsentView) {
h.mu.Lock()
h.views = append(h.views, view)
h.mu.Unlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("consent"))
}
func (h *fakeOIDCHost) EndSession(w http.ResponseWriter, r *http.Request) error {
h.mu.Lock()
defer h.mu.Unlock()
h.endSessions++
h.signedIn = false
return nil
}
func (h *fakeOIDCHost) lastView(t *testing.T) ConsentView {
t.Helper()
h.mu.Lock()
defer h.mu.Unlock()
if len(h.views) == 0 {
t.Fatal("no consent view was rendered")
}
return h.views[len(h.views)-1]
}
// oidcFixture is an OIDC server over the memory fakes plus one active,
// confidential, public-subject client.
type oidcFixture struct {
srv *Server
backend *memoryOIDCBackend
keys *memoryKeyStore
host *fakeOIDCHost
client *OIDCClientRecord
clientSecret string
}
const (
testOIDCIssuer = "https://issuer.example.test"
testOIDCRedirect = "https://rp.example.test/callback"
testOIDCSecret = "oas_cs_test-secret-0123456789abcdefghijklmnopqrstuvwx"
)
func newOIDCFixture(t *testing.T, issuer string, mutate func(*OIDCOptions)) *oidcFixture {
t.Helper()
oidc := DefaultOIDCOptions()
if mutate != nil {
mutate(&oidc)
}
opts := DefaultOptions()
opts.Issuer = issuer
srv := NewOIDCServer(opts, oidc)
f := &oidcFixture{
srv: srv,
backend: newMemoryOIDCBackend(),
keys: newMemoryKeyStore(),
host: &fakeOIDCHost{},
clientSecret: testOIDCSecret,
}
if err := srv.SetOIDCBackend(f.backend, f.keys, f.host); err != nil {
t.Fatal(err)
}
f.client = f.backend.addClient(OIDCClientRecord{
ClientID: "oas_0123456789abcdef01234567",
Name: "Example RP",
Kind: "oidc",
RedirectURIs: []string{testOIDCRedirect},
Scopes: []string{"openid", "profile", "email"},
IsActive: true,
SubjectType: SubjectTypePublic,
}, testOIDCSecret)
f.backend.setIdentity(7, map[string]any{
"name": "Ada Example", "email": "ada@example.test", "email_verified": true,
"groups": []string{"editors"}, "communities": []any{},
"iss": "https://evil.example.test", "x_extra": "dropped",
})
return f
}
// mux mounts the OIDC routes the way a host application would.
func (f *oidcFixture) mux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", f.srv.Discovery)
mux.HandleFunc("GET /oauth/jwks", f.srv.JWKS)
mux.HandleFunc("GET /oauth/authorize", f.srv.Authorize)
mux.HandleFunc("POST /oauth/authorize", f.srv.Authorize)
mux.HandleFunc("GET /oauth/authorize/resume/{request}", f.srv.Resume)
mux.HandleFunc("POST /oauth/token", f.srv.Token)
mux.HandleFunc("GET /oauth/userinfo", f.srv.UserInfo)
return mux
}

View File

@@ -0,0 +1,238 @@
package wristband
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"time"
"github.com/golang-jwt/jwt/v5"
)
// The id_token signing algorithms (D-12).
const (
// AlgRS256 is RSASSA-PKCS1-v1_5 with SHA-256 over an RSA key, the
// default and the algorithm of imported PHP keys.
AlgRS256 = "RS256"
// AlgES256 is ECDSA over P-256 with SHA-256.
AlgES256 = "ES256"
)
// SigningKey is the active signing key. Signer must be the
// *rsa.PrivateKey or *ecdsa.PrivateKey matching Alg, as returned by
// ParsePrivateKeyPEM. Key material reaches the engine only here and is
// never logged (T-19.4-09).
type SigningKey struct {
Kid string
Alg string
Signer crypto.Signer
}
// PublicKey is a published verification key.
type PublicKey struct {
Kid string
Alg string
Key crypto.PublicKey
}
// KeyStore supplies the signing-key ring (D-10..D-13).
type KeyStore interface {
// ActiveSigningKey returns the active key, generating the first key
// when the ring is empty.
ActiveSigningKey(ctx context.Context) (SigningKey, error)
// PublishedKeys returns the active key plus retired keys still inside
// their grace window at now, for JWKS and discovery.
PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error)
}
// GenerateSigningKey creates a new key for alg: RSA 2048 for RS256. It
// returns a kid of 8 random bytes in lowercase hex (16 characters, like the
// PHP key ring), the private key as PKCS#8 PEM and the public key as PKIX
// PEM.
func GenerateSigningKey(alg string) (kid string, privatePEM, publicPEM []byte, err error) {
var priv crypto.Signer
switch alg {
case AlgRS256:
priv, err = rsa.GenerateKey(rand.Reader, 2048)
default:
return "", nil, nil, fmt.Errorf("wristband: unsupported signing algorithm %q", alg)
}
if err != nil {
return "", nil, nil, fmt.Errorf("wristband: generate signing key: %w", err)
}
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", nil, nil, fmt.Errorf("wristband: encode private key: %w", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(priv.Public())
if err != nil {
return "", nil, nil, fmt.Errorf("wristband: encode public key: %w", err)
}
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
return "", nil, nil, fmt.Errorf("wristband: generate kid: %w", err)
}
privatePEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})
publicPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
return hex.EncodeToString(raw), privatePEM, publicPEM, nil
}
// ParsePrivateKeyPEM parses a PKCS#8 private key.
func ParsePrivateKeyPEM(data []byte) (crypto.Signer, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("wristband: no PEM block in private key")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("wristband: unreadable private key")
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("wristband: unsupported private key type")
}
if _, ok := signer.(*rsa.PrivateKey); !ok {
return nil, errors.New("wristband: unsupported private key type")
}
return signer, nil
}
// ParsePublicKeyPEM parses a PKIX public key.
func ParsePublicKeyPEM(data []byte) (crypto.PublicKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("wristband: no PEM block in public key")
}
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, errors.New("wristband: unreadable public key")
}
switch key.(type) {
case *rsa.PublicKey:
return key, nil
}
return nil, errors.New("wristband: unsupported public key type")
}
// jwkDocument is one JWKS entry. encoding/json keeps declaration order and
// omitempty drops the members of the other key type, so RSA keys serialise
// as kty, use, alg, kid, n, e and EC keys as kty, use, alg, kid, crv, x, y.
type jwkDocument struct {
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Kid string `json:"kid"`
Crv string `json:"crv,omitempty"`
N string `json:"n,omitempty"`
E string `json:"e,omitempty"`
X string `json:"x,omitempty"`
Y string `json:"y,omitempty"`
}
// jwkFor encodes a published key with the standard library (RFC 7518
// §6.3.1: n and e are minimal big-endian bytes). Every JWK carries use sig
// and its alg; an Alg that does not match the key type is refused
// (T-19.4-08).
func jwkFor(k PublicKey) (jwkDocument, error) {
b64 := base64.RawURLEncoding.EncodeToString
switch key := k.Key.(type) {
case *rsa.PublicKey:
if k.Alg != AlgRS256 {
return jwkDocument{}, errors.New("wristband: RSA key with a non-RSA algorithm")
}
return jwkDocument{
Kty: "RSA", Use: "sig", Alg: k.Alg, Kid: k.Kid,
N: b64(key.N.Bytes()), E: b64(big.NewInt(int64(key.E)).Bytes()),
}, nil
}
return jwkDocument{}, errors.New("wristband: unsupported public key type")
}
// idTokenInput carries the claims of one id_token (PHP TokenIssuer).
type idTokenInput struct {
Issuer string
Subject string
Audience string
TTL time.Duration
Now time.Time
Nonce string
AccessToken string
Identity map[string]any
Allowed []string
}
// protocolClaims are the claims the engine owns; an identity claim with one
// of these names is never copied (T-19.4-11).
var protocolClaims = map[string]bool{
"iss": true, "sub": true, "aud": true, "exp": true, "iat": true,
"nonce": true, "at_hash": true, "auth_time": true, "azp": true, "nbf": true, "jti": true,
}
// signIDToken signs an id_token with golang-jwt map claims, so aud stays a
// JSON string (the package global MarshalSingleStringAsArray only affects
// jwt.ClaimStrings and is never touched). Allowlisted identity claims are
// written first, then the protocol claims, then sub once more, so a user
// attribute can never overwrite a protocol claim (T-19.4-11).
func signIDToken(key SigningKey, in idTokenInput) (string, error) {
method, err := signingMethodFor(key)
if err != nil {
return "", err
}
claims := jwt.MapClaims{}
for _, name := range in.Allowed {
if protocolClaims[name] {
continue
}
if v, ok := in.Identity[name]; ok {
claims[name] = v
}
}
claims["iss"] = in.Issuer
claims["sub"] = in.Subject
claims["aud"] = in.Audience
claims["exp"] = in.Now.Add(in.TTL).Unix()
claims["iat"] = in.Now.Unix()
if in.Nonce != "" {
claims["nonce"] = in.Nonce
}
if in.AccessToken != "" {
claims["at_hash"] = atHash(in.AccessToken)
}
claims["sub"] = in.Subject
tok := jwt.NewWithClaims(method, claims)
tok.Header["kid"] = key.Kid
signed, err := tok.SignedString(key.Signer)
if err != nil {
return "", errors.New("wristband: id_token signing failed")
}
return signed, nil
}
// signingMethodFor derives the JWS method from the key type and refuses a
// key whose Alg does not match it (T-19.4-08).
func signingMethodFor(key SigningKey) (jwt.SigningMethod, error) {
switch key.Alg {
case AlgRS256:
if _, ok := key.Signer.(*rsa.PrivateKey); ok {
return jwt.SigningMethodRS256, nil
}
}
return nil, errors.New("wristband: signing key does not match its algorithm")
}
// atHash is base64url of the left half of SHA-256 over the access token
// (OIDC Core §3.1.3.6; RS256 and ES256 both hash with SHA-256).
func atHash(accessToken string) string {
sum := sha256.Sum256([]byte(accessToken))
return base64.RawURLEncoding.EncodeToString(sum[:16])
}

View File

@@ -0,0 +1,233 @@
// OpenID Connect flavour of the authorization server (19.4 D-01, D-06,
// D-09). One package serves two flavours: NewServer builds the MCP
// authorization server exactly as before; NewOIDCServer builds a second
// *Server whose flavour selects the OIDC validation and response code. Both
// share the primitives in crypto.go and redirect_html.go. OIDC storage,
// signing keys and the host's HTML arrive through the separate OIDCBackend,
// KeyStore and OIDCHost seams, so the MCP Backend and Tx are untouched.
package wristband
import (
"errors"
"net/http"
"time"
)
// flavour selects which orchestration code a Server runs. The zero value
// is the MCP flavour, so every Server built by NewServer stays MCP.
type flavour int
const (
flavourMCP flavour = iota
flavourOIDC
)
// OIDCOptions configures the OpenID Connect flavour. The issuer is shared
// with the MCP flavour through Options.Issuer. DefaultOIDCOptions returns
// the defaults of the PHP provider this flavour is compatible with.
type OIDCOptions struct {
// LoginURL is where an anonymous user is sent with a single
// redirect=<resume path> query parameter (D-04). A trailing slash is
// trimmed; an empty value sends the user to "/". Default: "/login".
LoginURL string
// IDTokenTTL is the id_token lifetime (its exp claim). Default: 1 hour.
IDTokenTTL time.Duration
// AccessTokenTTL is the lifetime of an opaque access token, which is
// only usable at the userinfo endpoint. Default: 1 hour.
AccessTokenTTL time.Duration
// CodeTTL is the lifetime of an authorization code. Default: 60 seconds.
CodeTTL time.Duration
// AuthRequestTTL is how long a parked authorization request can be
// resumed after the login handoff. Default: 10 minutes.
AuthRequestTTL time.Duration
// RefreshEnabled turns on the refresh_token grant for clients with
// OfflineAccess that were granted the offline_access scope (D-17). Off,
// the token endpoint answers the grant exactly like the PHP provider
// (unsupported_grant_type) and discovery does not advertise it.
// Default: false.
RefreshEnabled bool
// RefreshTokenTTL is the lifetime of a refresh token. Default: 30 days.
RefreshTokenTTL time.Duration
// ScopesSupported is the discovery scopes_supported list and the set of
// scopes authorize accepts (offline_access is added when
// RefreshEnabled). Default: openid, profile, email.
ScopesSupported []string
// ClaimsSupported is the discovery claims_supported list. Default: sub,
// name, picture, groups, communities, email, email_verified, iss, aud,
// exp, iat, nonce.
ClaimsSupported []string
// IdentityClaims is the allowlist of identity claims copied from
// IdentityStore.IdentityClaims into the id_token and the userinfo
// answer (T-19.4-11). Default: sub, name, picture, groups, communities,
// email, email_verified.
IdentityClaims []string
// SubjectTypesSupported is the discovery subject_types_supported list
// (D-15). Default: public, pairwise.
SubjectTypesSupported []string
// ClientIDPrefix is the prefix IssueOIDCClientCredentials puts on new
// client IDs. Default: "oas_".
ClientIDPrefix string
// ClientSecretPrefix is the prefix every client secret carries; a
// presented secret without it is refused before hashing. Default:
// "oas_cs_".
ClientSecretPrefix string
// CodePrefix is the prefix every authorization code carries. Default:
// "oas_ac_".
CodePrefix string
// AccessTokenPrefix is the prefix every access token carries. Default:
// "oas_at_".
AccessTokenPrefix string
// RefreshTokenPrefix is the prefix every refresh token carries.
// Default: "oas_rt_".
RefreshTokenPrefix string
}
// DefaultOIDCOptions returns the defaults of the PHP OpenID Connect
// provider this flavour is compatible with.
func DefaultOIDCOptions() OIDCOptions {
return OIDCOptions{
LoginURL: "/login",
IDTokenTTL: 3600 * time.Second,
AccessTokenTTL: 3600 * time.Second,
CodeTTL: 60 * time.Second,
AuthRequestTTL: 600 * time.Second,
RefreshEnabled: false,
RefreshTokenTTL: 30 * 24 * time.Hour,
ScopesSupported: []string{"openid", "profile", "email"},
ClaimsSupported: []string{"sub", "name", "picture", "groups", "communities", "email", "email_verified", "iss", "aud", "exp", "iat", "nonce"},
IdentityClaims: []string{"sub", "name", "picture", "groups", "communities", "email", "email_verified"},
SubjectTypesSupported: []string{SubjectTypePublic, SubjectTypePairwise},
ClientIDPrefix: "oas_",
ClientSecretPrefix: "oas_cs_",
CodePrefix: "oas_ac_",
AccessTokenPrefix: "oas_at_",
RefreshTokenPrefix: "oas_rt_",
}
}
// NewOIDCServer builds a Server with the OpenID Connect flavour. It shares
// Options (the issuer above all) and the clock and entropy seams with the
// MCP flavour. Storage, keys and the host arrive through SetOIDCBackend;
// until then every handler answers 500. The MCP-only handlers (Metadata,
// Register) answer 404 on an OIDC server.
func NewOIDCServer(opts Options, oidc OIDCOptions) *Server {
return &Server{
opts: opts,
flavour: flavourOIDC,
oidc: oidc,
now: time.Now,
randomBytes: randomBase64URL,
}
}
// SetOIDCBackend attaches the OIDC storage, the signing-key store and the
// host seam (D-09). It returns an error on an MCP server or when any
// argument is nil, so a misconfigured application fails at boot rather than
// on the first sign-in.
func (s *Server) SetOIDCBackend(b OIDCBackend, keys KeyStore, host OIDCHost) error {
if s.flavour != flavourOIDC {
return errors.New("wristband: SetOIDCBackend called on an MCP server")
}
if b == nil {
return errors.New("wristband: OIDC backend is nil")
}
if keys == nil {
return errors.New("wristband: key store is nil")
}
if host == nil {
return errors.New("wristband: OIDC host is nil")
}
s.oidcBackend = b
s.keys = keys
s.host = host
return nil
}
// oidcReady reports whether SetOIDCBackend has run on an OIDC server.
func (s *Server) oidcReady() bool {
return s.flavour == flavourOIDC && s.oidcBackend != nil && s.keys != nil && s.host != nil
}
// The rendered authorize error cases. The engine never redirects for these
// (RFC 6749 §4.1.2.1, T-19.4-01): the host renders a page with status 400.
const (
// AuthorizeErrorUnknownClient: the client_id is missing, unknown or not
// an OIDC client.
AuthorizeErrorUnknownClient = "unknown_client"
// AuthorizeErrorRedirectMismatch: the redirect_uri is not registered
// for the client.
AuthorizeErrorRedirectMismatch = "redirect_mismatch"
// AuthorizeErrorInactiveClient: the client exists but is switched off.
AuthorizeErrorInactiveClient = "inactive_client"
// AuthorizeErrorExpiredRequest: the parked request is missing, expired
// or already used, or the consent token did not verify.
AuthorizeErrorExpiredRequest = "expired_request"
)
// ErrAuthRequestExpired is returned by ConsentRequest and DecideConsent
// when the parked request is missing, expired or consumed, when its client
// is gone or inactive, and for every consent token failure (missing,
// forged, minted for another user, reused). They all collapse to one error
// so a caller cannot tell them apart (T-19.4-04).
var ErrAuthRequestExpired = errors.New("wristband: authorization request expired")
// OIDCHost is the seam through which the engine reaches the application:
// who is signed in, how pages look and how a session ends. The engine owns
// validation, redirects, headers and the consent CSRF token; the host owns
// HTML and users.
//
// Before calling RenderAuthorizeError or RenderConsent the engine sets
// X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none',
// Cache-Control: no-store, private and Referrer-Policy: no-referrer. The
// host writes the status and the body.
type OIDCHost interface {
// SignedInUser returns the signed-in user of the request. An error is
// treated like an anonymous request (the user is sent to log in).
SignedInUser(r *http.Request) (userID uint, ok bool, err error)
// RenderAuthorizeError writes the error page for one of the
// AuthorizeError* codes. It must write status 400.
RenderAuthorizeError(w http.ResponseWriter, r *http.Request, code string)
// RenderConsent writes the consent page for view with status 200. A
// host whose consent page lives in its own frontend may write its own
// redirect there instead and finish through ConsentRequest and
// DecideConsent.
RenderConsent(w http.ResponseWriter, r *http.Request, view ConsentView)
// EndSession signs the current user out (switch account). It may set
// cookies on w but must not write the status or the body.
EndSession(w http.ResponseWriter, r *http.Request) error
}
// ConsentView is what a consent page needs. ConsentToken is a one-shot
// CSRF token bound to the request and the user (T-19.4-04): the page posts
// it back with request_id and decision (or to switch account).
type ConsentView struct {
RequestID string
ConsentToken string
ClientID string
ClientName string
// Scopes lists only the scopes the user has not consented to yet;
// openid is never listed.
Scopes []string
UserID uint
// RedirectHost is the host of the client's redirect URI, for the "you
// will be sent to" line.
RedirectHost string
// Incremental is true when the user already consented to some of the
// client's scopes and is asked only for the new ones.
Incremental bool
}

View File

@@ -0,0 +1,79 @@
package wristband
import (
"fmt"
"strings"
)
// phpURLEncode ports PHP urlencode (the encoder behind http_build_query's
// default PHP_QUERY_RFC1738): A-Z a-z 0-9 . _ - pass, a space becomes +,
// every other byte becomes an uppercase %XX triplet, so a tilde is %7E.
// The MCP flavour's rfc3986Escape must not be used for OIDC redirects
// (D-14, Pitfall 2).
func phpURLEncode(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-':
b.WriteByte(c)
case c == ' ':
b.WriteByte('+')
default:
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
// appendPHPQuery appends pairs to uri in the given order with
// http_build_query semantics, using & when uri already carries a query and
// ? otherwise (PHP str_contains($uri, '?') ? '&' : '?').
func appendPHPQuery(uri string, pairs [][2]string) string {
parts := make([]string, len(pairs))
for i, p := range pairs {
parts[i] = phpURLEncode(p[0]) + "=" + phpURLEncode(p[1])
}
sep := "?"
if strings.Contains(uri, "?") {
sep = "&"
}
return uri + sep + strings.Join(parts, "&")
}
// phpURLDecode ports PHP urldecode: + becomes a space, a % followed by two
// hex digits becomes that byte, anything else (a lone or malformed %) is
// kept as it is.
func phpURLDecode(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '+':
b.WriteByte(' ')
case c == '%' && i+2 < len(s) && isHex(s[i+1]) && isHex(s[i+2]):
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
default:
b.WriteByte(c)
}
}
return b.String()
}
func isHex(c byte) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func unhex(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
default:
return c - 'A' + 10
}
}

View File

@@ -0,0 +1,227 @@
package wristband
import (
"context"
"time"
)
// OIDCClientRecord is the persisted shape of an administrator-registered
// OpenID Connect client. The OIDC flavour reads clients only through
// OIDCClientStore and refuses any record whose Kind is not ClientKindOIDC
// (D-08, T-19.4-05).
type OIDCClientRecord struct {
ID uint
ClientID string
Name string
Kind string
// SecretHash is the SHA-256 hex digest of the client secret (secrets
// carry OIDCOptions.ClientSecretPrefix and are never stored in clear).
SecretHash *string
RedirectURIs []string
// Scopes is the set of scopes the client may request.
Scopes []string
IsActive bool
// IsDev lets an http loopback redirect URI match a registered one that
// differs only in the port (T-19.4-01).
IsDev bool
// OfflineAccess allows the client to receive refresh tokens when the
// server has OIDCOptions.RefreshEnabled (D-17).
OfflineAccess bool
// SubjectType is SubjectTypePublic (or empty) or SubjectTypePairwise.
SubjectType string
// SectorIdentifier is the pairwise sector an administrator set. Empty
// means the single host of RedirectURIs (D-15).
SectorIdentifier string
}
// AuthRequestRecord is a parked authorization request. It deliberately has
// no user column: the user is resolved again on every resume, and the
// consent token digest binds a pending decision to one user (T-19.4-04).
type AuthRequestRecord struct {
ID uint
RequestID string
ClientID string
RedirectURI string
Scopes []string
State *string
Nonce *string
CodeChallenge string
CodeChallengeMethod string
// ConsentTokenHash is SHA-256 hex of token + "|" + user ID while a
// consent page is outstanding, nil otherwise.
ConsentTokenHash *string
ExpiresAt time.Time
ConsumedAt *time.Time
}
// OIDCCodeRecord is an issued authorization code, stored by the SHA-256 hex
// digest of the code.
type OIDCCodeRecord struct {
ID uint
CodeHash string
ClientID string
UserID uint
RedirectURI string
Scopes []string
CodeChallenge string
CodeChallengeMethod string
Nonce *string
ExpiresAt time.Time
ConsumedAt *time.Time
}
// OIDCAccessTokenRecord is an opaque access token, stored by the SHA-256
// hex digest of the token. AuthCodeID links tokens issued from a code so a
// replayed code can revoke them (T-19.4-03).
type OIDCAccessTokenRecord struct {
ID uint
TokenHash string
ClientID string
UserID uint
AuthCodeID *uint
Scopes []string
ExpiresAt time.Time
RevokedAt *time.Time
LastUsedAt *time.Time
}
// OIDCRefreshTokenRecord is a refresh token (D-17), stored by the SHA-256
// hex digest of the token. A rotated token points at its successor through
// RotatedToID and stays as replay evidence (T-19.4-12).
type OIDCRefreshTokenRecord struct {
ID uint
TokenHash string
ClientID string
UserID uint
AccessTokenID *uint
Scopes []string
ExpiresAt time.Time
RevokedAt *time.Time
RotatedToID *uint
}
// ConsentRecord is the one row per (user, client) pair holding every scope
// the user consented to.
type ConsentRecord struct {
ID uint
UserID uint
ClientID string
Scopes []string
CreatedAt time.Time
LastUsedAt *time.Time
}
// OIDCClientStore reads OIDC clients. Not found is (nil, nil).
type OIDCClientStore interface {
OIDCClientByClientID(ctx context.Context, clientID string) (*OIDCClientRecord, error)
}
// AuthRequestStore persists parked authorization requests. Not found is
// (nil, nil).
type AuthRequestStore interface {
// CreateAuthRequest stores rec and fills rec.ID.
CreateAuthRequest(ctx context.Context, rec *AuthRequestRecord) error
AuthRequestByRequestID(ctx context.Context, requestID string) (*AuthRequestRecord, error)
// AuthRequestByRequestIDForUpdate reads the row under a row lock, so a
// consent token is verified and cleared by one caller only (T-19.4-04).
AuthRequestByRequestIDForUpdate(ctx context.Context, requestID string) (*AuthRequestRecord, error)
// ConsumeAuthRequest marks the request consumed atomically (UPDATE ...
// WHERE id = ? AND consumed_at IS NULL AND expires_at > now) and
// reports whether exactly one row moved. The loser of a race gets
// false.
ConsumeAuthRequest(ctx context.Context, id uint, now time.Time) (bool, error)
// SetConsentTokenHash replaces (or, with nil, clears) the consent token
// digest of the request.
SetConsentTokenHash(ctx context.Context, id uint, hash *string) error
}
// OIDCCodeStore persists authorization codes. Not found is (nil, nil).
type OIDCCodeStore interface {
// CreateCode stores rec and fills rec.ID.
CreateCode(ctx context.Context, rec *OIDCCodeRecord) error
// ConsumeCode marks the code consumed atomically (UPDATE ... WHERE
// code_hash = ? AND consumed_at IS NULL AND expires_at > now) and
// returns the row when exactly one row moved, nil otherwise. The token
// endpoint calls it before any other check (burn then check,
// T-19.4-03).
ConsumeCode(ctx context.Context, codeHash string, now time.Time) (*OIDCCodeRecord, error)
// CodeByHash reads a code regardless of its state, so a replay of a
// consumed code can be told apart from an unknown one.
CodeByHash(ctx context.Context, codeHash string) (*OIDCCodeRecord, error)
}
// OIDCAccessTokenStore persists opaque access tokens. Not found is
// (nil, nil).
type OIDCAccessTokenStore interface {
// CreateAccessToken stores rec and fills rec.ID.
CreateAccessToken(ctx context.Context, rec *OIDCAccessTokenRecord) error
AccessTokenByHash(ctx context.Context, tokenHash string) (*OIDCAccessTokenRecord, error)
// TouchAccessToken stamps LastUsedAt.
TouchAccessToken(ctx context.Context, id uint, now time.Time) error
// RevokeAccessTokensForCode revokes every access token issued from the
// code, and every refresh token linked to one of them (T-19.4-03).
RevokeAccessTokensForCode(ctx context.Context, codeID uint, now time.Time) error
}
// ConsentStore persists consents, one row per (user, client) pair. Not
// found is (nil, nil).
type ConsentStore interface {
ConsentFor(ctx context.Context, userID uint, clientID string) (*ConsentRecord, error)
// SaveConsent merges scopes into the pair's row (creating it when
// absent) and stamps LastUsedAt with now.
SaveConsent(ctx context.Context, userID uint, clientID string, scopes []string, now time.Time) error
// TouchConsent stamps LastUsedAt.
TouchConsent(ctx context.Context, id uint, now time.Time) error
}
// SubjectStore allocates subjects (D-15, T-19.4-13).
type SubjectStore interface {
// SubjectFor returns the stored subject of (userID, sector), inserting
// candidate (a random UUIDv4 the engine generated) only when none
// exists. The public sector is "".
SubjectFor(ctx context.Context, userID uint, sector, candidate string) (string, error)
}
// IdentityStore hands the user's identity claims to the engine.
type IdentityStore interface {
// IdentityClaims returns the claims the scopes release (name, picture,
// email, ...). ok is false when the user no longer exists. The engine
// filters the map through OIDCOptions.IdentityClaims and sets sub
// itself.
IdentityClaims(ctx context.Context, userID uint, scopes []string) (claims map[string]any, ok bool, err error)
}
// OIDCRefreshTokenStore persists refresh tokens (D-17). Not found is
// (nil, nil).
type OIDCRefreshTokenStore interface {
// CreateRefreshToken stores rec and fills rec.ID.
CreateRefreshToken(ctx context.Context, rec *OIDCRefreshTokenRecord) error
// RefreshTokenByHashForUpdate reads the row under a row lock, so one
// refresh token rotates once (T-19.4-12).
RefreshTokenByHashForUpdate(ctx context.Context, tokenHash string) (*OIDCRefreshTokenRecord, error)
// MarkRefreshRotated links a rotated token to its successor.
MarkRefreshRotated(ctx context.Context, id, successorID uint) error
// RevokeRefreshLineage revokes startID and every token it was rotated
// to (following RotatedToID), and the access token linked to each.
RevokeRefreshLineage(ctx context.Context, startID uint, now time.Time) error
}
// OIDCTx bundles every OIDC store on one transaction-scoped handle.
type OIDCTx interface {
OIDCClientStore
AuthRequestStore
OIDCCodeStore
OIDCAccessTokenStore
ConsentStore
SubjectStore
IdentityStore
OIDCRefreshTokenStore
}
// OIDCBackend opens one transaction per call. When fn returns an error the
// transaction rolls back; the engine returns nil from fn whenever a write
// must commit although the protocol answer is an error (a replayed code
// revokes its tokens, T-19.4-03).
type OIDCBackend interface {
WithinOIDCTx(ctx context.Context, fn func(OIDCTx) error) error
}

View File

@@ -0,0 +1,39 @@
package wristband
import (
"context"
"errors"
"uuid"
)
// The subject types a client can have (D-15).
const (
// SubjectTypePublic gives a user the same sub at every public client.
SubjectTypePublic = "public"
// SubjectTypePairwise gives a user a different sub per sector.
SubjectTypePairwise = "pairwise"
)
// SubjectSector returns the subject sector of a client: "" for a public
// client.
func SubjectSector(c *OIDCClientRecord) (string, error) {
if c == nil {
return "", errors.New("wristband: no client")
}
switch c.SubjectType {
case "", SubjectTypePublic:
return "", nil
}
return "", errors.New("wristband: unknown subject type")
}
// subjectFor allocates (or reads) the subject of userID at client: a random
// UUIDv4 per (user, sector), never derived from an account attribute
// (T-19.4-13).
func subjectFor(ctx context.Context, tx OIDCTx, client *OIDCClientRecord, userID uint) (string, error) {
sector, err := SubjectSector(client)
if err != nil {
return "", err
}
return tx.SubjectFor(ctx, userID, sector, uuid.NewV4().String())
}

View File

@@ -0,0 +1,328 @@
// 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.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))
}

View File

@@ -0,0 +1,270 @@
package wristband
import (
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
)
// oidcTestVerifier is a fixed PKCE verifier (43 characters).
const oidcTestVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// noRedirectClient returns an HTTP client that never follows redirects.
func noRedirectClient() *http.Client {
return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
func readBody(t *testing.T, res *http.Response) []byte {
t.Helper()
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
return b
}
// startOIDCTestServer serves the fixture's routes on an httptest server
// whose URL is the issuer.
func startOIDCTestServer(t *testing.T, mutate func(*OIDCOptions)) (*oidcFixture, *httptest.Server) {
t.Helper()
ts := httptest.NewUnstartedServer(nil)
f := newOIDCFixture(t, "http://"+ts.Listener.Addr().String(), mutate)
ts.Config.Handler = f.mux()
ts.Start()
t.Cleanup(ts.Close)
return f, ts
}
// rsaKeyFromJWK rebuilds an RSA public key from a served JWK with the
// standard library only.
func rsaKeyFromJWK(t *testing.T, k map[string]any) *rsa.PublicKey {
t.Helper()
n, err := base64.RawURLEncoding.DecodeString(k["n"].(string))
if err != nil {
t.Fatal(err)
}
e, err := base64.RawURLEncoding.DecodeString(k["e"].(string))
if err != nil {
t.Fatal(err)
}
return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
}
// TestOIDCTracerSmoke drives a relying party end to end over the memory
// fakes: discovery, JWKS, authorize as a signed-in user, resume, consent,
// code exchange with client_secret_basic, id_token verification with
// golang-jwt against the served JWKS, and userinfo.
func TestOIDCTracerSmoke(t *testing.T) {
f, ts := startOIDCTestServer(t, nil)
hc := noRedirectClient()
// Discovery: the relying party uses only the URLs it reads here.
res, err := hc.Get(ts.URL + "/.well-known/openid-configuration")
if err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("discovery status = %d", res.StatusCode)
}
if cc := res.Header.Get("Cache-Control"); cc != "max-age=3600, public" {
t.Fatalf("discovery Cache-Control = %q", cc)
}
var doc map[string]any
if err := json.Unmarshal(readBody(t, res), &doc); err != nil {
t.Fatal(err)
}
if doc["issuer"] != ts.URL {
t.Fatalf("issuer = %v, want %s", doc["issuer"], ts.URL)
}
if fmt.Sprint(doc["id_token_signing_alg_values_supported"]) != "[RS256]" {
t.Fatalf("algs = %v", doc["id_token_signing_alg_values_supported"])
}
// JWKS.
res, err = hc.Get(doc["jwks_uri"].(string))
if err != nil {
t.Fatal(err)
}
var jwks struct {
Keys []map[string]any `json:"keys"`
}
if err := json.Unmarshal(readBody(t, res), &jwks); err != nil {
t.Fatal(err)
}
if len(jwks.Keys) != 1 || jwks.Keys[0]["kty"] != "RSA" || jwks.Keys[0]["use"] != "sig" || jwks.Keys[0]["alg"] != "RS256" {
t.Fatalf("jwks = %v", jwks.Keys)
}
// Authorize as a signed-in user.
f.host.signIn(7)
authz := doc["authorization_endpoint"].(string)
q := url.Values{
"response_type": {"code"},
"client_id": {f.client.ClientID},
"redirect_uri": {testOIDCRedirect},
"scope": {"openid profile email"},
"state": {"st-1"},
"nonce": {"n-123"},
"code_challenge": {s256Challenge(oidcTestVerifier)},
"code_challenge_method": {"S256"},
}
res, err = hc.Get(authz + "?" + q.Encode())
if err != nil {
t.Fatal(err)
}
readBody(t, res)
loc := res.Header.Get("Location")
if res.StatusCode != http.StatusFound || !strings.HasPrefix(loc, "/oauth/authorize/resume/") {
t.Fatalf("authorize = %d %q", res.StatusCode, loc)
}
// Resume renders consent through the host.
res, err = hc.Get(ts.URL + loc)
if err != nil {
t.Fatal(err)
}
readBody(t, res)
if res.StatusCode != http.StatusOK || res.Header.Get("X-Frame-Options") != "DENY" {
t.Fatalf("resume = %d, X-Frame-Options %q", res.StatusCode, res.Header.Get("X-Frame-Options"))
}
view := f.host.lastView(t)
if strings.Join(view.Scopes, " ") != "profile email" || view.ConsentToken == "" {
t.Fatalf("view = %+v", view)
}
// Allow.
res, err = hc.PostForm(authz, url.Values{
"request_id": {view.RequestID},
"consent_token": {view.ConsentToken},
"decision": {"allow"},
})
if err != nil {
t.Fatal(err)
}
readBody(t, res)
back, err := url.Parse(res.Header.Get("Location"))
if err != nil || res.StatusCode != http.StatusFound || !strings.HasPrefix(back.String(), testOIDCRedirect+"?code=oas_ac_") {
t.Fatalf("decide = %d %q", res.StatusCode, res.Header.Get("Location"))
}
if back.Query().Get("state") != "st-1" || back.Query().Has("iss") {
t.Fatalf("decide query = %v", back.Query())
}
code := back.Query().Get("code")
// Exchange with client_secret_basic, credentials form-encoded as the
// x/oauth2 client sends them.
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {testOIDCRedirect},
"code_verifier": {oidcTestVerifier},
}
req, _ := http.NewRequest(http.MethodPost, doc["token_endpoint"].(string), strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(url.QueryEscape(f.client.ClientID), url.QueryEscape(f.clientSecret))
res, err = hc.Do(req)
if err != nil {
t.Fatal(err)
}
body := readBody(t, res)
if res.StatusCode != http.StatusOK {
t.Fatalf("token = %d %s", res.StatusCode, body)
}
if res.Header.Get("Cache-Control") != "no-store, private" || res.Header.Get("Pragma") != "no-cache" {
t.Fatalf("token headers = %v", res.Header)
}
var tok struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
IDToken string `json:"id_token"`
Scope string `json:"scope"`
}
if err := json.Unmarshal(body, &tok); err != nil {
t.Fatal(err)
}
if tok.TokenType != "Bearer" || tok.ExpiresIn != 3600 || tok.Scope != "openid profile email" || !strings.HasPrefix(tok.AccessToken, "oas_at_") {
t.Fatalf("token body = %s", body)
}
// Verify the id_token with golang-jwt against the served JWKS only.
keyfunc := func(tk *jwt.Token) (any, error) {
kid, _ := tk.Header["kid"].(string)
for _, k := range jwks.Keys {
if k["kid"] == kid {
return rsaKeyFromJWK(t, k), nil
}
}
return nil, fmt.Errorf("unknown kid %q", kid)
}
claims := jwt.MapClaims{}
if _, err := jwt.ParseWithClaims(tok.IDToken, claims, keyfunc,
jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer(ts.URL), jwt.WithAudience(f.client.ClientID)); err != nil {
t.Fatalf("id_token does not verify: %v", err)
}
payload, err := base64.RawURLEncoding.DecodeString(strings.Split(tok.IDToken, ".")[1])
if err != nil {
t.Fatal(err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
t.Fatal(err)
}
if string(raw["aud"]) != `"`+f.client.ClientID+`"` {
t.Fatalf("aud = %s, want a JSON string", raw["aud"])
}
wantSub := f.backend.subject(7, "")
if wantSub == "" || claims["sub"] != wantSub {
t.Fatalf("sub = %v, want %q", claims["sub"], wantSub)
}
if claims["nonce"] != "n-123" {
t.Fatalf("nonce = %v", claims["nonce"])
}
sum := sha256.Sum256([]byte(tok.AccessToken))
if claims["at_hash"] != base64.RawURLEncoding.EncodeToString(sum[:16]) {
t.Fatalf("at_hash = %v", claims["at_hash"])
}
if claims["iss"] != ts.URL || claims["email"] != "ada@example.test" || claims["x_extra"] != nil {
t.Fatalf("claims = %v", claims)
}
if _, ok := claims["auth_time"]; ok {
t.Fatal("auth_time must not be emitted")
}
if _, ok := claims["azp"]; ok {
t.Fatal("azp must not be emitted")
}
// Userinfo answers the same subject.
req, _ = http.NewRequest(http.MethodGet, doc["userinfo_endpoint"].(string), nil)
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
res, err = hc.Do(req)
if err != nil {
t.Fatal(err)
}
body = readBody(t, res)
if res.StatusCode != http.StatusOK {
t.Fatalf("userinfo = %d %s", res.StatusCode, body)
}
var info map[string]any
if err := json.Unmarshal(body, &info); err != nil {
t.Fatal(err)
}
if info["sub"] != wantSub || info["email"] != "ada@example.test" || info["iss"] != nil {
t.Fatalf("userinfo = %s", body)
}
if !strings.HasPrefix(string(body), `{"sub":`) {
t.Fatalf("userinfo must start with sub: %s", body)
}
}

View File

@@ -0,0 +1,151 @@
package wristband
import (
"bytes"
"encoding/json"
"net/http"
"strings"
)
// UserInfo handles GET /oauth/userinfo on an OIDC server (404 on an MCP
// server). A valid bearer access token answers sub (the same subject the
// id_token carried for that client) plus the identity claims of the
// token's scopes, filtered by OIDCOptions.IdentityClaims. Every failure is
// one uniform 401 with an empty body and WWW-Authenticate: Bearer
// error="invalid_token" (T-19.4-10).
func (s *Server) UserInfo(w http.ResponseWriter, r *http.Request) {
if s.flavour != flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
if !s.oidcReady() {
w.WriteHeader(http.StatusInternalServerError)
return
}
presented := bearerToken(r)
if presented == "" || !strings.HasPrefix(presented, s.oidc.AccessTokenPrefix) {
writeUserInfoUnauthorized(w)
return
}
ctx := r.Context()
var claims map[string]any
var sub string
err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
now := s.now()
tok, err := tx.AccessTokenByHash(ctx, sha256Hex(presented))
if err != nil {
return err
}
if tok == nil || tok.RevokedAt != nil || !tok.ExpiresAt.After(now) {
return nil
}
client, err := tx.OIDCClientByClientID(ctx, tok.ClientID)
if err != nil {
return err
}
if client == nil || !client.IsActive {
return nil
}
identity, ok, err := tx.IdentityClaims(ctx, tok.UserID, tok.Scopes)
if err != nil {
return err
}
if !ok {
return nil
}
subject, err := subjectFor(ctx, tx, client, tok.UserID)
if err != nil {
return err
}
if err := tx.TouchAccessToken(ctx, tok.ID, now); err != nil {
return err
}
claims, sub = identity, subject
return nil
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if sub == "" {
writeUserInfoUnauthorized(w)
return
}
body, err := orderedClaimsJSON(sub, claims, s.oidc.IdentityClaims)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
h := w.Header()
h.Set("Content-Type", "application/json")
h.Set("Cache-Control", "no-store, private")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func writeUserInfoUnauthorized(w http.ResponseWriter) {
h := w.Header()
h.Set("WWW-Authenticate", `Bearer error="invalid_token"`)
h.Set("Cache-Control", "no-store, private")
w.WriteHeader(http.StatusUnauthorized)
}
// bearerToken mirrors Laravel's Request::bearerToken: the text after the
// last "Bearer " (any case) in the Authorization header, cut at a comma.
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
idx := strings.LastIndex(strings.ToLower(h), "bearer ")
if idx < 0 {
return ""
}
tok := h[idx+len("bearer "):]
if i := strings.IndexByte(tok, ','); i >= 0 {
tok = tok[:i]
}
return tok
}
// orderedClaimsJSON writes sub and the allowlisted identity claims in the
// allowlist's order (the PHP ClaimBuilder order). Protocol claim names are
// never copied from the identity map (T-19.4-11).
func orderedClaimsJSON(sub string, identity map[string]any, allowed []string) ([]byte, error) {
var buf bytes.Buffer
enc := func(v any) error {
var b bytes.Buffer
e := json.NewEncoder(&b)
e.SetEscapeHTML(false)
if err := e.Encode(v); err != nil {
return err
}
buf.Write(bytes.TrimSuffix(b.Bytes(), []byte("\n")))
return nil
}
buf.WriteByte('{')
if err := enc("sub"); err != nil {
return nil, err
}
buf.WriteByte(':')
if err := enc(sub); err != nil {
return nil, err
}
for _, name := range allowed {
if protocolClaims[name] {
continue
}
v, ok := identity[name]
if !ok {
continue
}
buf.WriteByte(',')
if err := enc(name); err != nil {
return nil, err
}
buf.WriteByte(':')
if err := enc(v); err != nil {
return nil, err
}
}
buf.WriteByte('}')
return buf.Bytes(), nil
}

View File

@@ -54,7 +54,14 @@ type rfcErrorBody struct {
// the endpoint's ordinary invalid_client_metadata response, not a generic
// error. Sweep, the cap check and the create all run inside one transaction
// (T-08-DCR-FLOOD).
//
// On an OIDC server it answers 404 with an empty body: OIDC clients are
// registered by an administrator, never dynamically (D-08).
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
if s.flavour == flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
if !isJSONContentType(r.Header.Get("Content-Type")) {
writeRegisterError(w, http.StatusBadRequest, "invalid_client_metadata", "Request must be application/json")
return

View File

@@ -111,6 +111,14 @@ type Server struct {
opts Options
backend Backend
// flavour selects MCP (zero value, NewServer) or OIDC (NewOIDCServer).
// The OIDC fields stay zero on an MCP server.
flavour flavour
oidc OIDCOptions
oidcBackend OIDCBackend
keys KeyStore
host OIDCHost
// now and randomBytes are deterministic clock/entropy seams so tests
// can control timestamps and generated secrets without depending on
// wall-clock time or true randomness (08-02-PLAN.md Task 2).
@@ -160,7 +168,14 @@ type metadataDocument struct {
// protocol constants, not Options: this phase's authorization server only
// ever supports the authorization_code/refresh_token grants with S256 PKCE
// (D-01), so there is nothing app-specific to configure there.
//
// On an OIDC server it answers 404 with an empty body: the OIDC flavour
// publishes Discovery instead.
func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) {
if s.flavour == flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
doc := metadataDocument{
Issuer: s.opts.Issuer,
AuthorizationEndpoint: s.opts.Issuer + "/oauth/mcp/authorize",

View File

@@ -60,7 +60,14 @@ type tokenErrorBody struct {
// ParseForm precedence puts body values ahead of query values — verified
// against the Go 1.27 stdlib source, not assumed). Basic credentials, when
// present, always override client_id/client_secret form values.
//
// On an OIDC server it dispatches to the OIDC flavour before any MCP code
// runs.
func (s *Server) Token(w http.ResponseWriter, r *http.Request) {
if s.flavour == flavourOIDC {
s.oidcToken(w, r)
return
}
if isJSONContentType(r.Header.Get("Content-Type")) {
writeTokenError(w, http.StatusBadRequest, "invalid_request")
return