From 6414ad4ee5fda731c04222618f172fe1b519dea2 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Thu, 26 Mar 2026 10:02:22 +0800 Subject: [PATCH 1/3] fix: oauth client schema standardization --- domain/token.go | 54 +++++++++++++++++++++++------ internal/service/oauth.go | 15 ++++---- internal/service/oauth_client.go | 38 +++++++++++--------- migrations/003_oauth_clients.up.sql | 50 ++++++++++++++++++++------ 4 files changed, 113 insertions(+), 44 deletions(-) diff --git a/domain/token.go b/domain/token.go index 2555019..2f2f7ea 100644 --- a/domain/token.go +++ b/domain/token.go @@ -65,21 +65,53 @@ type AccessToken struct { // OAuthClient represents a registered OAuth2 client. // Clients are global — tenant scoping happens at token issuance time, not client registration. +// OAuthClient represents a registered OAuth2 client (RFC 7591). +// Clients are global — tenant scoping happens at token issuance, not registration. // The ClientSecret field stores a bcrypt hash and is never serialised to JSON. -// For public clients (PKCE), ClientSecret is empty. type OAuthClient struct { bun.BaseModel `bun:"table:oauth_clients"` - ID string `bun:"id,pk" json:"id"` - ClientID string `bun:"client_id" json:"client_id"` - ClientSecret string `bun:"client_secret" json:"-"` - Name string `bun:"name" json:"name"` - GrantTypes []string `bun:"grant_types,array" json:"grant_types"` - RedirectURIs []string `bun:"redirect_uris,array" json:"redirect_uris"` - Scopes []string `bun:"scopes,array" json:"scopes"` - IsActive bool `bun:"is_active" json:"is_active"` - CreatedAt time.Time `bun:"created_at" json:"created_at"` - UpdatedAt time.Time `bun:"updated_at" json:"updated_at"` + // Core identity + ID string `bun:"id,pk" json:"id"` + ClientID string `bun:"client_id" json:"client_id"` + ClientSecret string `bun:"client_secret" json:"-"` + Name string `bun:"name" json:"name"` + Description string `bun:"description" json:"description,omitempty"` + + // Classification (RFC 6749 §2.1, RFC 7591) + ClientType string `bun:"client_type" json:"client_type"` + TokenEndpointAuthMethod string `bun:"token_endpoint_auth_method" json:"token_endpoint_auth_method,omitempty"` + + // OAuth configuration + GrantTypes []string `bun:"grant_types,array" json:"grant_types"` + RedirectURIs []string `bun:"redirect_uris,array" json:"redirect_uris"` + Scopes []string `bun:"scopes,array" json:"scopes"` + + // Token lifetime (per-client, 0 = use server default) + AccessTokenTTL int `bun:"access_token_ttl" json:"access_token_ttl,omitempty"` + RefreshTokenTTL int `bun:"refresh_token_ttl" json:"refresh_token_ttl,omitempty"` + + // Secret management + ClientSecretExpiresAt *time.Time `bun:"client_secret_expires_at" json:"client_secret_expires_at,omitempty"` + + // Key material (for private_key_jwt — RFC 7523) + JWKSURI string `bun:"jwks_uri" json:"jwks_uri,omitempty"` + JWKS json.RawMessage `bun:"jwks,type:jsonb" json:"jwks,omitempty"` + + // Software identity (RFC 7591) + SoftwareID string `bun:"software_id" json:"software_id,omitempty"` + SoftwareVersion string `bun:"software_version" json:"software_version,omitempty"` + + // Ownership + Contacts []string `bun:"contacts,array" json:"contacts,omitempty"` + + // Extensibility + Metadata json.RawMessage `bun:"metadata,type:jsonb" json:"metadata,omitempty"` + + // Lifecycle + IsActive bool `bun:"is_active" json:"is_active"` + CreatedAt time.Time `bun:"created_at" json:"created_at"` + UpdatedAt time.Time `bun:"updated_at" json:"updated_at"` } // ProofToken represents a persisted WIMSE Proof Token (WPT). diff --git a/internal/service/oauth.go b/internal/service/oauth.go index a5543b1..fe8f686 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -664,9 +664,8 @@ func (s *OAuthService) authorizationCode(ctx context.Context, req TokenRequest) return nil, oauthBadRequest("invalid_grant", "PKCE verification failed") } - // Derive token policy from registered grant_types: if the client is - // authorised for refresh_token, issue short-lived access tokens (the - // refresh token provides continuity). Otherwise issue long-lived tokens. + // Determine access token TTL. + // Priority: per-client config > grant-type-based default > server default. hasRefreshGrant := false for _, g := range oauthClient.GrantTypes { if g == string(domain.GrantTypeRefreshToken) { @@ -675,9 +674,13 @@ func (s *OAuthService) authorizationCode(ctx context.Context, req TokenRequest) } } - ttl := 90 * 24 * 3600 // 90 days for clients without refresh_token grant - if hasRefreshGrant { - ttl = 3600 // 1 hour when refresh tokens provide continuity + ttl := oauthClient.AccessTokenTTL + if ttl <= 0 { + // No per-client TTL — use grant-type-based defaults. + ttl = 90 * 24 * 3600 // 90 days for clients without refresh_token grant + if hasRefreshGrant { + ttl = 3600 // 1 hour when refresh tokens provide continuity + } } // Auth code JWT is self-contained — tenant context comes from the auth code. diff --git a/internal/service/oauth_client.go b/internal/service/oauth_client.go index c6f2b31..11f9ed8 100644 --- a/internal/service/oauth_client.go +++ b/internal/service/oauth_client.go @@ -67,13 +67,15 @@ func (s *OAuthClientService) RegisterClient(ctx context.Context, clientID, name ID: uuid.New().String(), ClientID: clientID, ClientSecret: string(hashed), - Name: name, - GrantTypes: grantTypes, - RedirectURIs: []string{}, - Scopes: scopes, - IsActive: true, - CreatedAt: now, - UpdatedAt: now, + Name: name, + ClientType: "confidential", + TokenEndpointAuthMethod: "client_secret_basic", + GrantTypes: grantTypes, + RedirectURIs: []string{}, + Scopes: scopes, + IsActive: true, + CreatedAt: now, + UpdatedAt: now, } if err := s.repo.Create(ctx, client); err != nil { @@ -114,16 +116,18 @@ func (s *OAuthClientService) RegisterPublicClient(ctx context.Context, name, cli now := time.Now() client := &domain.OAuthClient{ - ID: uuid.New().String(), - ClientID: clientID, - ClientSecret: "", // public client — no secret - Name: name, - GrantTypes: grantTypes, - RedirectURIs: redirectURIs, - Scopes: scopes, - IsActive: true, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.New().String(), + ClientID: clientID, + ClientSecret: "", // public client — no secret + Name: name, + ClientType: "public", + TokenEndpointAuthMethod: "none", + GrantTypes: grantTypes, + RedirectURIs: redirectURIs, + Scopes: scopes, + IsActive: true, + CreatedAt: now, + UpdatedAt: now, } if err := s.repo.Create(ctx, client); err != nil { diff --git a/migrations/003_oauth_clients.up.sql b/migrations/003_oauth_clients.up.sql index 364fb05..8f8b12c 100644 --- a/migrations/003_oauth_clients.up.sql +++ b/migrations/003_oauth_clients.up.sql @@ -2,16 +2,46 @@ -- Creates oauth_clients and oauth_tokens tables CREATE TABLE IF NOT EXISTS oauth_clients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - client_id VARCHAR(255) NOT NULL, - client_secret VARCHAR(255), - name VARCHAR(255) NOT NULL, - grant_types TEXT[] NOT NULL DEFAULT '{"client_credentials"}', - redirect_uris TEXT[] NOT NULL DEFAULT '{}', - scopes TEXT[] NOT NULL DEFAULT '{}', - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id VARCHAR(255) NOT NULL, + client_secret VARCHAR(255) DEFAULT '', + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + + -- Classification (RFC 6749 §2.1, RFC 7591) + client_type VARCHAR(20) NOT NULL DEFAULT 'public', -- public, confidential + token_endpoint_auth_method VARCHAR(50) DEFAULT 'none', -- none, client_secret_basic, client_secret_post, private_key_jwt + + -- OAuth configuration + grant_types TEXT[] NOT NULL DEFAULT '{"client_credentials"}', + redirect_uris TEXT[] NOT NULL DEFAULT '{}', + scopes TEXT[] NOT NULL DEFAULT '{}', + + -- Token lifetime (per-client override, 0 = use server default) + access_token_ttl INTEGER DEFAULT 0, + refresh_token_ttl INTEGER DEFAULT 0, + + -- Secret management + client_secret_expires_at TIMESTAMPTZ, + + -- Key material (for private_key_jwt auth — RFC 7523) + jwks_uri TEXT DEFAULT '', + jwks JSONB, + + -- Software identity (RFC 7591 — identifies the client software) + software_id VARCHAR(255) DEFAULT '', + software_version VARCHAR(100) DEFAULT '', + + -- Ownership + contacts TEXT[] DEFAULT '{}', + + -- Extensibility (avoids future schema changes) + metadata JSONB DEFAULT '{}', + + -- Lifecycle + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id From 7a85d6e1db1f9da43cb5e93f07dcc3ec9a0f8dea Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Thu, 26 Mar 2026 13:19:25 +0800 Subject: [PATCH 2/3] fix: Standardize oauth clients --- hooks.go | 22 +++ internal/handler/oauth_client.go | 93 ++++++---- internal/service/oauth.go | 26 ++- internal/service/oauth_client.go | 168 +++++++++++-------- internal/service/refresh_token.go | 21 ++- server.go | 97 +++++++++-- tests/integration/authorization_code_test.go | 112 +++++++++++++ tests/integration/helpers_test.go | 5 +- 8 files changed, 409 insertions(+), 135 deletions(-) diff --git a/hooks.go b/hooks.go index a96b067..c9178ec 100644 --- a/hooks.go +++ b/hooks.go @@ -2,6 +2,7 @@ package zeroid import ( "context" + "encoding/json" "net/http" "github.com/highflame-ai/zeroid/domain" @@ -39,6 +40,27 @@ type GrantRequest struct { // network layer (VPN, service mesh, localhost-only binding, firewall rules). type AdminAuthMiddleware func(next http.Handler) http.Handler +// OAuthClientConfig holds all fields for registering an OAuth2 client (RFC 7591). +// Used by EnsureClient for startup seeding and by deployers for programmatic registration. +type OAuthClientConfig struct { + ClientID string + Name string + Description string + Confidential bool + TokenEndpointAuthMethod string + GrantTypes []string + Scopes []string + RedirectURIs []string + AccessTokenTTL int + RefreshTokenTTL int + JWKSURI string + JWKS json.RawMessage + SoftwareID string + SoftwareVersion string + Contacts []string + Metadata json.RawMessage +} + // TrustedServiceValidator checks whether the current request comes from a trusted // internal service that is allowed to perform external principal token exchange // (RFC 8693). Implementations read from context (set by deployer-provided global diff --git a/internal/handler/oauth_client.go b/internal/handler/oauth_client.go index 7c38c33..2eb32c1 100644 --- a/internal/handler/oauth_client.go +++ b/internal/handler/oauth_client.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "errors" "net/http" @@ -16,14 +17,37 @@ import ( type CreateOAuthClientInput struct { Body struct { - ClientID string `json:"client_id" required:"true" minLength:"1" doc:"Globally unique client identifier"` - Name string `json:"name" required:"true" minLength:"1" doc:"Client display name"` + // Core + ClientID string `json:"client_id" required:"true" minLength:"1" doc:"Globally unique client identifier"` + Name string `json:"name" required:"true" minLength:"1" doc:"Client display name"` + Description string `json:"description,omitempty" doc:"Human-readable description"` + + // Classification + Confidential bool `json:"confidential,omitempty" doc:"If true, generates a client_secret for M2M flows"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty" doc:"Auth method: none, client_secret_basic, client_secret_post, private_key_jwt"` + + // OAuth configuration GrantTypes []string `json:"grant_types,omitempty" doc:"Permitted OAuth grant types"` Scopes []string `json:"scopes,omitempty" doc:"Permitted OAuth scopes"` RedirectURIs []string `json:"redirect_uris,omitempty" doc:"Allowed redirect URIs (required for authorization_code clients)"` - // Confidential when true — generates a client_secret for M2M flows. - // Public (false/omitted) — PKCE only, no secret. - Confidential bool `json:"confidential,omitempty" doc:"If true, generates a client_secret for client_credentials grant"` + + // Token lifetime (0 = server default) + AccessTokenTTL int `json:"access_token_ttl,omitempty" doc:"Access token lifetime in seconds"` + RefreshTokenTTL int `json:"refresh_token_ttl,omitempty" doc:"Refresh token lifetime in seconds"` + + // Key material (for private_key_jwt) + JWKSURI string `json:"jwks_uri,omitempty" doc:"URL to client's public JWK Set"` + JWKS json.RawMessage `json:"jwks,omitempty" doc:"Inline JWK Set (when no URI available)"` + + // Software identity (RFC 7591) + SoftwareID string `json:"software_id,omitempty" doc:"Identifies the client software"` + SoftwareVersion string `json:"software_version,omitempty" doc:"Client software version"` + + // Ownership + Contacts []string `json:"contacts,omitempty" doc:"Email addresses of responsible parties"` + + // Extensibility + Metadata json.RawMessage `json:"metadata,omitempty" doc:"Arbitrary JSON metadata"` } } @@ -103,45 +127,40 @@ func (a *API) registerOAuthClientRoutes(api huma.API) { } func (a *API) createOAuthClientOp(ctx context.Context, input *CreateOAuthClientInput) (*OAuthClientCreatedOutput, error) { - out := &OAuthClientCreatedOutput{} - - if input.Body.Confidential { - // Confidential client — generates a client_secret for M2M (client_credentials) flows. - // Identity link happens at token issuance time, not at registration. - client, plainSecret, regErr := a.oauthClientSvc.RegisterClient( - ctx, input.Body.ClientID, input.Body.Name, - input.Body.GrantTypes, input.Body.Scopes, - ) - if regErr != nil { - if errors.Is(regErr, service.ErrOAuthClientAlreadyExists) { - return nil, huma.Error409Conflict("oauth client with this client_id already exists") - } - log.Error().Err(regErr).Msg("failed to register oauth client") - return nil, huma.Error500InternalServerError("failed to register oauth client") + client, plainSecret, err := a.oauthClientSvc.RegisterClient(ctx, service.RegisterClientRequest{ + ClientID: input.Body.ClientID, + Name: input.Body.Name, + Description: input.Body.Description, + Confidential: input.Body.Confidential, + TokenEndpointAuthMethod: input.Body.TokenEndpointAuthMethod, + GrantTypes: input.Body.GrantTypes, + Scopes: input.Body.Scopes, + RedirectURIs: input.Body.RedirectURIs, + AccessTokenTTL: input.Body.AccessTokenTTL, + RefreshTokenTTL: input.Body.RefreshTokenTTL, + JWKSURI: input.Body.JWKSURI, + JWKS: input.Body.JWKS, + SoftwareID: input.Body.SoftwareID, + SoftwareVersion: input.Body.SoftwareVersion, + Contacts: input.Body.Contacts, + Metadata: input.Body.Metadata, + }) + if err != nil { + if errors.Is(err, service.ErrOAuthClientAlreadyExists) { + return nil, huma.Error409Conflict("oauth client with this client_id already exists") } + log.Error().Err(err).Msg("failed to register oauth client") + return nil, huma.Error500InternalServerError("failed to register oauth client") + } - out.Body.Client = client + out := &OAuthClientCreatedOutput{} + out.Body.Client = client + if input.Body.Confidential { out.Body.ClientSecret = plainSecret out.Body.Note = "Save client_secret now — it will not be shown again." } else { - // Public client — PKCE only, no secret. - client, regErr := a.oauthClientSvc.RegisterPublicClient( - ctx, input.Body.Name, input.Body.ClientID, - input.Body.RedirectURIs, - input.Body.GrantTypes, input.Body.Scopes, - ) - if regErr != nil { - if errors.Is(regErr, service.ErrOAuthClientAlreadyExists) { - return nil, huma.Error409Conflict("oauth client with this client_id already exists") - } - log.Error().Err(regErr).Msg("failed to register public oauth client") - return nil, huma.Error500InternalServerError("failed to register public oauth client") - } - - out.Body.Client = client out.Body.Note = "Public PKCE client registered — no client_secret (use PKCE code_challenge instead)." } - return out, nil } diff --git a/internal/service/oauth.go b/internal/service/oauth.go index fe8f686..650cb9c 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -43,6 +43,12 @@ type OAuthService struct { // CustomGrantHandler implements a custom OAuth2 grant type. type CustomGrantHandler func(ctx context.Context, req TokenRequest) (*domain.AccessToken, error) +// Default token TTLs (used when per-client TTL is not configured). +const ( + defaultAccessTokenTTLWithRefresh = 3600 // 1 hour when refresh tokens provide continuity + defaultAccessTokenTTLNoRefresh = 90 * 24 * 3600 // 90 days for clients without refresh_token grant +) + // reservedClaims are standard JWT and ZeroID claims that additional_claims cannot override. var reservedClaims = map[string]bool{ // RFC 7519 registered claims @@ -677,9 +683,9 @@ func (s *OAuthService) authorizationCode(ctx context.Context, req TokenRequest) ttl := oauthClient.AccessTokenTTL if ttl <= 0 { // No per-client TTL — use grant-type-based defaults. - ttl = 90 * 24 * 3600 // 90 days for clients without refresh_token grant + ttl = defaultAccessTokenTTLNoRefresh if hasRefreshGrant { - ttl = 3600 // 1 hour when refresh tokens provide continuity + ttl = defaultAccessTokenTTLWithRefresh } } @@ -715,6 +721,7 @@ func (s *OAuthService) authorizationCode(ctx context.Context, req TokenRequest) ProjectID: authCode.ProjectID, UserID: authCode.UserID, Scopes: strings.Join(authCode.Scopes, " "), + TTL: oauthClient.RefreshTokenTTL, }) if rtErr != nil { log.Error().Err(rtErr).Msg("Failed to issue refresh token — returning access token only") @@ -737,7 +744,18 @@ func (s *OAuthService) refreshToken(ctx context.Context, req TokenRequest) (*dom return nil, oauthServerError("refresh tokens not configured", nil) } - oldToken, newRT, err := s.refreshTokenSvc.RotateRefreshToken(ctx, req.RefreshTokenStr) + // Look up client to get per-client TTL settings. + var accessTTL, refreshTokenTTL int + if oauthClient, err := s.oauthClientSvc.GetClientByClientID(ctx, req.ClientID); err == nil { + accessTTL = oauthClient.AccessTokenTTL + refreshTokenTTL = oauthClient.RefreshTokenTTL + } + + if accessTTL <= 0 { + accessTTL = defaultAccessTokenTTLWithRefresh + } + + oldToken, newRT, err := s.refreshTokenSvc.RotateRefreshToken(ctx, req.RefreshTokenStr, refreshTokenTTL) if err != nil { return nil, oauthBadRequestCause("invalid_grant", "invalid or expired refresh token", err) } @@ -758,7 +776,7 @@ func (s *OAuthService) refreshToken(ctx context.Context, req TokenRequest) (*dom UseRS256: true, SubjectOverride: oldToken.UserID, ApplicationID: oldToken.ClientID, - TTL: 3600, // 1 hour + TTL: accessTTL, }) if err != nil { return nil, err diff --git a/internal/service/oauth_client.go b/internal/service/oauth_client.go index 11f9ed8..256a4d1 100644 --- a/internal/service/oauth_client.go +++ b/internal/service/oauth_client.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" "time" @@ -35,96 +36,109 @@ func NewOAuthClientService(repo *postgres.OAuthClientRepository) *OAuthClientSer return &OAuthClientService{repo: repo} } -// RegisterClient creates a new confidential OAuth2 client (M2M flows). -// Generates and bcrypt-hashes a client secret. -// Returns the created client and the plain-text secret (shown once only). +// RegisterClientRequest holds all fields for creating an OAuth2 client. +// Confidential clients get a generated bcrypt secret; public clients have none. +type RegisterClientRequest struct { + ClientID string + Name string + Description string + Confidential bool + TokenEndpointAuthMethod string + GrantTypes []string + Scopes []string + RedirectURIs []string + AccessTokenTTL int + RefreshTokenTTL int + JWKSURI string + JWKS json.RawMessage + SoftwareID string + SoftwareVersion string + Contacts []string + Metadata json.RawMessage +} + +// RegisterClient creates a new OAuth2 client. +// +// If req.Confidential is true, a client_secret is generated and bcrypt-hashed; +// the plain-text secret is returned (shown once only). For public clients the +// returned secret string is empty. +// // Identity link is resolved at token issuance time (client_credentials grant), // not at registration time — matching industry standard (Auth0, Okta). -func (s *OAuthClientService) RegisterClient(ctx context.Context, clientID, name string, grantTypes, scopes []string) (*domain.OAuthClient, string, error) { - if clientID == "" || name == "" { +func (s *OAuthClientService) RegisterClient(ctx context.Context, req RegisterClientRequest) (*domain.OAuthClient, string, error) { + if req.ClientID == "" || req.Name == "" { return nil, "", fmt.Errorf("clientID and name are required") } - plainSecret, err := generateSecureToken(32) - if err != nil { - return nil, "", fmt.Errorf("failed to generate client_secret: %w", err) - } + var plainSecret string + var hashedSecret string + var clientType string + var authMethod string - hashed, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost) - if err != nil { - return nil, "", fmt.Errorf("failed to hash client secret: %w", err) - } - - if len(grantTypes) == 0 { - grantTypes = []string{"client_credentials"} - } - if len(scopes) == 0 { - scopes = []string{} + if req.Confidential { + secret, err := generateSecureToken(32) + if err != nil { + return nil, "", fmt.Errorf("failed to generate client_secret: %w", err) + } + hashed, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + if err != nil { + return nil, "", fmt.Errorf("failed to hash client secret: %w", err) + } + plainSecret = secret + hashedSecret = string(hashed) + clientType = "confidential" + authMethod = "client_secret_basic" + } else { + clientType = "public" + authMethod = "none" } - now := time.Now() - client := &domain.OAuthClient{ - ID: uuid.New().String(), - ClientID: clientID, - ClientSecret: string(hashed), - Name: name, - ClientType: "confidential", - TokenEndpointAuthMethod: "client_secret_basic", - GrantTypes: grantTypes, - RedirectURIs: []string{}, - Scopes: scopes, - IsActive: true, - CreatedAt: now, - UpdatedAt: now, + if req.TokenEndpointAuthMethod != "" { + authMethod = req.TokenEndpointAuthMethod } - if err := s.repo.Create(ctx, client); err != nil { - if isDuplicateKeyError(err) { - return nil, "", ErrOAuthClientAlreadyExists + grantTypes := req.GrantTypes + if len(grantTypes) == 0 { + if req.Confidential { + grantTypes = []string{"client_credentials"} + } else { + grantTypes = []string{"authorization_code"} } - return nil, "", fmt.Errorf("failed to register oauth client: %w", err) } - log.Info(). - Str("client_id", clientID). - Msg("OAuth2 confidential client registered") - - return client, plainSecret, nil -} - -// RegisterPublicClient creates a public OAuth2 client for user-facing flows -// (authorization_code + PKCE). Public clients have no client_secret and no -// linked agent identity — the user authenticates separately. -// -// clientID is the string the client presents in the authorization_code exchange. -// Token issuance behaviour is derived from grant_types: clients registered with -// "refresh_token" receive short-lived (1h) access tokens plus rotating refresh -// tokens; clients without it receive long-lived (90-day) tokens. -func (s *OAuthClientService) RegisterPublicClient(ctx context.Context, name, clientID string, redirectURIs, grantTypes, scopes []string) (*domain.OAuthClient, error) { - if name == "" || clientID == "" { - return nil, fmt.Errorf("name and clientID are required") - } - if len(grantTypes) == 0 { - grantTypes = []string{"authorization_code"} - } + redirectURIs := req.RedirectURIs if redirectURIs == nil { redirectURIs = []string{} } + scopes := req.Scopes if scopes == nil { scopes = []string{} } + contacts := req.Contacts + if contacts == nil { + contacts = []string{} + } now := time.Now() client := &domain.OAuthClient{ ID: uuid.New().String(), - ClientID: clientID, - ClientSecret: "", // public client — no secret - Name: name, - ClientType: "public", - TokenEndpointAuthMethod: "none", + ClientID: req.ClientID, + ClientSecret: hashedSecret, + Name: req.Name, + Description: req.Description, + ClientType: clientType, + TokenEndpointAuthMethod: authMethod, GrantTypes: grantTypes, RedirectURIs: redirectURIs, Scopes: scopes, + AccessTokenTTL: req.AccessTokenTTL, + RefreshTokenTTL: req.RefreshTokenTTL, + JWKSURI: req.JWKSURI, + JWKS: req.JWKS, + SoftwareID: req.SoftwareID, + SoftwareVersion: req.SoftwareVersion, + Contacts: contacts, + Metadata: req.Metadata, IsActive: true, CreatedAt: now, UpdatedAt: now, @@ -132,16 +146,17 @@ func (s *OAuthClientService) RegisterPublicClient(ctx context.Context, name, cli if err := s.repo.Create(ctx, client); err != nil { if isDuplicateKeyError(err) { - return nil, ErrOAuthClientAlreadyExists + return nil, "", ErrOAuthClientAlreadyExists } - return nil, fmt.Errorf("failed to register public client: %w", err) + return nil, "", fmt.Errorf("failed to register oauth client: %w", err) } log.Info(). - Str("client_id", clientID). - Msg("OAuth2 public client registered (global)") + Str("client_id", req.ClientID). + Str("client_type", clientType). + Msg("OAuth2 client registered") - return client, nil + return client, plainSecret, nil } // GetPublicClient retrieves a registered public PKCE client by client_id. @@ -156,6 +171,16 @@ func (s *OAuthClientService) GetPublicClient(ctx context.Context, clientID strin return client, nil } +// GetClientByClientID retrieves any client (public or confidential) by client_id. +func (s *OAuthClientService) GetClientByClientID(ctx context.Context, clientID string) (*domain.OAuthClient, error) { + client, err := s.repo.GetByClientID(ctx, clientID) + if err != nil { + return nil, ErrOAuthClientNotFound + } + + return client, nil +} + // GetClient retrieves a client by UUID. func (s *OAuthClientService) GetClient(ctx context.Context, id string) (*domain.OAuthClient, error) { client, err := s.repo.GetByID(ctx, id) @@ -212,6 +237,11 @@ func (s *OAuthClientService) RotateSecret(ctx context.Context, id string) (*doma return client, plainSecret, nil } +// UpdateClient persists changes to a client record. +func (s *OAuthClientService) UpdateClient(ctx context.Context, client *domain.OAuthClient) error { + return s.repo.Update(ctx, client) +} + // DeleteClient removes an OAuth2 client. func (s *OAuthClientService) DeleteClient(ctx context.Context, id string) error { return s.repo.Delete(ctx, id) diff --git a/internal/service/refresh_token.go b/internal/service/refresh_token.go index 242e73a..4884dbb 100644 --- a/internal/service/refresh_token.go +++ b/internal/service/refresh_token.go @@ -36,6 +36,7 @@ type RefreshTokenParams struct { UserID string IdentityID *string Scopes string + TTL int // seconds, 0 = use default (90 days) } // RefreshTokenResult contains both the raw token (returned to client) and stored metadata. @@ -55,7 +56,13 @@ func (s *RefreshTokenService) IssueRefreshToken(ctx context.Context, params *Ref tokenHash := hashRefreshToken(rawToken) familyID := uuid.New().String() - ttl := time.Duration(domain.RefreshTokenTTLDays) * 24 * time.Hour + var ttl time.Duration + if params.TTL > 0 { + ttl = time.Duration(params.TTL) * time.Second + } else { + ttl = time.Duration(domain.RefreshTokenTTLDays) * 24 * time.Hour + } + expiresAt := time.Now().Add(ttl) record := &domain.RefreshToken{ @@ -86,7 +93,7 @@ func (s *RefreshTokenService) IssueRefreshToken(ctx context.Context, params *Ref // Implements reuse detection: if a revoked token is presented, the entire family is revoked. // Wrapped in a serializable transaction to prevent race conditions where two concurrent // calls with the same token both succeed and issue duplicate tokens. -func (s *RefreshTokenService) RotateRefreshToken(ctx context.Context, rawToken string) (*domain.RefreshToken, *RefreshTokenResult, error) { +func (s *RefreshTokenService) RotateRefreshToken(ctx context.Context, rawToken string, ttl int) (*domain.RefreshToken, *RefreshTokenResult, error) { tokenHash := hashRefreshToken(rawToken) var existing *domain.RefreshToken @@ -135,8 +142,14 @@ func (s *RefreshTokenService) RotateRefreshToken(ctx context.Context, rawToken s newTokenHash := hashRefreshToken(newRawToken) - ttl := time.Duration(domain.RefreshTokenTTLDays) * 24 * time.Hour - expiresAt := time.Now().Add(ttl) + var rotationTTL time.Duration + if ttl > 0 { + rotationTTL = time.Duration(ttl) * time.Second + } else { + rotationTTL = time.Duration(domain.RefreshTokenTTLDays) * 24 * time.Hour + } + + expiresAt := time.Now().Add(rotationTTL) newRecord := &domain.RefreshToken{ TokenHash: newTokenHash, diff --git a/server.go b/server.go index c35817e..4833693 100644 --- a/server.go +++ b/server.go @@ -441,29 +441,90 @@ func (s *Server) GetIdentity(ctx context.Context, id, accountID, projectID strin return s.identitySvc.GetIdentity(ctx, id, accountID, projectID) } -// PublicClientConfig defines a public OAuth client to ensure on startup. -type PublicClientConfig struct { - ClientID string - Name string - GrantTypes []string - Scopes []string - RedirectURIs []string +// EnsureClient registers an OAuth client if it doesn't exist, or updates it if the +// config has changed. Idempotent — safe to call on every startup. +// Does not regenerate client_secret on update (secrets are rotated explicitly). +func (s *Server) EnsureClient(ctx context.Context, cfg OAuthClientConfig) error { + existing, err := s.oauthClientSvc.GetClientByClientID(ctx, cfg.ClientID) + if err != nil { + // Client doesn't exist — create it. + _, _, regErr := s.oauthClientSvc.RegisterClient(ctx, service.RegisterClientRequest{ + ClientID: cfg.ClientID, + Name: cfg.Name, + Description: cfg.Description, + Confidential: cfg.Confidential, + TokenEndpointAuthMethod: cfg.TokenEndpointAuthMethod, + GrantTypes: cfg.GrantTypes, + Scopes: cfg.Scopes, + RedirectURIs: cfg.RedirectURIs, + AccessTokenTTL: cfg.AccessTokenTTL, + RefreshTokenTTL: cfg.RefreshTokenTTL, + JWKSURI: cfg.JWKSURI, + JWKS: cfg.JWKS, + SoftwareID: cfg.SoftwareID, + SoftwareVersion: cfg.SoftwareVersion, + Contacts: cfg.Contacts, + Metadata: cfg.Metadata, + }) + + return regErr + } + + // Client exists — update mutable fields from config. + // Secret is NOT touched (rotated explicitly via RotateSecret). + updated := false + + if cfg.Name != "" && cfg.Name != existing.Name { + existing.Name = cfg.Name + updated = true + } + if cfg.Description != "" && cfg.Description != existing.Description { + existing.Description = cfg.Description + updated = true + } + if len(cfg.GrantTypes) > 0 && !slicesEqual(cfg.GrantTypes, existing.GrantTypes) { + existing.GrantTypes = cfg.GrantTypes + updated = true + } + if len(cfg.Scopes) > 0 && !slicesEqual(cfg.Scopes, existing.Scopes) { + existing.Scopes = cfg.Scopes + updated = true + } + if len(cfg.RedirectURIs) > 0 && !slicesEqual(cfg.RedirectURIs, existing.RedirectURIs) { + existing.RedirectURIs = cfg.RedirectURIs + updated = true + } + if cfg.AccessTokenTTL > 0 && cfg.AccessTokenTTL != existing.AccessTokenTTL { + existing.AccessTokenTTL = cfg.AccessTokenTTL + updated = true + } + if cfg.RefreshTokenTTL > 0 && cfg.RefreshTokenTTL != existing.RefreshTokenTTL { + existing.RefreshTokenTTL = cfg.RefreshTokenTTL + updated = true + } + + if !updated { + return nil + } + + existing.UpdatedAt = time.Now() + + return s.oauthClientSvc.UpdateClient(ctx, existing) } -// EnsurePublicClient registers a public PKCE OAuth client if it doesn't already exist. -// Public clients are global — no tenant scoping. Idempotent, safe to call on every startup. -func (s *Server) EnsurePublicClient(ctx context.Context, cfg PublicClientConfig) error { - _, err := s.oauthClientSvc.GetPublicClient(ctx, cfg.ClientID) - if err == nil { - return nil // already exists +// slicesEqual returns true if two string slices have the same elements in order. +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false } - _, regErr := s.oauthClientSvc.RegisterPublicClient( - ctx, cfg.Name, cfg.ClientID, - cfg.RedirectURIs, cfg.GrantTypes, cfg.Scopes, - ) + for i := range a { + if a[i] != b[i] { + return false + } + } - return regErr + return true } // ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/authorization_code_test.go b/tests/integration/authorization_code_test.go index 40aa191..057beaa 100644 --- a/tests/integration/authorization_code_test.go +++ b/tests/integration/authorization_code_test.go @@ -1,10 +1,12 @@ package integration_test import ( + "context" "net/http" "testing" "time" + zeroid "github.com/highflame-ai/zeroid" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/stretchr/testify/assert" @@ -256,3 +258,113 @@ func TestRefreshTokenRotation(t *testing.T) { body := decode(t, resp) assert.Equal(t, "invalid_grant", body["error"]) } + +// TestAuthorizationCodePerClientTTL verifies that a client with a custom +// access_token_ttl gets a token with that exact TTL, overriding the +// grant-type-based defaults (90 days or 1 hour). +func TestAuthorizationCodePerClientTTL(t *testing.T) { + customTTL := 7200 // 2 hours + customClientID := uid("ttl-client") + + // Register a public client with custom access_token_ttl. + resp := post(t, "/api/v1/oauth/clients", map[string]any{ + "client_id": customClientID, + "name": "Custom TTL Client", + "grant_types": []string{"authorization_code", "refresh_token"}, + "redirect_uris": []string{testRedirectURI}, + "access_token_ttl": customTTL, + }, nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + // Exchange an auth code for a token. + verifier, challenge := buildPKCEPair(t) + code := buildAuthCode(t, customClientID, "user-ttl-001", testRedirectURI, challenge, []string{"data:read"}) + + resp = post(t, "/oauth2/token", map[string]any{ + "grant_type": "authorization_code", + "client_id": customClientID, + "code": code, + "code_verifier": verifier, + "redirect_uri": testRedirectURI, + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + + token := decode(t, resp) + + // Token TTL must match the per-client override, not the default (1 hour for refresh clients). + assert.EqualValues(t, customTTL, token["expires_in"], + "access token TTL should match per-client access_token_ttl (%d), not grant-type default", customTTL) + + // Refresh token should still be issued (client has refresh_token grant). + assert.NotEmpty(t, token["refresh_token"], "client with refresh_token grant should receive a refresh token") +} + +// TestEnsureClientUpdatesConfig verifies that EnsureClient updates mutable fields +// when the config changes, without regenerating the client_secret. +func TestEnsureClientUpdatesConfig(t *testing.T) { + clientID := uid("ensure-update") + + // First call — creates the client. + err := testZeroIDServer.EnsureClient(context.Background(), zeroid.OAuthClientConfig{ + ClientID: clientID, + Name: "Original Name", + GrantTypes: []string{"authorization_code"}, + RedirectURIs: []string{testRedirectURI}, + AccessTokenTTL: 3600, + }) + require.NoError(t, err) + + // Verify initial state. + resp := get(t, "/api/v1/oauth/clients", nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + body := decode(t, resp) + clients := body["clients"].([]any) + + var found map[string]any + for _, c := range clients { + m := c.(map[string]any) + if m["client_id"] == clientID { + found = m + break + } + } + + require.NotNil(t, found, "client should exist after EnsureClient") + assert.Equal(t, "Original Name", found["name"]) + assert.EqualValues(t, 3600, found["access_token_ttl"]) + + // Second call — updates name and TTL. + err = testZeroIDServer.EnsureClient(context.Background(), zeroid.OAuthClientConfig{ + ClientID: clientID, + Name: "Updated Name", + GrantTypes: []string{"authorization_code", "refresh_token"}, + RedirectURIs: []string{testRedirectURI}, + AccessTokenTTL: 7776000, + RefreshTokenTTL: 7776000, + }) + require.NoError(t, err) + + // Verify updated state. + resp = get(t, "/api/v1/oauth/clients", nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + body = decode(t, resp) + clients = body["clients"].([]any) + + found = nil + for _, c := range clients { + m := c.(map[string]any) + if m["client_id"] == clientID { + found = m + break + } + } + + require.NotNil(t, found, "client should still exist after update") + assert.Equal(t, "Updated Name", found["name"]) + assert.EqualValues(t, 7776000, found["access_token_ttl"]) + assert.EqualValues(t, 7776000, found["refresh_token_ttl"]) + + // Grant types should be updated. + grantTypes := found["grant_types"].([]any) + assert.Equal(t, 2, len(grantTypes)) +} diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go index aee8804..49330c9 100644 --- a/tests/integration/helpers_test.go +++ b/tests/integration/helpers_test.go @@ -421,13 +421,12 @@ func writeRSAKeyFiles(privKey *rsa.PrivateKey) (privPath, pubPath string, cleanu }, nil } -// registerTestOAuthClient registers a global public PKCE client via Server.EnsurePublicClient. +// registerTestOAuthClient registers a global public PKCE client via Server.EnsureClient. // Called once in TestMain before any tests run. -// Public clients are global — no tenant scoping. Tenant comes from the auth code JWT. // grantTypes controls token behaviour: include "refresh_token" for MCP-style // short-lived tokens with refresh rotation. func registerTestOAuthClient(clientID string, grantTypes []string) { - err := testZeroIDServer.EnsurePublicClient(context.Background(), zeroid.PublicClientConfig{ + err := testZeroIDServer.EnsureClient(context.Background(), zeroid.OAuthClientConfig{ ClientID: clientID, Name: clientID + "-test-client", GrantTypes: grantTypes, From 8c5f60535048e39dd4d1e3f2eab1e3a4ac1010a3 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Thu, 26 Mar 2026 14:45:57 +0800 Subject: [PATCH 3/3] fix: Review comments --- domain/token.go | 2 -- internal/service/oauth.go | 2 ++ internal/store/postgres/oauth_client.go | 4 ++-- server.go | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/domain/token.go b/domain/token.go index 2f2f7ea..2d97413 100644 --- a/domain/token.go +++ b/domain/token.go @@ -63,8 +63,6 @@ type AccessToken struct { RefreshToken string `json:"refresh_token,omitempty"` } -// OAuthClient represents a registered OAuth2 client. -// Clients are global — tenant scoping happens at token issuance time, not client registration. // OAuthClient represents a registered OAuth2 client (RFC 7591). // Clients are global — tenant scoping happens at token issuance, not registration. // The ClientSecret field stores a bcrypt hash and is never serialised to JSON. diff --git a/internal/service/oauth.go b/internal/service/oauth.go index 650cb9c..fc65fab 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -749,6 +749,8 @@ func (s *OAuthService) refreshToken(ctx context.Context, req TokenRequest) (*dom if oauthClient, err := s.oauthClientSvc.GetClientByClientID(ctx, req.ClientID); err == nil { accessTTL = oauthClient.AccessTokenTTL refreshTokenTTL = oauthClient.RefreshTokenTTL + } else { + log.Warn().Err(err).Str("client_id", req.ClientID).Msg("failed to get oauth client for TTL override, using defaults") } if accessTTL <= 0 { diff --git a/internal/store/postgres/oauth_client.go b/internal/store/postgres/oauth_client.go index ce74723..dda5d6c 100644 --- a/internal/store/postgres/oauth_client.go +++ b/internal/store/postgres/oauth_client.go @@ -41,12 +41,12 @@ func (r *OAuthClientRepository) GetByClientID(ctx context.Context, clientID stri } // GetPublicByClientID retrieves a public client by its OAuth2 client_id only. -// Public PKCE clients have no client_secret. +// Public PKCE clients are identified by client_type = 'public'. func (r *OAuthClientRepository) GetPublicByClientID(ctx context.Context, clientID string) (*domain.OAuthClient, error) { client := &domain.OAuthClient{} err := r.db.NewSelect().Model(client). Where("client_id = ?", clientID). - Where("client_secret = ''"). + Where("client_type = ?", "public"). Scan(ctx) if err != nil { return nil, fmt.Errorf("failed to get public oauth client: %w", err) diff --git a/server.go b/server.go index 4833693..e422305 100644 --- a/server.go +++ b/server.go @@ -482,15 +482,15 @@ func (s *Server) EnsureClient(ctx context.Context, cfg OAuthClientConfig) error existing.Description = cfg.Description updated = true } - if len(cfg.GrantTypes) > 0 && !slicesEqual(cfg.GrantTypes, existing.GrantTypes) { + if cfg.GrantTypes != nil && !slicesEqual(cfg.GrantTypes, existing.GrantTypes) { existing.GrantTypes = cfg.GrantTypes updated = true } - if len(cfg.Scopes) > 0 && !slicesEqual(cfg.Scopes, existing.Scopes) { + if cfg.Scopes != nil && !slicesEqual(cfg.Scopes, existing.Scopes) { existing.Scopes = cfg.Scopes updated = true } - if len(cfg.RedirectURIs) > 0 && !slicesEqual(cfg.RedirectURIs, existing.RedirectURIs) { + if cfg.RedirectURIs != nil && !slicesEqual(cfg.RedirectURIs, existing.RedirectURIs) { existing.RedirectURIs = cfg.RedirectURIs updated = true }