From 3e58894e40ea8c0b269ae9714badbe1f59f1b88f Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Sat, 16 May 2026 20:21:13 -0700 Subject: [PATCH] feat(jwt): per-audience access-token TTLs via TokenTtlPolicy SPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces JwtConfig.tokenTtl (single Duration) with a TokenTtlPolicy SPI keyed on the JWT aud claim, so multi-client deployments can issue different access-token lifetimes per audience (e.g. web=15m, cli=1h) without forking PkAuthJwtIssuer. The validator now accepts the union of {defaultAudience} ∪ ttlPolicy.knownAudiences(). See ADR 0014. JwtClaims gains an optional `audience` field; null falls back to JwtConfig.defaultAudience(). Validator returns the matched audience on the reconstructed claims so callers can read what the token was for. Adapter property records (Spring/Dropwizard/Micronaut) gain `defaultTtl` + `ttlsByAudience` and rename their single-TTL field. This is the first of three PRs landing on the 1.1.0 train; the next two add stateful access tokens (Feature 3) and rotating refresh tokens with family-based replay defense (Feature 1). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 52 ++++++ docs/adr/0014-per-audience-ttl-policy.md | 115 +++++++++++++ gradle.properties | 2 +- .../dropwizard/config/PkAuthConfig.java | 30 +++- .../dropwizard/dagger/PkAuthModule.java | 19 ++- .../codeheadsystems/pkauth/jwt/JwtClaims.java | 57 +++++-- .../codeheadsystems/pkauth/jwt/JwtConfig.java | 55 +++++-- .../pkauth/jwt/PkAuthJwtIssuer.java | 11 +- .../pkauth/jwt/PkAuthJwtValidator.java | 22 ++- .../pkauth/jwt/TokenTtlPolicy.java | 105 ++++++++++++ .../pkauth/jwt/PkAuthJwtTest.java | 153 ++++++++++++++++-- .../pkauth/micronaut/PkAuthConfiguration.java | 25 +++ .../pkauth/micronaut/PkAuthFactory.java | 13 +- .../PkAuthAutoConfiguration.java | 11 +- .../spring/config/PkAuthProperties.java | 19 ++- .../pkauth/spring/PkAuthPropertiesTest.java | 13 +- 16 files changed, 638 insertions(+), 64 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/adr/0014-per-audience-ttl-policy.md create mode 100644 pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicy.java diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..35f4c45 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to pk-auth are recorded here. The format loosely follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The 0.x line is treated as a single pre-stable development series — see +`docs/stability.md` and the early ADRs for context. This changelog starts at the +1.0.0 stabilisation cut; for 0.x history consult `git log` against the relevant +tags. + +## [Unreleased] + +### Added + +- `TokenTtlPolicy` SPI in `pk-auth-jwt` for per-audience JWT access-token TTL + dispatch. Multi-client deployments (e.g. web vs cli vs mobile) can now + configure different token lifetimes per audience without writing a custom + issuer. Static factories `TokenTtlPolicy.fixed(default, overrides)` and + `TokenTtlPolicy.single(ttl)` cover the common cases; implementations can also + declare their `knownAudiences()` so the validator accepts the expanded + audience set automatically. See ADR 0014. +- `JwtClaims` gains an optional `audience` field. When the issuer is called + with a claims object whose `audience` is null, the JWT's `aud` claim falls + back to `JwtConfig.defaultAudience()`. New convenience factory + `JwtClaims.forPasskey(userHandle, credentialId, audience, amr)` etc. mirror + the existing audience-less factories. + +### Changed + +- **Breaking.** `JwtConfig.tokenTtl: Duration` replaced by + `JwtConfig.ttlPolicy: TokenTtlPolicy`. Pre-1.1 code that constructed + `JwtConfig` directly must wrap the existing TTL in `TokenTtlPolicy.single(ttl)`. + The `JwtConfig.defaults(issuer, audience)` factory still works as before and + produces a single-TTL policy. +- **Breaking.** `JwtConfig.audience` renamed to `JwtConfig.defaultAudience`. + The accessor moves from `config.audience()` to `config.defaultAudience()`. + Semantic note: the validator now accepts any audience listed in + `defaultAudience ∪ ttlPolicy.knownAudiences()`, which matters when running + multiple client audiences through a single validator. +- **Breaking (adapters).** `PkAuthProperties.Jwt` (Spring), + `PkAuthConfig.Jwt` (Dropwizard), and `PkAuthConfiguration.Jwt` (Micronaut) + each gain a `ttlsByAudience: Map` field and rename their + single-TTL field; see each adapter's javadoc for the bound property name. + +## [1.0.0] — 2026-05 (stabilisation cut) + +First stable release. Captures the surface produced by the 0.x development +series; see `git log` for the full history. + +[Unreleased]: https://github.com/codeheadsystems/pk-auth/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/codeheadsystems/pk-auth/releases/tag/v1.0.0 diff --git a/docs/adr/0014-per-audience-ttl-policy.md b/docs/adr/0014-per-audience-ttl-policy.md new file mode 100644 index 0000000..d5faeb6 --- /dev/null +++ b/docs/adr/0014-per-audience-ttl-policy.md @@ -0,0 +1,115 @@ +# 14. Per-audience JWT TTL via a TokenTtlPolicy SPI + +Date: 2026-05-16 + +## Status + +Accepted. + +## Context + +Through 1.0, `JwtConfig.tokenTtl` was a single `Duration` — every JWT issued by +`PkAuthJwtIssuer` had the same lifetime. Real multi-client deployments rarely +fit this shape. The motif consumer +(`/home/wolpert/projects/motif`) discovered the gap first: web sessions want +15-minute access tokens, CLI sessions want hour-plus tokens (one re-auth per +work session, not per coffee break), and mobile sits in between. Hosts forced +to share a single TTL either give the web client a too-long token (security +loss) or give the CLI client a too-short one (UX loss). + +Motif's `ConfigRefreshTokenPolicy` already maps a `session_kind` enum +(`web`/`ios`/`android`/`cli`) to per-kind TTLs and is consumed at issue time. +We could either: + +1. Add a `Map ttlByAudience` field on `JwtConfig`. +2. Introduce a `TokenTtlPolicy` SPI with `accessTtl(audience) -> Duration` and + let hosts implement it however they want. + +The map-on-config option is simpler but locks the dispatch logic into +configuration: a host that wants tier-based TTLs ("admin sessions get 5 +minutes regardless of audience") or business-hours-aware TTLs has to either +override `PkAuthJwtIssuer` or push that logic up into their own issuer. + +The SPI keeps that knob open at the cost of introducing one more public type. +For a library whose whole job is composability, the SPI wins. Adapters bind a +default static-map implementation via factory methods (`TokenTtlPolicy.fixed(...)`, +`TokenTtlPolicy.single(...)`) so the 90% case stays one line of configuration. + +A second question is which identifier the policy is keyed on. JWT's `aud` +claim is the closest existing concept — it already identifies "who is this +token for." Motif's `session_kind` is functionally equivalent but pk-auth +shouldn't invent a new label when the standard claim covers the same axis. +Hosts pick whatever audience strings make sense for their topology; the policy +doesn't enumerate them. This means `JwtConfig.audience` (singular) is no +longer a single accepted value — it's the *default* audience plus the set +declared by the policy's `knownAudiences()`. The validator accepts the union. + +Versioning: this is a breaking shape change to `JwtConfig` and +`PkAuthProperties.Jwt` / `PkAuthConfig.Jwt` / `PkAuthConfiguration.Jwt`. It +lands in 1.1.0. Per `docs/stability.md` and the pattern set by ADRs 0010 and +0011, the project favours clean breaks at minor boundaries over carrying +compatibility shims; 1.0 hosts upgrading to 1.1 swap `Duration tokenTtl` for +`TokenTtlPolicy ttlPolicy` and rename `audience` accessors to +`defaultAudience`. The adapter records keep a backward-compatible auxiliary +constructor so hosts that don't care about per-audience TTLs see only a +parameter rename, not a positional shift. + +## Decision + +Introduce a `TokenTtlPolicy` SPI in `pk-auth-jwt`: + +```java +public interface TokenTtlPolicy { + Duration accessTtl(String audience); + default Set knownAudiences() { return Set.of(); } + + static TokenTtlPolicy fixed(Duration defaultTtl, Map overrides); + static TokenTtlPolicy single(Duration ttl); +} +``` + +Replace `JwtConfig.tokenTtl: Duration` with `JwtConfig.ttlPolicy: TokenTtlPolicy`. +Rename `JwtConfig.audience` to `JwtConfig.defaultAudience` and add a derived +`allowedAudiences()` accessor returning `{defaultAudience} ∪ ttlPolicy.knownAudiences()`. + +`JwtClaims` gains an optional `audience` field. The issuer reads it; null +means "use `defaultAudience`." The validator returns the matched audience on +`JwtVerificationResult.Success.claims().audience()` so consumers can read what +the token was actually issued for. + +Spring Boot / Dropwizard / Micronaut config records each rename their +`tokenTtl` field to `defaultTtl` and add `ttlsByAudience: Map`. +Adapters build the policy via `TokenTtlPolicy.fixed(...)` when the map is +non-empty, or `TokenTtlPolicy.single(...)` otherwise. + +## Consequences + +- **Pro**: Hosts can serve different client kinds from one issuer without + forking `PkAuthJwtIssuer`. +- **Pro**: The SPI surface is two methods. Static factories cover the common + "fixed map" and "single TTL" cases; advanced hosts can implement their own + policy (admin-session TTLs, time-of-day TTLs, A/B-test TTLs) without + touching pk-auth. +- **Pro**: The validator now accepts the union of audiences known to the + policy, which is the natural shape for multi-client deployments. A single + validator bean serves web, mobile, and CLI without separate beans per + audience. +- **Con**: Breaking change at the canonical `JwtConfig` constructor. Hosts + upgrading from 1.0 to 1.1 must update direct constructor calls (the + `JwtConfig.defaults(...)` factory and the adapter properties are unchanged + for the single-TTL case). Called out in `CHANGELOG.md`. +- **Con**: `JwtClaims` records an extra (nullable) field. The legacy 5-arg + constructor is preserved for source compatibility with code that constructs + claims directly; the canonical 6-arg constructor is the new shape. +- **Con**: Hosts that implement `TokenTtlPolicy` themselves are responsible + for keeping `knownAudiences()` consistent with what they dispatch on. + Inconsistency manifests as `WrongAudience` rejections at validation time, + which is detectable but not statically prevented. + +## Open follow-ups + +- A future ADR will define a `RefreshTtlPolicy` parallel for the refresh-token + module (PR 3 in the 1.1 rollout). +- If a real consumer asks for it, expose the matched audience as a typed claim + rather than only on the validator's reconstructed `JwtClaims`. Not worth + the JWT-body bloat absent demand. diff --git a/gradle.properties b/gradle.properties index 2d8c05d..e3e2dc9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,4 @@ org.gradle.jvmargs=-Xmx2g \ # Project identity group=com.codeheadsystems -version=0.1.0-SNAPSHOT +version=1.1.0-SNAPSHOT diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java index 95ad118..2b6893e 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java @@ -2,7 +2,9 @@ package com.codeheadsystems.pkauth.dropwizard.config; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Objects; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -78,12 +80,21 @@ public record RelyingParty(String id, String name, Set origins) { * tokens at the end of a successful authentication ceremony. * * @param issuer iss claim value. - * @param audience aud claim value. + * @param audience the default {@code aud} claim used when {@link + * com.codeheadsystems.pkauth.jwt.JwtClaims#audience()} is unset at issue time. * @param secret Raw HMAC secret bytes; must be at least 32 bytes for HS256. - * @param tokenTtl How long an issued JWT remains valid; null defers to the {@link - * com.codeheadsystems.pkauth.jwt.JwtConfig} default of one hour. + * @param defaultTtl access-token TTL applied to audiences without an override; null defers to + * {@link com.codeheadsystems.pkauth.jwt.JwtConfig#DEFAULT_TOKEN_TTL}. + * @param ttlsByAudience per-audience access-token TTL overrides (e.g. {@code web: PT15M, cli: + * PT1H}). The keys here also extend the validator's accepted-audience set via {@link + * com.codeheadsystems.pkauth.jwt.TokenTtlPolicy#knownAudiences()}. */ - public record Jwt(String issuer, String audience, byte[] secret, @Nullable Duration tokenTtl) { + public record Jwt( + String issuer, + String audience, + byte[] secret, + @Nullable Duration defaultTtl, + @Nullable Map ttlsByAudience) { public Jwt { Objects.requireNonNull(issuer, "issuer"); Objects.requireNonNull(audience, "audience"); @@ -94,6 +105,17 @@ public record Jwt(String issuer, String audience, byte[] secret, @Nullable Durat } // Defensive copy to keep callers from mutating the byte[] under us. secret = secret.clone(); + if (ttlsByAudience != null) { + ttlsByAudience = Map.copyOf(new LinkedHashMap<>(ttlsByAudience)); + } + } + + /** + * Four-arg constructor for hosts that do not configure per-audience overrides. Equivalent to + * passing {@code null} for {@code ttlsByAudience}. + */ + public Jwt(String issuer, String audience, byte[] secret, @Nullable Duration defaultTtl) { + this(issuer, audience, secret, defaultTtl, null); } /** Returns a fresh defensive copy of the underlying secret. */ diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthModule.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthModule.java index de66746..6fda496 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthModule.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthModule.java @@ -14,6 +14,7 @@ import com.codeheadsystems.pkauth.jwt.JwtKeyset; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.jwt.TokenTtlPolicy; import com.codeheadsystems.pkauth.spi.CeremonyRateLimiter; import com.codeheadsystems.pkauth.spi.ChallengeStore; import com.codeheadsystems.pkauth.spi.ClockProvider; @@ -22,6 +23,8 @@ import dagger.Module; import dagger.Provides; import jakarta.inject.Singleton; +import java.time.Duration; +import java.util.Map; import java.util.Set; /** @@ -135,16 +138,18 @@ PasskeyAuthenticationService providePasskeyAuthenticationService( @Singleton JwtConfig provideJwtConfig(PkAuthConfig cfg) { PkAuthConfig.Jwt jwt = cfg.jwt(); - JwtConfig defaults = JwtConfig.defaults(jwt.issuer(), jwt.audience()); - if (jwt.tokenTtl() == null) { - return defaults; - } + Duration defaultTtl = jwt.defaultTtl() == null ? JwtConfig.DEFAULT_TOKEN_TTL : jwt.defaultTtl(); + Map overrides = jwt.ttlsByAudience(); + TokenTtlPolicy ttlPolicy = + overrides == null || overrides.isEmpty() + ? TokenTtlPolicy.single(defaultTtl) + : TokenTtlPolicy.fixed(defaultTtl, overrides); return new JwtConfig( jwt.issuer(), jwt.audience(), - jwt.tokenTtl(), - defaults.notBeforeSkew(), - defaults.clockSkew()); + ttlPolicy, + JwtConfig.DEFAULT_NBF_SKEW, + JwtConfig.DEFAULT_CLOCK_SKEW); } @Provides diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java index 1a21160..7f9c4eb 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java @@ -18,13 +18,16 @@ * @param credentialId WebAuthn credential id; required iff {@link AuthMethod#PASSKEY}, else null * @param amr RFC 8176-compatible authentication-method-reference values * @param additionalClaims optional extra claims merged into the JWT body + * @param audience the audience this token is for; when {@code null} the issuer uses {@link + * JwtConfig#defaultAudience()}. Drives per-audience TTL lookup via {@link TokenTtlPolicy}. */ public record JwtClaims( UserHandle userHandle, AuthMethod method, byte @Nullable [] credentialId, List amr, - @Nullable Map additionalClaims) { + @Nullable Map additionalClaims, + @Nullable String audience) { public JwtClaims { Objects.requireNonNull(userHandle, "userHandle"); @@ -36,6 +39,9 @@ public record JwtClaims( if (method != AuthMethod.PASSKEY && credentialId != null) { throw new IllegalArgumentException("credentialId must be null when method != PASSKEY"); } + if (audience != null && audience.isBlank()) { + throw new IllegalArgumentException("audience must be non-blank when present"); + } if (credentialId != null) { credentialId = credentialId.clone(); } @@ -45,19 +51,48 @@ public record JwtClaims( } } - /** Convenience factory for passkey-issued tokens. */ + /** + * Five-arg constructor preserved for callers that pre-date the per-audience TTL work; audience + * defaults to {@code null} (issuer uses {@link JwtConfig#defaultAudience()}). + */ + public JwtClaims( + UserHandle userHandle, + AuthMethod method, + byte @Nullable [] credentialId, + List amr, + @Nullable Map additionalClaims) { + this(userHandle, method, credentialId, amr, additionalClaims, null); + } + + /** Convenience factory for passkey-issued tokens (uses {@code defaultAudience} at issue time). */ public static JwtClaims forPasskey(UserHandle userHandle, byte[] credentialId, List amr) { - return new JwtClaims(userHandle, AuthMethod.PASSKEY, credentialId, amr, null); + return new JwtClaims(userHandle, AuthMethod.PASSKEY, credentialId, amr, null, null); + } + + /** Convenience factory for passkey-issued tokens scoped to a specific audience. */ + public static JwtClaims forPasskey( + UserHandle userHandle, byte[] credentialId, String audience, List amr) { + return new JwtClaims(userHandle, AuthMethod.PASSKEY, credentialId, amr, null, audience); } - /** Convenience factory for backup-code-issued tokens. */ + /** Convenience factory for backup-code-issued tokens (uses {@code defaultAudience}). */ public static JwtClaims forBackupCode(UserHandle userHandle, List amr) { - return new JwtClaims(userHandle, AuthMethod.BACKUP_CODE, null, amr, null); + return new JwtClaims(userHandle, AuthMethod.BACKUP_CODE, null, amr, null, null); } - /** Convenience factory for magic-link-issued tokens. */ + /** Convenience factory for backup-code-issued tokens scoped to a specific audience. */ + public static JwtClaims forBackupCode(UserHandle userHandle, String audience, List amr) { + return new JwtClaims(userHandle, AuthMethod.BACKUP_CODE, null, amr, null, audience); + } + + /** Convenience factory for magic-link-issued tokens (uses {@code defaultAudience}). */ public static JwtClaims forMagicLink(UserHandle userHandle, List amr) { - return new JwtClaims(userHandle, AuthMethod.MAGIC_LINK, null, amr, null); + return new JwtClaims(userHandle, AuthMethod.MAGIC_LINK, null, amr, null, null); + } + + /** Convenience factory for magic-link-issued tokens scoped to a specific audience. */ + public static JwtClaims forMagicLink(UserHandle userHandle, String audience, List amr) { + return new JwtClaims(userHandle, AuthMethod.MAGIC_LINK, null, amr, null, audience); } @Override @@ -72,12 +107,14 @@ public boolean equals(Object o) { && method == other.method && Arrays.equals(credentialId, other.credentialId) && amr.equals(other.amr) - && Objects.equals(additionalClaims, other.additionalClaims); + && Objects.equals(additionalClaims, other.additionalClaims) + && Objects.equals(audience, other.audience); } @Override public int hashCode() { - return Objects.hash(userHandle, method, Arrays.hashCode(credentialId), amr, additionalClaims); + return Objects.hash( + userHandle, method, Arrays.hashCode(credentialId), amr, additionalClaims, audience); } @Override @@ -90,6 +127,8 @@ public String toString() { + (credentialId == null ? "null" : HexFormat.of().formatHex(credentialId)) + ", amr=" + amr + + ", audience=" + + audience + "]"; } } diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtConfig.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtConfig.java index 3b30472..4720ed4 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtConfig.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtConfig.java @@ -2,20 +2,32 @@ package com.codeheadsystems.pkauth.jwt; import java.time.Duration; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; /** * Configuration for pk-auth's JWT issuance and validation. * + *

The {@code defaultAudience} is the audience the issuer uses when {@link JwtClaims#audience()} + * is null on a call to {@link PkAuthJwtIssuer#issue(JwtClaims)}, and is always accepted by the + * validator. Additional accepted audiences come from {@link TokenTtlPolicy#knownAudiences()} — see + * {@link #allowedAudiences()} for the resolved set. + * * @param issuer the {@code iss} claim value - * @param audience the {@code aud} claim value - * @param tokenTtl how long an issued token is valid + * @param defaultAudience the audience used when {@link JwtClaims#audience()} is absent at issue + * time; always part of {@link #allowedAudiences()} + * @param ttlPolicy per-audience access-token TTL dispatch * @param notBeforeSkew the {@code nbf} claim is set to {@code iat - notBeforeSkew} (small negative * skew helps clients with slightly fast clocks accept tokens immediately) * @param clockSkew leniency window applied to {@code exp} and {@code nbf} during validation */ public record JwtConfig( - String issuer, String audience, Duration tokenTtl, Duration notBeforeSkew, Duration clockSkew) { + String issuer, + String defaultAudience, + TokenTtlPolicy ttlPolicy, + Duration notBeforeSkew, + Duration clockSkew) { /** Default token TTL: 1 hour. */ public static final Duration DEFAULT_TOKEN_TTL = Duration.ofHours(1); @@ -31,14 +43,11 @@ public record JwtConfig( if (issuer.isBlank()) { throw new IllegalArgumentException("issuer must be non-blank"); } - Objects.requireNonNull(audience, "audience"); - if (audience.isBlank()) { - throw new IllegalArgumentException("audience must be non-blank"); - } - Objects.requireNonNull(tokenTtl, "tokenTtl"); - if (tokenTtl.isZero() || tokenTtl.isNegative()) { - throw new IllegalArgumentException("tokenTtl must be positive"); + Objects.requireNonNull(defaultAudience, "defaultAudience"); + if (defaultAudience.isBlank()) { + throw new IllegalArgumentException("defaultAudience must be non-blank"); } + Objects.requireNonNull(ttlPolicy, "ttlPolicy"); Objects.requireNonNull(notBeforeSkew, "notBeforeSkew"); if (notBeforeSkew.isNegative()) { throw new IllegalArgumentException("notBeforeSkew must be non-negative"); @@ -49,8 +58,30 @@ public record JwtConfig( } } - /** Convenience constructor with the documented defaults. */ + /** + * Returns the audiences the validator accepts for an issued token: the {@link #defaultAudience()} + * union {@link TokenTtlPolicy#knownAudiences()}. Always contains {@link #defaultAudience()}. + */ + public Set allowedAudiences() { + Set known = ttlPolicy.knownAudiences(); + if (known.isEmpty()) { + return Set.of(defaultAudience); + } + Set all = new HashSet<>(known); + all.add(defaultAudience); + return Set.copyOf(all); + } + + /** + * Convenience constructor with the documented defaults — a single-TTL policy and the standard + * skew values. + */ public static JwtConfig defaults(String issuer, String audience) { - return new JwtConfig(issuer, audience, DEFAULT_TOKEN_TTL, DEFAULT_NBF_SKEW, DEFAULT_CLOCK_SKEW); + return new JwtConfig( + issuer, + audience, + TokenTtlPolicy.single(DEFAULT_TOKEN_TTL), + DEFAULT_NBF_SKEW, + DEFAULT_CLOCK_SKEW); } } diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java index 157d6ae..99e11cf 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java @@ -41,18 +41,23 @@ public PkAuthJwtIssuer(JwtConfig config, JwtKeyset keyset, ClockProvider clockPr this.clockProvider = Objects.requireNonNull(clockProvider, "clockProvider"); } - /** Issues a signed JWT for the supplied claims. */ + /** + * Issues a signed JWT for the supplied claims. The {@code aud} claim is taken from {@link + * JwtClaims#audience()} when set, falling back to {@link JwtConfig#defaultAudience()}; the + * access-token TTL is then looked up via {@link JwtConfig#ttlPolicy()} keyed by that audience. + */ public String issue(JwtClaims claims) { Objects.requireNonNull(claims, "claims"); Instant now = clockProvider.now(); + String audience = claims.audience() != null ? claims.audience() : config.defaultAudience(); JWTClaimsSet.Builder body = new JWTClaimsSet.Builder() .issuer(config.issuer()) - .audience(config.audience()) + .audience(audience) .subject(Base64Url.encode(claims.userHandle().value())) .issueTime(Date.from(now)) .notBeforeTime(Date.from(now.minus(config.notBeforeSkew()))) - .expirationTime(Date.from(now.plus(config.tokenTtl()))) + .expirationTime(Date.from(now.plus(config.ttlPolicy().accessTtl(audience)))) .jwtID(UUID.randomUUID().toString()) .claim(CLAIM_METHOD, claims.method().wireValue()) .claim(CLAIM_AMR, List.copyOf(claims.amr())); diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java index 08c6cea..d116729 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.jspecify.annotations.Nullable; /** @@ -100,9 +101,19 @@ public JwtVerificationResult validate(String token) { } List aud = body.getAudience(); - if (aud == null || !aud.contains(config.audience())) { + Set allowed = config.allowedAudiences(); + String matchedAudience = null; + if (aud != null) { + for (String a : aud) { + if (allowed.contains(a)) { + matchedAudience = a; + break; + } + } + } + if (matchedAudience == null) { return new JwtVerificationResult.WrongAudience( - config.audience(), aud == null ? "" : String.join(",", aud)); + String.join(",", allowed), aud == null ? "" : String.join(",", aud)); } Instant now = clockProvider.now(); @@ -170,7 +181,12 @@ public JwtVerificationResult validate(String token) { Map remaining = removeKnownClaims(body.toJSONObject()); return new JwtVerificationResult.Success( new JwtClaims( - userHandle, method, credentialId, amr, remaining.isEmpty() ? null : remaining)); + userHandle, + method, + credentialId, + amr, + remaining.isEmpty() ? null : remaining, + matchedAudience)); } /** diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicy.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicy.java new file mode 100644 index 0000000..ca1ef7b --- /dev/null +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicy.java @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Per-audience TTL lookup used by {@link PkAuthJwtIssuer} when minting access tokens. Hosts that + * issue tokens for distinct client audiences (e.g. {@code web}, {@code ios}, {@code cli}) typically + * want different access-token lifetimes per audience; the policy is the dispatch seam. + * + *

Implementations are expected to be cheap and side-effect-free: {@link #accessTtl(String)} is + * called once per {@link PkAuthJwtIssuer#issue(JwtClaims)} invocation. + * + *

{@link #knownAudiences()} declares which audiences this policy is configured for. The + * validator (and adapters that want to enumerate accepted audiences for documentation or OpenAPI + * surfaces) reads this set. Returning an empty set is the conservative default — the validator + * falls back to accepting only {@link JwtConfig#defaultAudience()} in that case. The built-in + * {@link #fixed(Duration, Map)} factory wires this automatically from the override map's keys. + */ +public interface TokenTtlPolicy { + + /** + * Returns the access-token TTL for the given audience. Must always return a positive duration. + */ + Duration accessTtl(String audience); + + /** + * Audiences this policy explicitly knows about. The default returns an empty set; implementations + * with a finite, enumerable audience set (e.g. {@link #fixed(Duration, Map)}) override this so + * the validator can accept tokens for any of them. Returning empty means "validator accepts only + * the issuer's default audience". + */ + default Set knownAudiences() { + return Set.of(); + } + + /** + * Returns a policy that dispatches to {@code overrides} by audience, falling back to {@code + * defaultTtl} for any audience not present in the map. + * + *

{@link #knownAudiences()} on the returned policy is the union of {@code overrides.keySet()}. + * The default TTL audience is not implied — a token issued for an audience that is neither in the + * override map nor equal to {@link JwtConfig#defaultAudience()} will use {@code defaultTtl} but + * will not be accepted by the validator unless the host declares the audience another way. + */ + static TokenTtlPolicy fixed(Duration defaultTtl, Map overrides) { + Objects.requireNonNull(defaultTtl, "defaultTtl"); + Objects.requireNonNull(overrides, "overrides"); + if (defaultTtl.isZero() || defaultTtl.isNegative()) { + throw new IllegalArgumentException("defaultTtl must be positive"); + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry e : overrides.entrySet()) { + Objects.requireNonNull(e.getKey(), "override audience"); + Objects.requireNonNull(e.getValue(), "override ttl for " + e.getKey()); + if (e.getValue().isZero() || e.getValue().isNegative()) { + throw new IllegalArgumentException( + "ttl for audience '" + e.getKey() + "' must be positive"); + } + copy.put(e.getKey(), e.getValue()); + } + Map frozen = Map.copyOf(copy); + Set audiences = Set.copyOf(frozen.keySet()); + return new TokenTtlPolicy() { + @Override + public Duration accessTtl(String audience) { + Duration explicit = frozen.get(audience); + return explicit != null ? explicit : defaultTtl; + } + + @Override + public Set knownAudiences() { + return audiences; + } + + @Override + public String toString() { + return "TokenTtlPolicy.fixed(default=" + defaultTtl + ", overrides=" + frozen + ")"; + } + }; + } + + /** Returns a policy that uses the same TTL for every audience. */ + static TokenTtlPolicy single(Duration ttl) { + Objects.requireNonNull(ttl, "ttl"); + if (ttl.isZero() || ttl.isNegative()) { + throw new IllegalArgumentException("ttl must be positive"); + } + return new TokenTtlPolicy() { + @Override + public Duration accessTtl(String audience) { + return ttl; + } + + @Override + public String toString() { + return "TokenTtlPolicy.single(" + ttl + ")"; + } + }; + } +} diff --git a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtTest.java b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtTest.java index 11a1b94..e966f16 100644 --- a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtTest.java +++ b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtTest.java @@ -22,6 +22,7 @@ import java.time.ZoneOffset; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -88,7 +89,11 @@ void expiredTokenIsRejected() { JwtKeyset keyset = JwtKeyset.hs256(randomBytes(32)); JwtConfig config = new JwtConfig( - ISSUER, AUDIENCE, Duration.ofMinutes(1), Duration.ZERO, Duration.ofSeconds(5)); + ISSUER, + AUDIENCE, + TokenTtlPolicy.single(Duration.ofMinutes(1)), + Duration.ZERO, + Duration.ofSeconds(5)); PkAuthJwtIssuer issuer = new PkAuthJwtIssuer(config, keyset, fixedClock(NOW)); String token = issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {1}), List.of("user"))); @@ -113,7 +118,11 @@ void notYetValidIsRejected() { JwtConfig validationConfig = new JwtConfig( - ISSUER, AUDIENCE, Duration.ofMinutes(5), Duration.ZERO, Duration.ofSeconds(5)); + ISSUER, + AUDIENCE, + TokenTtlPolicy.single(Duration.ofMinutes(5)), + Duration.ZERO, + Duration.ofSeconds(5)); PkAuthJwtValidator validator = new PkAuthJwtValidator(validationConfig, keyset, fixedClock(NOW)); assertThat(validator.validate(token)).isInstanceOf(JwtVerificationResult.NotYetValid.class); @@ -211,26 +220,140 @@ void authMethodWireValueMapping() { @Test void jwtConfigValidations() { - assertThatThrownBy( - () -> new JwtConfig("", "a", Duration.ofMinutes(1), Duration.ZERO, Duration.ZERO)) + TokenTtlPolicy ok = TokenTtlPolicy.single(Duration.ofMinutes(1)); + assertThatThrownBy(() -> new JwtConfig("", "a", ok, Duration.ZERO, Duration.ZERO)) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy( - () -> new JwtConfig("i", "", Duration.ofMinutes(1), Duration.ZERO, Duration.ZERO)) + assertThatThrownBy(() -> new JwtConfig("i", "", ok, Duration.ZERO, Duration.ZERO)) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> new JwtConfig("i", "a", Duration.ZERO, Duration.ZERO, Duration.ZERO)) + assertThatThrownBy(() -> TokenTtlPolicy.single(Duration.ZERO)) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy( - () -> - new JwtConfig( - "i", "a", Duration.ofMinutes(1), Duration.ofSeconds(-1), Duration.ZERO)) + assertThatThrownBy(() -> new JwtConfig("i", "a", ok, Duration.ofSeconds(-1), Duration.ZERO)) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy( - () -> - new JwtConfig( - "i", "a", Duration.ofMinutes(1), Duration.ZERO, Duration.ofSeconds(-1))) + assertThatThrownBy(() -> new JwtConfig("i", "a", ok, Duration.ZERO, Duration.ofSeconds(-1))) .isInstanceOf(IllegalArgumentException.class); } + @Test + void perAudienceTtlPolicyDispatches() { + JwtKeyset keyset = JwtKeyset.hs256(randomBytes(32)); + TokenTtlPolicy policy = + TokenTtlPolicy.fixed( + Duration.ofHours(1), + Map.of( + "web", Duration.ofMinutes(15), + "cli", Duration.ofHours(8))); + JwtConfig config = + new JwtConfig( + ISSUER, AUDIENCE, policy, JwtConfig.DEFAULT_NBF_SKEW, JwtConfig.DEFAULT_CLOCK_SKEW); + PkAuthJwtIssuer issuer = new PkAuthJwtIssuer(config, keyset, fixedClock(NOW)); + + String webToken = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {1}), "web", List.of("u"))); + String cliToken = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {1}), "cli", List.of("u"))); + + Instant webExp = parsedExp(webToken); + Instant cliExp = parsedExp(cliToken); + + // Web TTL is 15m, CLI TTL is 8h — verify durations differ by ~7h45m. + assertThat(Duration.between(webExp, cliExp)) + .isCloseTo(Duration.ofMinutes(465), Duration.ofSeconds(2)); + assertThat(Duration.between(NOW, webExp)) + .isCloseTo(Duration.ofMinutes(15), Duration.ofSeconds(2)); + assertThat(Duration.between(NOW, cliExp)).isCloseTo(Duration.ofHours(8), Duration.ofSeconds(2)); + } + + @Test + void audienceAbsentFromClaimsUsesDefaultAudienceAndDefaultTtl() { + JwtKeyset keyset = JwtKeyset.hs256(randomBytes(32)); + TokenTtlPolicy policy = + TokenTtlPolicy.fixed(Duration.ofMinutes(30), Map.of("web", Duration.ofMinutes(5))); + JwtConfig config = + new JwtConfig( + ISSUER, AUDIENCE, policy, JwtConfig.DEFAULT_NBF_SKEW, JwtConfig.DEFAULT_CLOCK_SKEW); + PkAuthJwtIssuer issuer = new PkAuthJwtIssuer(config, keyset, fixedClock(NOW)); + PkAuthJwtValidator validator = new PkAuthJwtValidator(config, keyset, fixedClock(NOW)); + + // No audience on the claims → uses defaultAudience and the default TTL (30m). + String token = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {1}), List.of("u"))); + assertThat(Duration.between(NOW, parsedExp(token))) + .isCloseTo(Duration.ofMinutes(30), Duration.ofSeconds(2)); + + JwtVerificationResult result = validator.validate(token); + assertThat(result).isInstanceOf(JwtVerificationResult.Success.class); + assertThat(((JwtVerificationResult.Success) result).claims().audience()).isEqualTo(AUDIENCE); + } + + @Test + void validatorAcceptsAudiencesDeclaredByPolicy() { + JwtKeyset keyset = JwtKeyset.hs256(randomBytes(32)); + TokenTtlPolicy policy = + TokenTtlPolicy.fixed( + Duration.ofMinutes(30), + Map.of("web", Duration.ofMinutes(15), "cli", Duration.ofHours(1))); + JwtConfig config = + new JwtConfig( + ISSUER, AUDIENCE, policy, JwtConfig.DEFAULT_NBF_SKEW, JwtConfig.DEFAULT_CLOCK_SKEW); + PkAuthJwtIssuer issuer = new PkAuthJwtIssuer(config, keyset, fixedClock(NOW)); + PkAuthJwtValidator validator = new PkAuthJwtValidator(config, keyset, fixedClock(NOW)); + + String cliToken = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {1}), "cli", List.of("u"))); + JwtVerificationResult result = validator.validate(cliToken); + assertThat(result).isInstanceOf(JwtVerificationResult.Success.class); + assertThat(((JwtVerificationResult.Success) result).claims().audience()).isEqualTo("cli"); + } + + @Test + void validatorRejectsUnknownAudienceEvenIfIssuedBySameKey() throws Exception { + JwtKeyset keyset = JwtKeyset.hs256(randomBytes(32)); + // Issuer-side knows about "rogue"; validator-side does not. + TokenTtlPolicy issuerPolicy = + TokenTtlPolicy.fixed(Duration.ofMinutes(30), Map.of("rogue", Duration.ofMinutes(5))); + JwtConfig issuerConfig = + new JwtConfig( + ISSUER, + AUDIENCE, + issuerPolicy, + JwtConfig.DEFAULT_NBF_SKEW, + JwtConfig.DEFAULT_CLOCK_SKEW); + JwtConfig validatorConfig = JwtConfig.defaults(ISSUER, AUDIENCE); + + PkAuthJwtIssuer issuer = new PkAuthJwtIssuer(issuerConfig, keyset, fixedClock(NOW)); + PkAuthJwtValidator validator = new PkAuthJwtValidator(validatorConfig, keyset, fixedClock(NOW)); + + String token = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {1}), "rogue", List.of("u"))); + assertThat(validator.validate(token)).isInstanceOf(JwtVerificationResult.WrongAudience.class); + } + + @Test + void allowedAudiencesIncludesDefaultPlusPolicyKnownAudiences() { + TokenTtlPolicy policy = + TokenTtlPolicy.fixed( + Duration.ofMinutes(30), + Map.of("web", Duration.ofMinutes(15), "cli", Duration.ofHours(1))); + JwtConfig config = + new JwtConfig( + ISSUER, AUDIENCE, policy, JwtConfig.DEFAULT_NBF_SKEW, JwtConfig.DEFAULT_CLOCK_SKEW); + assertThat(config.allowedAudiences()).containsExactlyInAnyOrder(AUDIENCE, "web", "cli"); + } + + @Test + void allowedAudiencesOnSinglePolicyIsJustDefault() { + JwtConfig config = JwtConfig.defaults(ISSUER, AUDIENCE); + assertThat(config.allowedAudiences()).containsExactly(AUDIENCE); + } + + private static Instant parsedExp(String token) { + try { + return SignedJWT.parse(token).getJWTClaimsSet().getExpirationTime().toInstant(); + } catch (java.text.ParseException e) { + throw new RuntimeException(e); + } + } + @Test void keysetSignerAndAlgorithm() { JwtKeyset hs = JwtKeyset.hs256(randomBytes(32)); diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java index 4bc1780..9e44ae7 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java @@ -4,6 +4,7 @@ import io.micronaut.context.annotation.ConfigurationProperties; import java.time.Duration; import java.util.List; +import java.util.Map; import org.jspecify.annotations.Nullable; /** @@ -134,12 +135,20 @@ public void setOrigins(@Nullable List origins) { /** * JWT issuance and validation config. {@code issuer}, {@code audience}, and {@code secret} are * all required — there are no adapter defaults. {@link PkAuthFactory} validates them at startup. + * + *

{@code defaultTtl} sets the access-token TTL applied to audiences not listed in {@code + * ttlsByAudience}; null defers to {@link + * com.codeheadsystems.pkauth.jwt.JwtConfig#DEFAULT_TOKEN_TTL}. Keys present in {@code + * ttlsByAudience} are recognised by the validator via {@link + * com.codeheadsystems.pkauth.jwt.TokenTtlPolicy#knownAudiences()}. */ @ConfigurationProperties("jwt") public static final class Jwt { private @Nullable String issuer; private @Nullable String audience; private @Nullable String secret; + private @Nullable Duration defaultTtl; + private @Nullable Map ttlsByAudience; public @Nullable String getIssuer() { return issuer; @@ -164,6 +173,22 @@ public void setAudience(@Nullable String audience) { public void setSecret(@Nullable String secret) { this.secret = secret; } + + public @Nullable Duration getDefaultTtl() { + return defaultTtl; + } + + public void setDefaultTtl(@Nullable Duration defaultTtl) { + this.defaultTtl = defaultTtl; + } + + public @Nullable Map getTtlsByAudience() { + return ttlsByAudience; + } + + public void setTtlsByAudience(@Nullable Map ttlsByAudience) { + this.ttlsByAudience = ttlsByAudience; + } } /** diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactory.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactory.java index fa3d123..c9fba97 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactory.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactory.java @@ -13,6 +13,7 @@ import com.codeheadsystems.pkauth.jwt.JwtSecretResolver; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.jwt.TokenTtlPolicy; import com.codeheadsystems.pkauth.magiclink.EmailSender; import com.codeheadsystems.pkauth.magiclink.LoggingEmailSender; import com.codeheadsystems.pkauth.magiclink.MagicLinkService; @@ -30,7 +31,9 @@ import io.micronaut.context.annotation.Factory; import io.micronaut.context.annotation.Requires; import jakarta.inject.Singleton; +import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -91,7 +94,15 @@ JwtConfig jwtConfig(PkAuthConfiguration config) { "pkauth.jwt.{issuer,audience} are required. Set them explicitly in configuration —" + " there are no defaults."); } - return JwtConfig.defaults(issuer, audience); + Duration defaultTtl = + jwt.getDefaultTtl() == null ? JwtConfig.DEFAULT_TOKEN_TTL : jwt.getDefaultTtl(); + Map overrides = jwt.getTtlsByAudience(); + TokenTtlPolicy ttlPolicy = + overrides == null || overrides.isEmpty() + ? TokenTtlPolicy.single(defaultTtl) + : TokenTtlPolicy.fixed(defaultTtl, overrides); + return new JwtConfig( + issuer, audience, ttlPolicy, JwtConfig.DEFAULT_NBF_SKEW, JwtConfig.DEFAULT_CLOCK_SKEW); } @Singleton diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfiguration.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfiguration.java index f709123..91a9e28 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfiguration.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfiguration.java @@ -16,6 +16,7 @@ import com.codeheadsystems.pkauth.jwt.JwtSecretResolver; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.jwt.TokenTtlPolicy; import com.codeheadsystems.pkauth.magiclink.EmailSender; import com.codeheadsystems.pkauth.magiclink.LoggingEmailSender; import com.codeheadsystems.pkauth.magiclink.MagicLinkService; @@ -37,6 +38,7 @@ import com.codeheadsystems.pkauth.testkit.InMemoryOtpRepository; import com.codeheadsystems.pkauth.testkit.InMemoryUserLookup; import java.time.Duration; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -198,11 +200,16 @@ public PasskeyAuthenticationService pkAuthService( @ConditionalOnMissingBean public JwtConfig pkAuthJwtConfig(PkAuthProperties props) { PkAuthProperties.Jwt jwt = requireJwt(props); - Duration ttl = jwt.tokenTtl(); + Duration defaultTtl = jwt.defaultTtl() == null ? JwtConfig.DEFAULT_TOKEN_TTL : jwt.defaultTtl(); + Map overrides = jwt.ttlsByAudience(); + TokenTtlPolicy ttlPolicy = + overrides == null || overrides.isEmpty() + ? TokenTtlPolicy.single(defaultTtl) + : TokenTtlPolicy.fixed(defaultTtl, overrides); return new JwtConfig( jwt.issuer(), jwt.audience(), - ttl == null ? JwtConfig.DEFAULT_TOKEN_TTL : ttl, + ttlPolicy, JwtConfig.DEFAULT_NBF_SKEW, JwtConfig.DEFAULT_CLOCK_SKEW); } diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java index b9331a5..ec941b2 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java @@ -2,6 +2,7 @@ package com.codeheadsystems.pkauth.spring.config; import java.time.Duration; +import java.util.Map; import java.util.Set; import org.jspecify.annotations.Nullable; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -58,12 +59,22 @@ public record RelyingParty(String id, String name, Set origins) {} * adapters needing ES256 wire a {@code JwtKeyset} bean explicitly. * * @param issuer the {@code iss} claim (required) - * @param audience the {@code aud} claim (required) + * @param audience the default {@code aud} claim, used when {@link + * com.codeheadsystems.pkauth.jwt.JwtClaims#audience()} is unset at issue time (required) * @param secret HS256 shared secret; must be ≥ 32 bytes when UTF-8 encoded (required) - * @param tokenTtl how long an issued token is valid; null falls back to {@link - * com.codeheadsystems.pkauth.jwt.JwtConfig#DEFAULT_TOKEN_TTL} + * @param defaultTtl access-token TTL applied to audiences not present in {@code ttlsByAudience}; + * null falls back to {@link com.codeheadsystems.pkauth.jwt.JwtConfig#DEFAULT_TOKEN_TTL} + * @param ttlsByAudience per-audience access-token TTL overrides (e.g. {@code web=PT15M, + * cli=PT1H}). Empty/null means every audience uses {@code defaultTtl}. Keys here also extend + * the validator's accepted-audience set via {@link + * com.codeheadsystems.pkauth.jwt.TokenTtlPolicy#knownAudiences()}. */ - public record Jwt(String issuer, String audience, String secret, @Nullable Duration tokenTtl) {} + public record Jwt( + String issuer, + String audience, + String secret, + @Nullable Duration defaultTtl, + @Nullable Map ttlsByAudience) {} /** * Ceremony tunables forwarded to {@code CeremonyConfig}. diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthPropertiesTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthPropertiesTest.java index e3e3346..bfcdd8e 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthPropertiesTest.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthPropertiesTest.java @@ -5,6 +5,7 @@ import com.codeheadsystems.pkauth.spring.config.PkAuthProperties; import java.time.Duration; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; @@ -16,7 +17,7 @@ void optionalBlocksFallToDefaultsWhenAbsent() { new PkAuthProperties( new PkAuthProperties.RelyingParty( "example.com", "Example", Set.of("https://example.com")), - new PkAuthProperties.Jwt("iss", "aud", "secret-that-is-long-enough-32b!!", null), + new PkAuthProperties.Jwt("iss", "aud", "secret-that-is-long-enough-32b!!", null, null), null, null, false); @@ -29,17 +30,23 @@ void optionalBlocksFallToDefaultsWhenAbsent() { @Test void preservesExplicitValues() { + Map perAudience = Map.of("web", Duration.ofMinutes(15)); PkAuthProperties props = new PkAuthProperties( new PkAuthProperties.RelyingParty( "example.com", "Example", Set.of("https://example.com")), new PkAuthProperties.Jwt( - "iss", "aud", "secret-that-is-long-enough-32b!!", Duration.ofMinutes(30)), + "iss", + "aud", + "secret-that-is-long-enough-32b!!", + Duration.ofMinutes(30), + perAudience), new PkAuthProperties.Ceremony(Duration.ofMinutes(2)), new PkAuthProperties.Otp("dGVzdC1wZXBwZXItYmFzZTY0LWVuY29kZWQ="), true); assertThat(props.relyingParty().id()).isEqualTo("example.com"); - assertThat(props.jwt().tokenTtl()).isEqualTo(Duration.ofMinutes(30)); + assertThat(props.jwt().defaultTtl()).isEqualTo(Duration.ofMinutes(30)); + assertThat(props.jwt().ttlsByAudience()).isEqualTo(perAudience); assertThat(props.ceremony().challengeTtl()).isEqualTo(Duration.ofMinutes(2)); assertThat(props.otp().pepper()).isEqualTo("dGVzdC1wZXBwZXItYmFzZTY0LWVuY29kZWQ="); assertThat(props.devMode()).isTrue();