Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions domain/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,23 +63,53 @@ 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.
// 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).
Expand Down
22 changes: 22 additions & 0 deletions hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package zeroid

import (
"context"
"encoding/json"
"net/http"

"github.com/highflame-ai/zeroid/domain"
Expand Down Expand Up @@ -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
Expand Down
93 changes: 56 additions & 37 deletions internal/handler/oauth_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"context"
"encoding/json"
"errors"
"net/http"

Expand All @@ -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"`
}
}

Expand Down Expand Up @@ -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
}

Expand Down
39 changes: 31 additions & 8 deletions internal/service/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -664,9 +670,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) {
Expand All @@ -675,9 +680,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 = defaultAccessTokenTTLNoRefresh
if hasRefreshGrant {
ttl = defaultAccessTokenTTLWithRefresh
}
}

// Auth code JWT is self-contained — tenant context comes from the auth code.
Expand Down Expand Up @@ -712,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")
Expand All @@ -734,7 +744,20 @@ 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
} else {
log.Warn().Err(err).Str("client_id", req.ClientID).Msg("failed to get oauth client for TTL override, using defaults")
}

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)
}
Expand All @@ -755,7 +778,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
Expand Down
Loading