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
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<String, Duration>` 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
115 changes: 115 additions & 0 deletions docs/adr/0014-per-audience-ttl-policy.md
Original file line number Diff line number Diff line change
@@ -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<String, Duration> 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<String> knownAudiences() { return Set.of(); }

static TokenTtlPolicy fixed(Duration defaultTtl, Map<String, Duration> 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<String, Duration>`.
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.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ org.gradle.jvmargs=-Xmx2g \

# Project identity
group=com.codeheadsystems
version=0.1.0-SNAPSHOT
version=1.1.0-SNAPSHOT
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,12 +80,21 @@ public record RelyingParty(String id, String name, Set<String> 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<String, Duration> ttlsByAudience) {
public Jwt {
Objects.requireNonNull(issuer, "issuer");
Objects.requireNonNull(audience, "audience");
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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<String, Duration> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> amr,
@Nullable Map<String, Object> additionalClaims) {
@Nullable Map<String, Object> additionalClaims,
@Nullable String audience) {

public JwtClaims {
Objects.requireNonNull(userHandle, "userHandle");
Expand All @@ -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();
}
Expand All @@ -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<String> amr,
@Nullable Map<String, Object> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> amr) {
return new JwtClaims(userHandle, AuthMethod.MAGIC_LINK, null, amr, null, audience);
}

@Override
Expand All @@ -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
Expand All @@ -90,6 +127,8 @@ public String toString() {
+ (credentialId == null ? "null" : HexFormat.of().formatHex(credentialId))
+ ", amr="
+ amr
+ ", audience="
+ audience
+ "]";
}
}
Loading