* Add client authentication methods and client_credentials grant support * Introduce RFC 9728 Protected Resource Metadata endpoint * Implement OIDC discovery and id_token issuance * Update database schema for new client fields * Add new HTTP handlers for metadata and userinfo
11 KiB
Complete the OAuth2 / discovery spec in pkg/security
Context
pkg/security/oauth_server.go already implements a solid OAuth 2.1 authorization server: PKCE-only authorization code grant, refresh token grant, RFC 7591 dynamic client registration, RFC 7009 revocation, RFC 7662 introspection, and RFC 8414 authorization-server metadata discovery. The user asked to close the remaining gaps identified in review:
- Client authentication (
client_secret_basic/client_secret_post) — today every registered client is a public client; there is noclient_secretat all. client_credentialsgrant (RFC 6749 §4.4) — machine-to-machine tokens with no end user.- RFC 9728 OAuth Protected Resource Metadata (
/.well-known/oauth-protected-resource). - OIDC-flavored discovery +
id_token(/.well-known/openid-configuration, JWKS,id_tokenissuance) — not core RFC 6749/8414 but explicitly requested.
The hard architectural constraint (found during exploration): every access token in this codebase is an opaque string backed by a user_sessions row with a user_id, and RLS-scoping hooks (pkg/resolvespec/handler.go, pkg/restheadspec/handler.go) derive UserContext from that row via OAuthIntrospectToken. There is no JWT signing/validation live anywhere (JWTAuthenticator in providers.go is a stub). To keep client_credentials tokens compatible with the existing RLS pipeline without touching hooks.go or the introspection contract, client-credentials tokens will be backed by a synthetic service-account user row, reusing the existing get-or-create-user-by-email + create-session stored procedures/direct-mode functions that already exist for social login (oauth2GetOrCreateUser / oauth2CreateSession in oauth2_methods.go, same package, unexported but directly callable from oauth_server.go). This means no new tables and only two new columns are needed for the whole feature set.
1. Client authentication (client_secret_basic / client_secret_post)
Schema (database_schema.sql oauth_clients table ~line 1593, and database_schema_sqlite.sql ~line 96): add
client_secret_hash TEXT,
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none'
resolvespec_oauth_register_client (database_schema.sql:1628) already returns to_jsonb(oauth_clients.*), so the new columns automatically appear in the client JSON with no extra procedure change beyond the INSERT column/value list.
Go structs: add ClientSecretHash string \json:"client_secret_hash,omitempty"`andTokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`toOAuthServerClient (oauth_server_db.go:11) and to oauthClient (oauth_server.go:50`).
Persistence:
oauth_server_db_direct.gooauthRegisterClientDirect: extend INSERT to include the two new columns;oauthGetClientDirect: extend SELECT + Scan.- Procedure mode needs no new function — only the
INSERTcolumn/value list insideresolvespec_oauth_register_clientchanges.
Secret issuance (registerHandler, oauth_server.go:243): decode an optional token_endpoint_auth_method field on the registration request. If it is client_secret_basic/client_secret_post, or the requested grant_types includes client_credentials (which forces confidential — see §2), generate a secret with the existing randomOAuthToken(), store sha256(secret) hex in client_secret_hash, and return the plaintext client_secret + client_secret_expires_at: 0 once in the registration response only (never persisted in plaintext, never returned by GET/introspection). Default token_endpoint_auth_method is "none" (today's public-client behavior, unchanged).
Enforcement: add authenticateClient(r *http.Request) (*oauthClient, error) helper: reads Authorization: Basic base64(client_id:client_secret) or client_id/client_secret form fields, looks up the client, and does subtle.ConstantTimeCompare against sha256(provided) vs ClientSecretHash. In handleAuthCodeGrant and the new handleClientCredentialsGrant, if the resolved client has a non-empty ClientSecretHash, require successful authenticateClient; if empty (public client), keep today's behavior (PKCE only, no secret check) — fully backward compatible.
Metadata: metadataHandler (oauth_server.go:220) — change token_endpoint_auth_methods_supported to ["none","client_secret_basic","client_secret_post"].
2. client_credentials grant
tokenHandlerswitch (oauth_server.go:575): addcase "client_credentials": s.handleClientCredentialsGrant(w, r).- New
handleClientCredentialsGrant:- Authenticate the client via
authenticateClient(mandatory — RFC 6749 §4.4 requires a confidential client). No secret ⇒invalid_client. - Confirm
"client_credentials"is in the client's registeredGrantTypes⇒ elseunauthorized_client. - Compute effective scopes = requested
scopeform value ∩client.AllowedScopes(default toAllowedScopesifscopeomitted). - Build a synthetic
UserContext{UserName: "client:"+clientID, Email: "oauth-client-"+clientID+"@service.internal", RemoteID: clientID, Roles: effectiveScopes}and call the existing unexporteda.auth.oauth2GetOrCreateUser(ctx, userCtx, "oauth2_client")to get-or-create the backing user row (deterministic synthetic email makes this idempotent per client). - Generate
sessionToken := randomOAuthToken(), call the existing unexporteda.auth.oauth2CreateSession(ctx, sessionToken, userID, &oauth2.Token{AccessToken: sessionToken, TokenType: "Bearer"}, time.Now().Add(cfg.AccessTokenTTL), "oauth2_client")to persist it inuser_sessions(same table/procedure everything else already reads via introspection). s.writeOAuthToken(w, sessionToken, "" /* no refresh token per RFC 6749 §4.4.3 */, effectiveScopes).
- Only wired for the
s.auth != nil(DatabaseAuthenticator) case — external-provider-only servers returnunsupported_grant_type(no local user store to back a service account).
- Authenticate the client via
metadataHandler: add"client_credentials"togrant_types_supported.
3. RFC 9728 Protected Resource Metadata
- Add
cfg.ResourceIdentifier stringtoOAuthServerConfig(oauth_server.go:18), defaulting tocfg.IssuerinNewOAuthServer. - New handler
protectedResourceHandlerreturning:{"resource": "<ResourceIdentifier>", "authorization_servers": ["<Issuer>"], "scopes_supported": cfg.DefaultScopes, "bearer_methods_supported": ["header"]} - Register in
HTTPHandler():mux.HandleFunc("/.well-known/oauth-protected-resource", s.protectedResourceHandler).
4. OIDC discovery + id_token
- Dependency: add
github.com/golang-jwt/jwt/v5as a direct dependency (go get github.com/golang-jwt/jwt/v5@v5.3.1— already resolved ingo.sumtransitively, so this should be a clean, network-lightgo mod tidy). - Signing key: in
NewOAuthServer, generate an RSA-2048 keypair in memory (rsa.GenerateKey) unlesscfg.SigningKey *rsa.PrivateKeyis supplied (new optional config field, documented for multi-instance deployments that need id_tokens verifiable consistently across restarts/instances). Compute a stablekidfrom a SHA-256 fingerprint of the public key modulus. - JWKS endpoint:
GET /oauth/jwks.json→ standard JWKS JSON (kty:"RSA",n,e,kid,use:"sig",alg:"RS256"). - OIDC discovery:
GET /.well-known/openid-configuration→ same fields as/.well-known/oauth-authorization-serverplusjwks_uri,userinfo_endpoint,subject_types_supported: ["public"],id_token_signing_alg_values_supported: ["RS256"],response_types_supported,grant_types_supported(mirrors §1/§2 additions). id_tokenissuance: inwriteOAuthToken(or a thin wrapper called only fromhandleAuthCodeGrant/handleRefreshGrant, not from client_credentials — OIDC has no id_token for that grant), ifscopescontains"openid": call the already-existings.auth.OAuthIntrospectToken(ctx, accessToken)(or the matching provider's) to fetchOAuthTokenInfo{Sub,Username,Email}for the just-issued token, then build and RS256-sign a JWT with claimsiss,sub,aud(client_id — needs to be threaded down from the token handler),exp,iat, and (if scope hasprofile/email)preferred_username/email. Attach asid_tokenin the JSON response.- Userinfo endpoint:
GET/POST /oauth/userinfowithAuthorization: Bearer <token>→ reuseOAuthIntrospectToken, return{sub, preferred_username, email}(401{"error":"invalid_token"}if inactive). - Register
/oauth/jwks.json,/.well-known/openid-configuration,/oauth/userinfoinHTTPHandler().
Files touched
pkg/security/oauth_server.go— bulk of the new logic (client auth, client_credentials grant, RFC 9728, OIDC discovery, JWKS, userinfo, id_token signing), updated doc comment listing endpoints.pkg/security/oauth_server_db.go—OAuthServerClientstruct fields.pkg/security/oauth_server_db_direct.go— INSERT/SELECT column updates.pkg/security/database_schema.sql,pkg/security/database_schema_sqlite.sql—oauth_clientsnew columns;resolvespec_oauth_register_clientINSERT list.pkg/security/OAUTH2.md— document new endpoints/grant/columns.go.mod/go.sum— new direct dependency.- New
pkg/security/oauth_server_test.go— HTTP-level tests (see below); extenddirect_mode_test.go'sTestDirectMode_OAuthGetOrCreateUserAndSession-style coverage only if the column changes require it.
Verification
go build ./...andgo vet ./...in the module root.go test ./pkg/security/... -run OAuth -vafter adding tests covering:- Register a confidential client (
grant_types: ["client_credentials"]) → assertclient_secretpresent once,client_secret_hashnever leaks viaGET/introspection. client_credentialsgrant: valid secret → 200 withaccess_token, norefresh_token; bad secret →invalid_client; public client (no secret) attemptingclient_credentials→invalid_client.- Authorization-code flow for a confidential client without credentials on
/oauth/token→invalid_client; with credentials → succeeds (regression check that public-client flow with no secret is unaffected). /.well-known/oauth-protected-resourcereturns expected JSON shape./.well-known/openid-configuration+/oauth/jwks.jsonshape; parse a returnedid_token(scopeopenid profile email) withgolang-jwtusing the JWKS public key and assertiss/sub/aud/expclaims./oauth/userinfowith a valid vs. revoked token.
- Register a confidential client (
- Full existing suite:
go test ./pkg/security/...to confirm no regression in direct-mode / procedure-mode OAuth tests already indirect_mode_test.go.