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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
520
modules/wristband/oidc_authorize.go
Normal file
520
modules/wristband/oidc_authorize.go
Normal 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()
|
||||
}
|
||||
127
modules/wristband/oidc_consent.go
Normal file
127
modules/wristband/oidc_consent.go
Normal 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
|
||||
}
|
||||
168
modules/wristband/oidc_discovery.go
Normal file
168
modules/wristband/oidc_discovery.go
Normal 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
|
||||
}
|
||||
582
modules/wristband/oidc_fakes_test.go
Normal file
582
modules/wristband/oidc_fakes_test.go
Normal 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
|
||||
}
|
||||
238
modules/wristband/oidc_keys.go
Normal file
238
modules/wristband/oidc_keys.go
Normal 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])
|
||||
}
|
||||
233
modules/wristband/oidc_options.go
Normal file
233
modules/wristband/oidc_options.go
Normal 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
|
||||
}
|
||||
79
modules/wristband/oidc_redirect.go
Normal file
79
modules/wristband/oidc_redirect.go
Normal 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
|
||||
}
|
||||
}
|
||||
227
modules/wristband/oidc_stores.go
Normal file
227
modules/wristband/oidc_stores.go
Normal 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
|
||||
}
|
||||
39
modules/wristband/oidc_subject.go
Normal file
39
modules/wristband/oidc_subject.go
Normal 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())
|
||||
}
|
||||
328
modules/wristband/oidc_token.go
Normal file
328
modules/wristband/oidc_token.go
Normal 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))
|
||||
}
|
||||
270
modules/wristband/oidc_tracer_test.go
Normal file
270
modules/wristband/oidc_tracer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
151
modules/wristband/oidc_userinfo.go
Normal file
151
modules/wristband/oidc_userinfo.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user