Skip to content

feat(token): add per-client token lifetime profiles#168

Merged
appleboy merged 6 commits intomainfrom
worktree-token
Apr 21, 2026
Merged

feat(token): add per-client token lifetime profiles#168
appleboy merged 6 commits intomainfrom
worktree-token

Conversation

@appleboy
Copy link
Copy Markdown
Member

@appleboy appleboy commented Apr 19, 2026

Summary

  • Add a TokenProfile field on OAuthApplication with three presets — short (15m/1d), standard (inherits JWT_EXPIRATION / REFRESH_TOKEN_EXPIRATION, default), and long (24h/90d) — selectable per client from the admin UI.
  • Resolve the effective TTL at every user-delegated issuance (device code, authorization code, and refresh) so admin profile changes take effect on the next token issued rather than being pinned to whatever the client had when a refresh token was minted.
  • client_credentials tokens are governed independently by CLIENT_CREDENTIALS_TOKEN_EXPIRATION and intentionally ignore TokenProfile; 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.
  • Enforce guardrails: JWT_EXPIRATION_MAX (default 24h) and REFRESH_TOKEN_EXPIRATION_MAX (default 90d) are validated at startup; any profile that exceeds them blocks boot.
  • Unknown profile names on a client row (data corruption) are logged at WARNING and fall back to the standard profile's TTLs rather than silently granting base JWT lifetime.
  • Profile changes are logged at WARNING severity in the audit log with a previous_token_profile field for diffability.

Breaking change

The core.TokenProvider interface gained a trailing TTL parameter on its generation methods. External implementations need to update their signatures; the in-tree LocalTokenProvider is already updated. Passing 0 preserves 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):

JWT_EXPIRATION_MAX=24h
REFRESH_TOKEN_EXPIRATION_MAX=2160h
TOKEN_PROFILE_SHORT_ACCESS_TTL=15m
TOKEN_PROFILE_SHORT_REFRESH_TTL=24h
TOKEN_PROFILE_STANDARD_ACCESS_TTL=   # defaults to JWT_EXPIRATION
TOKEN_PROFILE_STANDARD_REFRESH_TTL=  # defaults to REFRESH_TOKEN_EXPIRATION
TOKEN_PROFILE_LONG_ACCESS_TTL=24h
TOKEN_PROFILE_LONG_REFRESH_TTL=2160h

Jitter semantics

JWT_EXPIRATION_JITTER applies only when the resolved access-token TTL is 0 (i.e. the standard profile exactly mirrors the base JWT_EXPIRATION). Explicit short / long overrides — and a standard profile 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 issues
  • make test — all packages pass, including new coverage in internal/models/oauth_application_test.go, internal/config/config_test.go, internal/token/local_test.go, and internal/services/token_profile_test.go
  • Create an OAuth client in the admin UI, set Token Lifetime = Short, complete the device code flow, confirm the issued access token expires in ~15 min
  • Edit the same client to Long, refresh the access token, confirm the new token uses the 24h TTL (profile change takes effect on next refresh)
  • Enable Client Credentials Flow on a confidential client with TokenProfile=long, issue a client_credentials token, confirm it still expires per CLIENT_CREDENTIALS_TOKEN_EXPIRATION (default 1h) — profile is ignored for M2M
  • Set a profile's TTL above the cap via env var; confirm the server fails to start with the expected error
  • Existing integration flows (GitHub/Gitea/Microsoft OAuth login, refresh-token rotation) remain green — covered by the service test suite

🤖 Generated with Claude Code

- 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>
Copilot AI review requested due to automatic review settings April 19, 2026 15:00
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in ClientService.
  • Extends core.TokenProvider generation/refresh methods with TTL override parameters and updates LocalTokenProvider + 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.

Comment thread internal/services/client.go Outdated
Comment thread internal/services/client.go Outdated
Comment thread internal/services/token.go Outdated
Comment thread internal/services/token_client_credentials.go Outdated
Comment thread internal/templates/admin_client_form.templ Outdated
- 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

appleboy and others added 2 commits April 20, 2026 22:32
- 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/models/oauth_application.go Outdated
Comment thread internal/services/client.go
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/services/token.go Outdated
Comment thread internal/token/local_test.go Outdated
Comment thread internal/token/local_test.go Outdated
Comment thread internal/services/token_client_credentials.go
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@appleboy appleboy merged commit 08ac32f into main Apr 21, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants