mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-08-01 07:07:38 +00:00
feat(security): complete OAuth2/OIDC spec coverage in OAuthServer
Add client_secret_basic/client_secret_post client authentication, the client_credentials grant (RFC 6749 §4.4, backed by a synthetic service-account user so it reuses the existing session/introspection/RLS pipeline unchanged), RFC 9728 protected resource metadata, and OIDC discovery + JWKS + id_token/userinfo support. Remove plan_oauth.md, which was only meant as a working handoff doc. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1597,6 +1597,8 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_name VARCHAR(255),
|
||||
grant_types TEXT[] DEFAULT ARRAY['authorization_code'],
|
||||
allowed_scopes TEXT[] DEFAULT ARRAY['openid','profile','email'],
|
||||
client_secret_hash TEXT, -- sha256 hex of the confidential-client secret; NULL for public clients
|
||||
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1634,13 +1636,15 @@ DECLARE
|
||||
BEGIN
|
||||
v_client_id := p_data->>'client_id';
|
||||
|
||||
INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes)
|
||||
INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method)
|
||||
VALUES (
|
||||
v_client_id,
|
||||
ARRAY(SELECT jsonb_array_elements_text(p_data->'redirect_uris')),
|
||||
p_data->>'client_name',
|
||||
COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'grant_types')), ARRAY['authorization_code']),
|
||||
COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'allowed_scopes')), ARRAY['openid','profile','email'])
|
||||
COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'allowed_scopes')), ARRAY['openid','profile','email']),
|
||||
NULLIF(p_data->>'client_secret_hash', ''),
|
||||
COALESCE(NULLIF(p_data->>'token_endpoint_auth_method', ''), 'none')
|
||||
)
|
||||
RETURNING to_jsonb(oauth_clients.*) INTO v_row;
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_name VARCHAR(255),
|
||||
grant_types TEXT, -- JSON-encoded []string
|
||||
allowed_scopes TEXT, -- JSON-encoded []string
|
||||
client_secret_hash TEXT, -- sha256 hex of the confidential-client secret; NULL for public clients
|
||||
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP
|
||||
);
|
||||
|
||||
+424
-35
@@ -3,8 +3,11 @@ package security
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -12,6 +15,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OAuthServerConfig configures the MCP-standard OAuth2 authorization server.
|
||||
@@ -44,15 +50,32 @@ type OAuthServerConfig struct {
|
||||
|
||||
// AuthCodeTTL is the auth code lifetime. Defaults to 2 minutes.
|
||||
AuthCodeTTL time.Duration
|
||||
|
||||
// ResourceIdentifier is this server's protected-resource identifier, advertised in
|
||||
// RFC 9728 metadata. Defaults to Issuer.
|
||||
ResourceIdentifier string
|
||||
|
||||
// SigningKey signs id_tokens (RS256) and is exposed via the JWKS endpoint. If nil, an
|
||||
// RSA-2048 key is generated in memory when the server starts. Supply a persistent key
|
||||
// for multi-instance deployments so id_tokens remain verifiable across restarts/instances.
|
||||
SigningKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// oauthClient is a dynamically registered OAuth2 client (RFC 7591).
|
||||
type oauthClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes,omitempty"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes,omitempty"`
|
||||
ClientSecretHash string `json:"client_secret_hash,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
}
|
||||
|
||||
// isConfidential reports whether the client has a registered secret and must
|
||||
// authenticate itself at the token endpoint.
|
||||
func (c *oauthClient) isConfidential() bool {
|
||||
return c.ClientSecretHash != ""
|
||||
}
|
||||
|
||||
// pendingAuth tracks an in-progress authorization code exchange.
|
||||
@@ -85,13 +108,25 @@ type externalProvider struct {
|
||||
// The server exposes these RFC-compliant endpoints:
|
||||
//
|
||||
// GET /.well-known/oauth-authorization-server RFC 8414 — server metadata discovery
|
||||
// GET /.well-known/openid-configuration OIDC discovery (superset of the above)
|
||||
// GET /.well-known/oauth-protected-resource RFC 9728 — protected resource metadata
|
||||
// POST /oauth/register RFC 7591 — dynamic client registration
|
||||
// GET /oauth/authorize OAuth 2.1 + PKCE — start authorization
|
||||
// POST /oauth/authorize Direct login form submission
|
||||
// POST /oauth/token Token exchange and refresh
|
||||
// POST /oauth/token Token exchange: authorization_code,
|
||||
// refresh_token, client_credentials (RFC 6749 §4.4)
|
||||
// POST /oauth/revoke RFC 7009 — token revocation
|
||||
// POST /oauth/introspect RFC 7662 — token introspection
|
||||
// GET /oauth/userinfo OIDC UserInfo endpoint
|
||||
// GET /oauth/jwks.json JWKS — id_token verification keys
|
||||
// GET {ProviderCallbackPath} Internal — external provider callback
|
||||
//
|
||||
// Confidential clients (registered with token_endpoint_auth_method other than "none", or
|
||||
// any grant_types including client_credentials) authenticate at /oauth/token via
|
||||
// client_secret_basic or client_secret_post. Public clients keep relying on PKCE alone.
|
||||
//
|
||||
// When the granted scope includes "openid", authorization_code and refresh_token responses
|
||||
// include an RS256-signed id_token (see OAuthServerConfig.SigningKey).
|
||||
type OAuthServer struct {
|
||||
cfg OAuthServerConfig
|
||||
auth *DatabaseAuthenticator // nil = only external providers
|
||||
@@ -102,6 +137,9 @@ type OAuthServer struct {
|
||||
pending map[string]*pendingAuth // provider_state → pending (external flow)
|
||||
codes map[string]*pendingAuth // auth_code → pending (post-auth)
|
||||
|
||||
signingKey *rsa.PrivateKey
|
||||
signingKeyID string
|
||||
|
||||
done chan struct{} // closed by Close() to stop background goroutines
|
||||
}
|
||||
|
||||
@@ -130,13 +168,32 @@ func NewOAuthServer(cfg OAuthServerConfig, auth *DatabaseAuthenticator) *OAuthSe
|
||||
}
|
||||
// Normalize issuer: remove trailing slash to ensure consistent endpoint URL construction.
|
||||
cfg.Issuer = strings.TrimSuffix(cfg.Issuer, "/")
|
||||
if cfg.ResourceIdentifier == "" {
|
||||
cfg.ResourceIdentifier = cfg.Issuer
|
||||
}
|
||||
|
||||
signingKey := cfg.SigningKey
|
||||
if signingKey == nil {
|
||||
var err error
|
||||
signingKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
// Signing keys are only required for id_token issuance (OIDC "openid" scope);
|
||||
// leaving signingKey nil degrades gracefully by omitting id_token/JWKS support.
|
||||
signingKey = nil
|
||||
}
|
||||
}
|
||||
|
||||
s := &OAuthServer{
|
||||
cfg: cfg,
|
||||
auth: auth,
|
||||
clients: make(map[string]*oauthClient),
|
||||
pending: make(map[string]*pendingAuth),
|
||||
codes: make(map[string]*pendingAuth),
|
||||
done: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
auth: auth,
|
||||
clients: make(map[string]*oauthClient),
|
||||
pending: make(map[string]*pendingAuth),
|
||||
codes: make(map[string]*pendingAuth),
|
||||
signingKey: signingKey,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if signingKey != nil {
|
||||
s.signingKeyID = rsaKeyID(&signingKey.PublicKey)
|
||||
}
|
||||
go s.cleanupExpired()
|
||||
return s
|
||||
@@ -178,11 +235,15 @@ func (s *OAuthServer) ProviderCallbackPath() string {
|
||||
func (s *OAuthServer) HTTPHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", s.metadataHandler)
|
||||
mux.HandleFunc("/.well-known/openid-configuration", s.openIDConfigurationHandler)
|
||||
mux.HandleFunc("/.well-known/oauth-protected-resource", s.protectedResourceHandler)
|
||||
mux.HandleFunc("/oauth/register", s.registerHandler)
|
||||
mux.HandleFunc("/oauth/authorize", s.authorizeHandler)
|
||||
mux.HandleFunc("/oauth/token", s.tokenHandler)
|
||||
mux.HandleFunc("/oauth/revoke", s.revokeHandler)
|
||||
mux.HandleFunc("/oauth/introspect", s.introspectHandler)
|
||||
mux.HandleFunc("/oauth/userinfo", s.userinfoHandler)
|
||||
mux.HandleFunc("/oauth/jwks.json", s.jwksHandler)
|
||||
mux.HandleFunc(s.cfg.ProviderCallbackPath, s.providerCallbackHandler)
|
||||
return mux
|
||||
}
|
||||
@@ -217,25 +278,127 @@ func (s *OAuthServer) cleanupExpired() {
|
||||
// RFC 8414 — Server metadata
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) metadataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// serverMetadata builds the fields shared by RFC 8414 authorization-server
|
||||
// metadata and OIDC discovery metadata.
|
||||
func (s *OAuthServer) serverMetadata() map[string]interface{} {
|
||||
issuer := s.cfg.Issuer
|
||||
meta := map[string]interface{}{
|
||||
grantTypes := []string{"authorization_code", "refresh_token"}
|
||||
if s.auth != nil {
|
||||
grantTypes = append(grantTypes, "client_credentials")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": issuer + "/oauth/authorize",
|
||||
"token_endpoint": issuer + "/oauth/token",
|
||||
"registration_endpoint": issuer + "/oauth/register",
|
||||
"revocation_endpoint": issuer + "/oauth/revoke",
|
||||
"introspection_endpoint": issuer + "/oauth/introspect",
|
||||
"userinfo_endpoint": issuer + "/oauth/userinfo",
|
||||
"jwks_uri": issuer + "/oauth/jwks.json",
|
||||
"scopes_supported": s.cfg.DefaultScopes,
|
||||
"response_types_supported": []string{"code"},
|
||||
"grant_types_supported": []string{"authorization_code", "refresh_token"},
|
||||
"grant_types_supported": grantTypes,
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"token_endpoint_auth_methods_supported": []string{"none"},
|
||||
"token_endpoint_auth_methods_supported": []string{"none", "client_secret_basic", "client_secret_post"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OAuthServer) metadataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(s.serverMetadata()) //nolint:errcheck
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// OIDC discovery — GET /.well-known/openid-configuration
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) openIDConfigurationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
meta := s.serverMetadata()
|
||||
meta["subject_types_supported"] = []string{"public"}
|
||||
meta["id_token_signing_alg_values_supported"] = []string{"RS256"}
|
||||
meta["claims_supported"] = []string{"sub", "iss", "aud", "exp", "iat", "email", "preferred_username"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(meta) //nolint:errcheck
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RFC 9728 — Protected Resource Metadata
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) protectedResourceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
meta := map[string]interface{}{
|
||||
"resource": s.cfg.ResourceIdentifier,
|
||||
"authorization_servers": []string{s.cfg.Issuer},
|
||||
"scopes_supported": s.cfg.DefaultScopes,
|
||||
"bearer_methods_supported": []string{"header"},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(meta) //nolint:errcheck
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// JWKS — GET /oauth/jwks.json
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) jwksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if s.signingKey == nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"keys": []interface{}{}}) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
pub := s.signingKey.PublicKey
|
||||
jwk := map[string]interface{}{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": s.signingKeyID,
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(bigEndianBytes(pub.E)),
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"keys": []interface{}{jwk}}) //nolint:errcheck
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Userinfo — GET/POST /oauth/userinfo
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) userinfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
if token == "" || token == auth {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
writeOAuthError(w, "invalid_token", "missing bearer token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
authToUse := s.auth
|
||||
if authToUse == nil {
|
||||
s.mu.RLock()
|
||||
if len(s.providers) > 0 {
|
||||
authToUse = s.providers[0].auth
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
if authToUse == nil {
|
||||
writeOAuthError(w, "invalid_token", "no authenticator configured", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := authToUse.OAuthIntrospectToken(r.Context(), token)
|
||||
if err != nil || !info.Active {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
writeOAuthError(w, "invalid_token", "token is inactive or invalid", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{ //nolint:errcheck
|
||||
"sub": info.Sub,
|
||||
"preferred_username": info.Username,
|
||||
"email": info.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RFC 7591 — Dynamic client registration
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -246,10 +409,11 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeOAuthError(w, "invalid_request", "malformed JSON", http.StatusBadRequest)
|
||||
@@ -272,21 +436,48 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// client_credentials is a machine-to-machine grant and requires a confidential
|
||||
// client (RFC 6749 §4.4), so it always forces secret issuance regardless of the
|
||||
// requested auth method.
|
||||
authMethod := req.TokenEndpointAuthMethod
|
||||
if authMethod == "" {
|
||||
authMethod = "none"
|
||||
}
|
||||
if oauthSliceContains(grantTypes, "client_credentials") && authMethod == "none" {
|
||||
authMethod = "client_secret_basic"
|
||||
}
|
||||
|
||||
var plaintextSecret string
|
||||
var secretHash string
|
||||
if authMethod != "none" {
|
||||
plaintextSecret, err = randomOAuthToken()
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
secretHash = hashClientSecret(plaintextSecret)
|
||||
}
|
||||
|
||||
client := &oauthClient{
|
||||
ClientID: clientID,
|
||||
RedirectURIs: req.RedirectURIs,
|
||||
ClientName: req.ClientName,
|
||||
GrantTypes: grantTypes,
|
||||
AllowedScopes: allowedScopes,
|
||||
ClientID: clientID,
|
||||
RedirectURIs: req.RedirectURIs,
|
||||
ClientName: req.ClientName,
|
||||
GrantTypes: grantTypes,
|
||||
AllowedScopes: allowedScopes,
|
||||
ClientSecretHash: secretHash,
|
||||
TokenEndpointAuthMethod: authMethod,
|
||||
}
|
||||
|
||||
if s.cfg.PersistClients && s.auth != nil {
|
||||
dbClient := &OAuthServerClient{
|
||||
ClientID: client.ClientID,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
ClientName: client.ClientName,
|
||||
GrantTypes: client.GrantTypes,
|
||||
AllowedScopes: client.AllowedScopes,
|
||||
ClientID: client.ClientID,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
ClientName: client.ClientName,
|
||||
GrantTypes: client.GrantTypes,
|
||||
AllowedScopes: client.AllowedScopes,
|
||||
ClientSecretHash: client.ClientSecretHash,
|
||||
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
|
||||
}
|
||||
if _, err := s.auth.OAuthRegisterClient(r.Context(), dbClient); err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
@@ -298,9 +489,25 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.clients[clientID] = client
|
||||
s.mu.Unlock()
|
||||
|
||||
// RFC 7591 registration response: the plaintext secret is returned exactly once here
|
||||
// and never persisted or served again — only its hash (client.ClientSecretHash) is
|
||||
// stored, and that hash is deliberately excluded from this response.
|
||||
resp := map[string]interface{}{
|
||||
"client_id": client.ClientID,
|
||||
"redirect_uris": client.RedirectURIs,
|
||||
"client_name": client.ClientName,
|
||||
"grant_types": client.GrantTypes,
|
||||
"allowed_scopes": client.AllowedScopes,
|
||||
"token_endpoint_auth_method": client.TokenEndpointAuthMethod,
|
||||
}
|
||||
if plaintextSecret != "" {
|
||||
resp["client_secret"] = plaintextSecret
|
||||
resp["client_secret_expires_at"] = 0
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(client) //nolint:errcheck
|
||||
json.NewEncoder(w).Encode(resp) //nolint:errcheck
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -577,6 +784,8 @@ func (s *OAuthServer) tokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleAuthCodeGrant(w, r)
|
||||
case "refresh_token":
|
||||
s.handleRefreshGrant(w, r)
|
||||
case "client_credentials":
|
||||
s.handleClientCredentialsGrant(w, r)
|
||||
default:
|
||||
writeOAuthError(w, "unsupported_grant_type", "", http.StatusBadRequest)
|
||||
}
|
||||
@@ -593,6 +802,15 @@ func (s *OAuthServer) handleAuthCodeGrant(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Confidential clients (those registered with a client_secret) must authenticate;
|
||||
// public clients keep relying on PKCE alone, unchanged from prior behavior.
|
||||
if client, ok := s.lookupOrFetchClient(r.Context(), clientID); ok && client.isConfidential() {
|
||||
if _, err := s.authenticateClient(r); err != nil {
|
||||
writeOAuthError(w, "invalid_client", err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var sessionToken string
|
||||
var refreshToken string
|
||||
var scopes []string
|
||||
@@ -647,12 +865,13 @@ func (s *OAuthServer) handleAuthCodeGrant(w http.ResponseWriter, r *http.Request
|
||||
scopes = pending.Scopes
|
||||
}
|
||||
|
||||
s.writeOAuthToken(w, sessionToken, refreshToken, scopes)
|
||||
s.writeOAuthToken(w, r, sessionToken, refreshToken, clientID, scopes, true)
|
||||
}
|
||||
|
||||
func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request) {
|
||||
refreshToken := r.FormValue("refresh_token")
|
||||
providerName := r.FormValue("provider")
|
||||
clientID := r.FormValue("client_id")
|
||||
if refreshToken == "" {
|
||||
writeOAuthError(w, "invalid_request", "refresh_token required", http.StatusBadRequest)
|
||||
return
|
||||
@@ -666,7 +885,7 @@ func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request)
|
||||
writeOAuthError(w, "invalid_grant", err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.writeOAuthToken(w, loginResp.Token, loginResp.RefreshToken, nil)
|
||||
s.writeOAuthToken(w, r, loginResp.Token, loginResp.RefreshToken, clientID, nil, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -676,13 +895,86 @@ func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request)
|
||||
writeOAuthError(w, "invalid_grant", err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.writeOAuthToken(w, loginResp.Token, loginResp.RefreshToken, nil)
|
||||
s.writeOAuthToken(w, r, loginResp.Token, loginResp.RefreshToken, clientID, nil, false)
|
||||
return
|
||||
}
|
||||
|
||||
writeOAuthError(w, "invalid_grant", "no provider available for refresh", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RFC 6749 §4.4 — Client credentials grant
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
writeOAuthError(w, "unsupported_grant_type", "client_credentials requires a local user store", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.authenticateClient(r)
|
||||
if err != nil {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="oauth"`)
|
||||
writeOAuthError(w, "invalid_client", err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !oauthSliceContains(client.GrantTypes, "client_credentials") {
|
||||
writeOAuthError(w, "unauthorized_client", "client is not authorized for client_credentials", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
requested := strings.Fields(r.FormValue("scope"))
|
||||
effectiveScopes := client.AllowedScopes
|
||||
if len(requested) > 0 {
|
||||
effectiveScopes = nil
|
||||
for _, sc := range requested {
|
||||
if oauthSliceContains(client.AllowedScopes, sc) {
|
||||
effectiveScopes = append(effectiveScopes, sc)
|
||||
}
|
||||
}
|
||||
if len(effectiveScopes) == 0 {
|
||||
writeOAuthError(w, "invalid_scope", "no requested scope is allowed for this client", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// client_credentials tokens have no end user, but the rest of the stack (RLS-scoping
|
||||
// hooks, introspection) expects every access token to resolve to a user_sessions row
|
||||
// with a user_id. Represent the client as a deterministic synthetic "service account"
|
||||
// user so the existing get-or-create/create-session/introspection pipeline handles it
|
||||
// unchanged — no new tables or code paths required.
|
||||
userCtx := &UserContext{
|
||||
UserName: "client:" + client.ClientID,
|
||||
Email: "oauth-client-" + client.ClientID + "@service.internal",
|
||||
RemoteID: client.ClientID,
|
||||
Roles: effectiveScopes,
|
||||
}
|
||||
userID, err := s.auth.oauth2GetOrCreateUser(r.Context(), userCtx, "oauth2_client")
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sessionToken, err := randomOAuthToken()
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(s.cfg.AccessTokenTTL)
|
||||
err = s.auth.oauth2CreateSession(r.Context(), sessionToken, userID, &oauth2.Token{
|
||||
AccessToken: sessionToken,
|
||||
TokenType: "Bearer",
|
||||
}, expiresAt, "oauth2_client")
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// No refresh token per RFC 6749 §4.4.3, and no id_token — client_credentials has no
|
||||
// end-user subject to represent in OIDC terms.
|
||||
s.writeOAuthToken(w, r, sessionToken, "", client.ClientID, effectiveScopes, false)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RFC 7009 — Token revocation
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -879,7 +1171,10 @@ func oauthSliceContains(slice []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, accessToken, refreshToken string, scopes []string) {
|
||||
// writeOAuthToken writes the token response. When issueIDToken is true and the granted
|
||||
// scopes include "openid", an RS256-signed id_token is included (OIDC); client_credentials
|
||||
// responses always pass issueIDToken=false since that grant has no end-user subject.
|
||||
func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, r *http.Request, accessToken, refreshToken, clientID string, scopes []string, issueIDToken bool) {
|
||||
expiresIn := int64(s.cfg.AccessTokenTTL.Seconds())
|
||||
resp := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
@@ -892,12 +1187,106 @@ func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, accessToken, refres
|
||||
if len(scopes) > 0 {
|
||||
resp["scope"] = strings.Join(scopes, " ")
|
||||
}
|
||||
if issueIDToken && oauthSliceContains(scopes, "openid") {
|
||||
if idToken, err := s.buildIDToken(r.Context(), accessToken, clientID, scopes); err == nil {
|
||||
resp["id_token"] = idToken
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
json.NewEncoder(w).Encode(resp) //nolint:errcheck
|
||||
}
|
||||
|
||||
// buildIDToken issues an OIDC id_token for the just-issued access token by reusing the
|
||||
// existing introspection pipeline to resolve the subject's claims.
|
||||
func (s *OAuthServer) buildIDToken(ctx context.Context, accessToken, clientID string, scopes []string) (string, error) {
|
||||
if s.signingKey == nil {
|
||||
return "", fmt.Errorf("no signing key configured")
|
||||
}
|
||||
authToUse := s.auth
|
||||
if authToUse == nil {
|
||||
s.mu.RLock()
|
||||
if len(s.providers) > 0 {
|
||||
authToUse = s.providers[0].auth
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
if authToUse == nil {
|
||||
return "", fmt.Errorf("no authenticator configured")
|
||||
}
|
||||
info, err := authToUse.OAuthIntrospectToken(ctx, accessToken)
|
||||
if err != nil || !info.Active {
|
||||
return "", fmt.Errorf("token not active")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"iss": s.cfg.Issuer,
|
||||
"sub": info.Sub,
|
||||
"aud": clientID,
|
||||
"exp": now.Add(s.cfg.AccessTokenTTL).Unix(),
|
||||
"iat": now.Unix(),
|
||||
}
|
||||
if oauthSliceContains(scopes, "profile") && info.Username != "" {
|
||||
claims["preferred_username"] = info.Username
|
||||
}
|
||||
if oauthSliceContains(scopes, "email") && info.Email != "" {
|
||||
claims["email"] = info.Email
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = s.signingKeyID
|
||||
return token.SignedString(s.signingKey)
|
||||
}
|
||||
|
||||
// authenticateClient validates client_secret_basic (Authorization: Basic) or
|
||||
// client_secret_post (client_id/client_secret form fields) credentials against a
|
||||
// registered confidential client's stored secret hash.
|
||||
func (s *OAuthServer) authenticateClient(r *http.Request) (*oauthClient, error) {
|
||||
clientID, clientSecret, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
clientID = r.FormValue("client_id")
|
||||
clientSecret = r.FormValue("client_secret")
|
||||
}
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return nil, fmt.Errorf("client authentication required")
|
||||
}
|
||||
client, ok := s.lookupOrFetchClient(r.Context(), clientID)
|
||||
if !ok || !client.isConfidential() {
|
||||
return nil, fmt.Errorf("invalid client credentials")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(hashClientSecret(clientSecret)), []byte(client.ClientSecretHash)) != 1 {
|
||||
return nil, fmt.Errorf("invalid client credentials")
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func hashClientSecret(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// rsaKeyID derives a stable JWKS "kid" from an RSA public key's modulus.
|
||||
func rsaKeyID(pub *rsa.PublicKey) string {
|
||||
sum := sha256.Sum256(pub.N.Bytes())
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// bigEndianBytes encodes a small positive int (e.g. an RSA public exponent) as
|
||||
// minimal big-endian bytes for JWK "e" encoding.
|
||||
func bigEndianBytes(n int) []byte {
|
||||
if n == 0 {
|
||||
return []byte{0}
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte(n & 0xff)}, b...)
|
||||
n >>= 8
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func writeOAuthError(w http.ResponseWriter, errCode, description string, status int) {
|
||||
resp := map[string]string{"error": errCode}
|
||||
if description != "" {
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
|
||||
// OAuthServerClient is a persisted RFC 7591 registered OAuth2 client.
|
||||
type OAuthServerClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes,omitempty"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes,omitempty"`
|
||||
ClientSecretHash string `json:"client_secret_hash,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthCode is a short-lived authorization code.
|
||||
|
||||
@@ -15,6 +15,15 @@ import (
|
||||
// Array columns (redirect_uris, grant_types, allowed_scopes, scopes) are
|
||||
// JSON-encoded TEXT instead of native Postgres arrays.
|
||||
|
||||
// nullIfEmpty converts an empty string to a SQL NULL so optional TEXT columns
|
||||
// (e.g. client_secret_hash for public clients) stay unset rather than "".
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) {
|
||||
grantTypes := client.GrantTypes
|
||||
if len(grantTypes) == 0 {
|
||||
@@ -38,11 +47,16 @@ func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, c
|
||||
return nil, fmt.Errorf("failed to marshal allowed_scopes: %w", err)
|
||||
}
|
||||
|
||||
authMethod := client.TokenEndpointAuthMethod
|
||||
if authMethod == "" {
|
||||
authMethod = "none"
|
||||
}
|
||||
|
||||
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||
`INSERT INTO %s (client_id, redirect_uris, client_name, grant_types, allowed_scopes, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO %s (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.tableNames.OAuthClients))
|
||||
_, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), true, time.Now())
|
||||
_, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), nullIfEmpty(client.ClientSecretHash), authMethod, true, time.Now())
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -50,23 +64,25 @@ func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, c
|
||||
}
|
||||
|
||||
return &OAuthServerClient{
|
||||
ClientID: client.ClientID,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
ClientName: client.ClientName,
|
||||
GrantTypes: grantTypes,
|
||||
AllowedScopes: allowedScopes,
|
||||
ClientID: client.ClientID,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
ClientName: client.ClientName,
|
||||
GrantTypes: grantTypes,
|
||||
AllowedScopes: allowedScopes,
|
||||
ClientSecretHash: client.ClientSecretHash,
|
||||
TokenEndpointAuthMethod: authMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, clientID string) (*OAuthServerClient, error) {
|
||||
var redirectURIsJSON, grantTypesJSON, allowedScopesJSON sql.NullString
|
||||
var clientName sql.NullString
|
||||
var clientName, clientSecretHash, authMethod sql.NullString
|
||||
|
||||
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||
`SELECT redirect_uris, client_name, grant_types, allowed_scopes FROM %s WHERE client_id = ? AND is_active = ?`,
|
||||
`SELECT redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method FROM %s WHERE client_id = ? AND is_active = ?`,
|
||||
a.tableNames.OAuthClients))
|
||||
return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON)
|
||||
return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON, &clientSecretHash, &authMethod)
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -75,7 +91,12 @@ func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, client
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
result := &OAuthServerClient{ClientID: clientID, ClientName: clientName.String}
|
||||
result := &OAuthServerClient{
|
||||
ClientID: clientID,
|
||||
ClientName: clientName.String,
|
||||
ClientSecretHash: clientSecretHash.String,
|
||||
TokenEndpointAuthMethod: authMethod.String,
|
||||
}
|
||||
if redirectURIsJSON.Valid {
|
||||
_ = json.Unmarshal([]byte(redirectURIsJSON.String), &result.RedirectURIs)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func newTestOAuthServer(t *testing.T) (*OAuthServer, *DatabaseAuthenticator) {
|
||||
t.Helper()
|
||||
db := newDirectTestDB(t)
|
||||
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
||||
srv := NewOAuthServer(OAuthServerConfig{Issuer: "https://auth.example.com", PersistCodes: true}, auth)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, auth
|
||||
}
|
||||
|
||||
// s256Challenge computes the PKCE S256 code_challenge for a given verifier,
|
||||
// matching validatePKCESHA256 in oauth_server.go.
|
||||
func s256Challenge(verifier string) string {
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, mux http.Handler, method, path string, body map[string]interface{}) (*httptest.ResponseRecorder, map[string]interface{}) {
|
||||
t.Helper()
|
||||
var reqBody *strings.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
reqBody = strings.NewReader(string(b))
|
||||
} else {
|
||||
reqBody = strings.NewReader("")
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reqBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if rec.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
|
||||
}
|
||||
return rec, parsed
|
||||
}
|
||||
|
||||
func doForm(t *testing.T, mux http.Handler, path string, form url.Values, basicUser, basicPass string) (*httptest.ResponseRecorder, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if basicUser != "" {
|
||||
req.SetBasicAuth(basicUser, basicPass)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if rec.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
|
||||
}
|
||||
return rec, parsed
|
||||
}
|
||||
|
||||
func doGet(mux http.Handler, path, bearer string) (*httptest.ResponseRecorder, map[string]interface{}) {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
var parsed map[string]interface{}
|
||||
if rec.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
|
||||
}
|
||||
return rec, parsed
|
||||
}
|
||||
|
||||
func TestOAuthServer_RegisterConfidentialClient_IssuesSecretOnce(t *testing.T) {
|
||||
srv, _ := newTestOAuthServer(t)
|
||||
mux := srv.HTTPHandler()
|
||||
|
||||
rec, resp := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
||||
"redirect_uris": []string{"https://app.example.com/callback"},
|
||||
"grant_types": []string{"client_credentials"},
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("register status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
secret, _ := resp["client_secret"].(string)
|
||||
if secret == "" {
|
||||
t.Fatal("expected client_secret to be present in registration response")
|
||||
}
|
||||
if _, ok := resp["client_secret_hash"]; ok {
|
||||
t.Error("client_secret_hash must never be returned in the registration response")
|
||||
}
|
||||
if resp["token_endpoint_auth_method"] != "client_secret_basic" {
|
||||
t.Errorf("token_endpoint_auth_method = %v, want client_secret_basic", resp["token_endpoint_auth_method"])
|
||||
}
|
||||
if resp["client_id"] == "" || resp["client_id"] == nil {
|
||||
t.Fatal("expected non-empty client_id")
|
||||
}
|
||||
|
||||
// Fetching the client back (e.g. via a later authorize/token call) must never leak the hash.
|
||||
clientID := resp["client_id"].(string)
|
||||
fetched, ok := srv.lookupOrFetchClient(context.Background(), clientID)
|
||||
if !ok {
|
||||
t.Fatal("expected client to be found")
|
||||
}
|
||||
if fetched.ClientSecretHash == "" {
|
||||
t.Error("expected ClientSecretHash to be stored internally")
|
||||
}
|
||||
if fetched.ClientSecretHash == secret {
|
||||
t.Error("stored hash must not equal the plaintext secret")
|
||||
}
|
||||
|
||||
// Public registration (no client_credentials) should stay a public client with no secret.
|
||||
recPublic, respPublic := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
||||
"redirect_uris": []string{"https://app.example.com/callback"},
|
||||
})
|
||||
if recPublic.Code != http.StatusCreated {
|
||||
t.Fatalf("public register status = %d", recPublic.Code)
|
||||
}
|
||||
if _, ok := respPublic["client_secret"]; ok {
|
||||
t.Error("public client registration should not receive a client_secret")
|
||||
}
|
||||
if respPublic["token_endpoint_auth_method"] != "none" {
|
||||
t.Errorf("public client token_endpoint_auth_method = %v, want none", respPublic["token_endpoint_auth_method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthServer_ClientCredentialsGrant(t *testing.T) {
|
||||
srv, _ := newTestOAuthServer(t)
|
||||
mux := srv.HTTPHandler()
|
||||
|
||||
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
||||
"redirect_uris": []string{"https://app.example.com/callback"},
|
||||
"grant_types": []string{"client_credentials"},
|
||||
"allowed_scopes": []string{"read", "write"},
|
||||
})
|
||||
clientID := reg["client_id"].(string)
|
||||
clientSecret := reg["client_secret"].(string)
|
||||
|
||||
t.Run("valid credentials", func(t *testing.T) {
|
||||
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"scope": {"read"},
|
||||
}, clientID, clientSecret)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("token status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if resp["access_token"] == "" || resp["access_token"] == nil {
|
||||
t.Error("expected non-empty access_token")
|
||||
}
|
||||
if _, ok := resp["refresh_token"]; ok {
|
||||
t.Error("client_credentials must not issue a refresh_token (RFC 6749 §4.4.3)")
|
||||
}
|
||||
if resp["scope"] != "read" {
|
||||
t.Errorf("scope = %v, want read", resp["scope"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope not allowed for client", func(t *testing.T) {
|
||||
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"scope": {"admin"},
|
||||
}, clientID, clientSecret)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if resp["error"] != "invalid_scope" {
|
||||
t.Errorf("error = %v, want invalid_scope", resp["error"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong secret", func(t *testing.T) {
|
||||
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
}, clientID, "wrong-secret")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if resp["error"] != "invalid_client" {
|
||||
t.Errorf("error = %v, want invalid_client", resp["error"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public client cannot use client_credentials", func(t *testing.T) {
|
||||
_, pubReg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
||||
"redirect_uris": []string{"https://app.example.com/callback"},
|
||||
})
|
||||
pubClientID := pubReg["client_id"].(string)
|
||||
|
||||
rec, _ := doForm(t, mux, "/oauth/token", url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {pubClientID},
|
||||
}, "", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 for public client attempting client_credentials", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOAuthServer_ConfidentialClient_AuthCodeGrantRequiresSecret(t *testing.T) {
|
||||
srv, auth := newTestOAuthServer(t)
|
||||
mux := srv.HTTPHandler()
|
||||
|
||||
regResp, err := auth.Register(context.Background(), RegisterRequest{
|
||||
Username: "nadia", Password: "p", Email: "nadia@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
|
||||
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
||||
"redirect_uris": []string{"https://app.example.com/callback"},
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
})
|
||||
clientID := reg["client_id"].(string)
|
||||
clientSecret := reg["client_secret"].(string)
|
||||
|
||||
verifier := "verifier-nadia-1234567890"
|
||||
challenge := s256Challenge(verifier)
|
||||
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
|
||||
Code: "code-no-auth",
|
||||
ClientID: clientID,
|
||||
RedirectURI: "https://app.example.com/callback",
|
||||
CodeChallenge: challenge,
|
||||
SessionToken: regResp.Token,
|
||||
Scopes: []string{"profile"},
|
||||
ExpiresAt: futureTime(),
|
||||
}); err != nil {
|
||||
t.Fatalf("OAuthSaveCode() error = %v", err)
|
||||
}
|
||||
|
||||
// No client credentials supplied -> must be rejected for a confidential client.
|
||||
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"code-no-auth"},
|
||||
"redirect_uri": {"https://app.example.com/callback"},
|
||||
"client_id": {clientID},
|
||||
"code_verifier": {verifier},
|
||||
}, "", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 without client auth, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if resp["error"] != "invalid_client" {
|
||||
t.Errorf("error = %v, want invalid_client", resp["error"])
|
||||
}
|
||||
|
||||
// Same code, now with correct client credentials -> succeeds.
|
||||
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
|
||||
Code: "code-with-auth",
|
||||
ClientID: clientID,
|
||||
RedirectURI: "https://app.example.com/callback",
|
||||
CodeChallenge: challenge,
|
||||
SessionToken: regResp.Token,
|
||||
Scopes: []string{"profile"},
|
||||
ExpiresAt: futureTime(),
|
||||
}); err != nil {
|
||||
t.Fatalf("OAuthSaveCode() error = %v", err)
|
||||
}
|
||||
rec2, _ := doForm(t, mux, "/oauth/token", url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"code-with-auth"},
|
||||
"redirect_uri": {"https://app.example.com/callback"},
|
||||
"client_id": {clientID},
|
||||
"code_verifier": {verifier},
|
||||
}, clientID, clientSecret)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 with client auth, body=%s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthServer_PublicClient_AuthCodeGrantUnaffected(t *testing.T) {
|
||||
srv, auth := newTestOAuthServer(t)
|
||||
mux := srv.HTTPHandler()
|
||||
|
||||
regResp, err := auth.Register(context.Background(), RegisterRequest{
|
||||
Username: "oscar", Password: "p", Email: "oscar@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
|
||||
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
||||
"redirect_uris": []string{"https://app.example.com/callback"},
|
||||
})
|
||||
clientID := reg["client_id"].(string)
|
||||
if _, ok := reg["client_secret"]; ok {
|
||||
t.Fatal("expected no client_secret for a default (public) registration")
|
||||
}
|
||||
|
||||
verifier := "verifier-oscar-1234567890"
|
||||
challenge := s256Challenge(verifier)
|
||||
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
|
||||
Code: "public-code",
|
||||
ClientID: clientID,
|
||||
RedirectURI: "https://app.example.com/callback",
|
||||
CodeChallenge: challenge,
|
||||
SessionToken: regResp.Token,
|
||||
Scopes: []string{"profile"},
|
||||
ExpiresAt: futureTime(),
|
||||
}); err != nil {
|
||||
t.Fatalf("OAuthSaveCode() error = %v", err)
|
||||
}
|
||||
|
||||
// No credentials needed for a public client — PKCE alone is sufficient, unchanged.
|
||||
rec, _ := doForm(t, mux, "/oauth/token", url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"public-code"},
|
||||
"redirect_uri": {"https://app.example.com/callback"},
|
||||
"client_id": {clientID},
|
||||
"code_verifier": {verifier},
|
||||
}, "", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 for public client without credentials, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthServer_ProtectedResourceMetadata(t *testing.T) {
|
||||
srv, _ := newTestOAuthServer(t)
|
||||
mux := srv.HTTPHandler()
|
||||
|
||||
rec, resp := doGet(mux, "/.well-known/oauth-protected-resource", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if resp["resource"] != "https://auth.example.com" {
|
||||
t.Errorf("resource = %v", resp["resource"])
|
||||
}
|
||||
servers, ok := resp["authorization_servers"].([]interface{})
|
||||
if !ok || len(servers) != 1 || servers[0] != "https://auth.example.com" {
|
||||
t.Errorf("authorization_servers = %v", resp["authorization_servers"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthServer_OIDCDiscoveryAndIDToken(t *testing.T) {
|
||||
srv, auth := newTestOAuthServer(t)
|
||||
mux := srv.HTTPHandler()
|
||||
|
||||
// Discovery document.
|
||||
rec, disc := doGet(mux, "/.well-known/openid-configuration", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("discovery status = %d", rec.Code)
|
||||
}
|
||||
if disc["jwks_uri"] != "https://auth.example.com/oauth/jwks.json" {
|
||||
t.Errorf("jwks_uri = %v", disc["jwks_uri"])
|
||||
}
|
||||
grantTypes, _ := disc["grant_types_supported"].([]interface{})
|
||||
found := false
|
||||
for _, g := range grantTypes {
|
||||
if g == "client_credentials" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected client_credentials in grant_types_supported, got %v", grantTypes)
|
||||
}
|
||||
|
||||
// JWKS.
|
||||
rec, _ = doGet(mux, "/oauth/jwks.json", "")
|
||||
var jwks struct {
|
||||
Keys []struct {
|
||||
Kid string `json:"kid"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &jwks)
|
||||
if len(jwks.Keys) != 1 {
|
||||
t.Fatalf("expected 1 JWKS key, got %d", len(jwks.Keys))
|
||||
}
|
||||
|
||||
// End-to-end authorization_code flow with scope=openid, verifying the id_token
|
||||
// signature against the published JWKS key.
|
||||
regResp, err := auth.Register(context.Background(), RegisterRequest{
|
||||
Username: "olivia", Password: "p", Email: "olivia@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
|
||||
_, clientReg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
||||
"redirect_uris": []string{"https://app.example.com/callback"},
|
||||
})
|
||||
clientID := clientReg["client_id"].(string)
|
||||
|
||||
verifier := "test-code-verifier-1234567890"
|
||||
challenge := s256Challenge(verifier)
|
||||
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
|
||||
Code: "oidc-code",
|
||||
ClientID: clientID,
|
||||
RedirectURI: "https://app.example.com/callback",
|
||||
CodeChallenge: challenge,
|
||||
SessionToken: regResp.Token,
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
ExpiresAt: futureTime(),
|
||||
}); err != nil {
|
||||
t.Fatalf("OAuthSaveCode() error = %v", err)
|
||||
}
|
||||
|
||||
tokRec, tokenResp := doForm(t, mux, "/oauth/token", url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {"oidc-code"},
|
||||
"redirect_uri": {"https://app.example.com/callback"},
|
||||
"client_id": {clientID},
|
||||
"code_verifier": {verifier},
|
||||
}, "", "")
|
||||
if tokRec.Code != http.StatusOK {
|
||||
t.Fatalf("token exchange status = %d, body = %s", tokRec.Code, tokRec.Body.String())
|
||||
}
|
||||
idTokenStr, _ := tokenResp["id_token"].(string)
|
||||
if idTokenStr == "" {
|
||||
t.Fatal("expected id_token in response for scope containing openid")
|
||||
}
|
||||
|
||||
nBytes, err := base64.RawURLEncoding.DecodeString(jwks.Keys[0].N)
|
||||
if err != nil {
|
||||
t.Fatalf("decode n: %v", err)
|
||||
}
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(jwks.Keys[0].E)
|
||||
if err != nil {
|
||||
t.Fatalf("decode e: %v", err)
|
||||
}
|
||||
pub := &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: int(new(big.Int).SetBytes(eBytes).Int64())}
|
||||
|
||||
parsed, err := jwt.Parse(idTokenStr, func(token *jwt.Token) (interface{}, error) {
|
||||
return pub, nil
|
||||
}, jwt.WithValidMethods([]string{"RS256"}))
|
||||
if err != nil || !parsed.Valid {
|
||||
t.Fatalf("id_token did not verify against JWKS key: %v", err)
|
||||
}
|
||||
claims := parsed.Claims.(jwt.MapClaims)
|
||||
if claims["iss"] != "https://auth.example.com" {
|
||||
t.Errorf("iss claim = %v", claims["iss"])
|
||||
}
|
||||
if claims["aud"] != clientID {
|
||||
t.Errorf("aud claim = %v, want %v", claims["aud"], clientID)
|
||||
}
|
||||
if claims["preferred_username"] != "olivia" {
|
||||
t.Errorf("preferred_username claim = %v", claims["preferred_username"])
|
||||
}
|
||||
if claims["email"] != "olivia@example.com" {
|
||||
t.Errorf("email claim = %v", claims["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthServer_Userinfo(t *testing.T) {
|
||||
srv, auth := newTestOAuthServer(t)
|
||||
mux := srv.HTTPHandler()
|
||||
|
||||
regResp, err := auth.Register(context.Background(), RegisterRequest{
|
||||
Username: "pete", Password: "p", Email: "pete@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
|
||||
rec, info := doGet(mux, "/oauth/userinfo", regResp.Token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("userinfo status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if info["preferred_username"] != "pete" {
|
||||
t.Errorf("preferred_username = %v", info["preferred_username"])
|
||||
}
|
||||
|
||||
rec2, _ := doGet(mux, "/oauth/userinfo", "not-a-real-token")
|
||||
if rec2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 for invalid token", rec2.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user