feat(token): add per-client token lifetime profiles#168
Conversation
- Add TokenProfile field on OAuthApplication with short/standard/long presets - Resolve per-client TTL at token issuance across device, auth code, client credentials, and refresh flows - Add configurable TTL caps (JWT_EXPIRATION_MAX, REFRESH_TOKEN_EXPIRATION_MAX) enforced at startup - Expose Token Lifetime dropdown in the admin client form and detail page - Log profile changes with WARNING severity in the audit log - Extend TokenProvider interface methods to accept an explicit TTL (0 preserves provider default) BREAKING CHANGE: TokenProvider interface methods GenerateToken, GenerateRefreshToken, and GenerateClientCredentialsToken now take a trailing ttl time.Duration argument; RefreshAccessToken takes accessTTL, refreshTTL time.Duration. Pass 0 on each to preserve previous behavior (use config default). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
Adds per-OAuth-client token lifetime “profiles” (short/standard/long) and threads explicit TTL overrides through token issuance so profile changes apply on subsequent token generation/refresh.
Changes:
- Introduces
OAuthApplication.TokenProfile(model + admin UI) and validates/normalizes it inClientService. - Extends
core.TokenProvidergeneration/refresh methods with TTL override parameters and updatesLocalTokenProvider+ call sites. - Adds config-driven token profile presets with startup validation against max TTL caps, plus associated tests/docs.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/core/token.go | Extends TokenProvider interface with TTL override parameters. |
| internal/token/local.go | Applies TTL override semantics (and jitter behavior) in the local provider. |
| internal/services/token.go | Resolves per-client TTLs during issuance; avoids extra client lookups when possible. |
| internal/services/token_refresh.go | Re-resolves TTLs at refresh time so profile changes take effect immediately. |
| internal/services/token_client_credentials.go | Applies per-client TTL to client credentials issuance. |
| internal/services/client.go | Adds TokenProfile validation/defaulting and audit logging for profile changes. |
| internal/models/oauth_application.go | Adds TokenProfile constants/validation and DB field with default. |
| internal/templates/admin_client_form.templ | Adds admin UI select for TokenProfile. |
| internal/templates/admin_client_detail.templ | Displays TokenProfile on client detail page. |
| internal/config/config.go | Adds TokenProfiles map + hard caps and validation. |
| internal/*_test.go | Updates existing tests for new signatures and adds coverage for profile TTL behavior. |
| .env.example / CLAUDE.md | Documents new env vars and feature behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Trim whitespace in normalizeTokenProfile so " standard " is accepted. - Normalize empty previous TokenProfile before comparing, so migrated rows going "" → short/long now produce a WARNING audit event. - ttlForClient returns 0 when the standard profile mirrors base JWT config, restoring JWT_EXPIRATION_JITTER for the default path; explicit short/long and diverged standard still override as before. - IssueClientCredentialsToken passes ttl=0 so CLIENT_CREDENTIALS_TOKEN_EXPIRATION remains the authority for M2M token lifetime, keeping it independently constrained from user-delegated tokens. - Admin "Token Lifetime" dropdown: drop hard-coded TTL numbers; wording now reflects configurability and points to server configuration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- CONFIGURATION.md: add TOKEN_PROFILE_* env vars and JWT_EXPIRATION_MAX / REFRESH_TOKEN_EXPIRATION_MAX caps to the env snippet; add a Token Lifetime Profiles section covering preset defaults, hard caps, jitter semantics, client_credentials independence, and audit behavior on profile changes. - ARCHITECTURE.md: describe TokenProfile resolution under Refresh Token Architecture — when each preset fits, why standard returns zero TTLs to preserve jitter, and how client_credentials remains separately governed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Centralize empty→standard TokenProfile fallback in models.ResolveTokenProfile; used by normalizeTokenProfile, UpdateClient audit path, and ttlForClient resolver. - Log a WARNING when a client row carries an unknown token_profile value, and fall back to the standard profile's TTLs rather than base JWT config so a corrupted row can't quietly grant longer-than-intended tokens. - Thread the already-loaded client through ExchangeAuthorizationCode for parity with the device-flow path, removing a redundant cached lookup. - Build ErrInvalidTokenProfile from the models constants so the message can't drift from IsValidTokenProfile. - Seed the default "AuthGate CLI" client with TokenProfile=standard explicitly instead of relying on the GORM column default. - Audit previous_token_profile now records the normalized value so readers don't see "" alongside "standard". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The earlier refactor to centralize "" → standard fallback inadvertently dropped the trim-then-validate semantics that normalizeTokenProfile originally had, so inputs like " standard " from form posts or API clients would fail validation and fall back to the standard TTLs. Trim once inside ResolveTokenProfile so both validation (in normalizeTokenProfile) and resolution (in ttlForClient) see the canonical value. Add test coverage for the whitespace cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The previous ttlForClient zeroed out profile TTLs whenever they matched JWT_EXPIRATION or REFRESH_TOKEN_EXPIRATION, which incorrectly affected short/long profiles if an operator set them equal to the base config. Zeroing is a standard-only affordance for preserving jitter on the default path; explicit short/long profiles must now return their TTLs verbatim. Added TestResolveClientTTL_ShortProfileNeverZeroes to lock this in. Also captured a reference start time before token generation in the TTL-override tests so the assertions aren't flaky on slower CI runners. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
TokenProfilefield onOAuthApplicationwith three presets —short(15m/1d),standard(inheritsJWT_EXPIRATION/REFRESH_TOKEN_EXPIRATION, default), andlong(24h/90d) — selectable per client from the admin UI.client_credentialstokens are governed independently byCLIENT_CREDENTIALS_TOKEN_EXPIRATIONand intentionally ignoreTokenProfile; M2M tokens have a larger blast radius than user tokens and are kept short by default (1h). Per-client M2M TTLs would require a dedicated profile field.JWT_EXPIRATION_MAX(default 24h) andREFRESH_TOKEN_EXPIRATION_MAX(default 90d) are validated at startup; any profile that exceeds them blocks boot.WARNINGseverity in the audit log with aprevious_token_profilefield for diffability.Breaking change
The
core.TokenProviderinterface gained a trailing TTL parameter on its generation methods. External implementations need to update their signatures; the in-treeLocalTokenProvideris already updated. Passing0preserves the old behavior (use config default).GenerateToken(ctx, userID, clientID, scopes, ttl)GenerateRefreshToken(ctx, userID, clientID, scopes, ttl)GenerateClientCredentialsToken(ctx, userID, clientID, scopes, ttl)RefreshAccessToken(ctx, refreshToken, accessTTL, refreshTTL)Configuration
New env vars (all optional — sensible defaults applied):
Jitter semantics
JWT_EXPIRATION_JITTERapplies only when the resolved access-token TTL is0(i.e. thestandardprofile exactly mirrors the baseJWT_EXPIRATION). Explicitshort/longoverrides — and astandardprofile that has been diverged from base config — use their TTL verbatim with no jitter, so the admin's profile choice is honored precisely.Test plan
make lint— 0 issuesmake test— all packages pass, including new coverage ininternal/models/oauth_application_test.go,internal/config/config_test.go,internal/token/local_test.go, andinternal/services/token_profile_test.goTokenProfile=long, issue a client_credentials token, confirm it still expires perCLIENT_CREDENTIALS_TOKEN_EXPIRATION(default 1h) — profile is ignored for M2M🤖 Generated with Claude Code