diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java index 63871d2a..3c5df835 100644 --- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java @@ -59,6 +59,33 @@ OidcStepUpChallengeJpaAdapter.class }) public class IdentityAuthConfiguration { + /** + * How long a customer access token stays valid. + * + *
Five minutes was the value until 2026-08-09, and it did not survive first contact with real + * use: a session that logged in and then worked through authoring, validation and release was + * rejected mid-sequence, so a person doing one continuous task had to re-authenticate inside it. + * That is a usability defect, not a security control — the token is a bearer credential whose real + * revocation lever is the auth epoch and the credential version, both of which are checked on every + * request and both of which take effect immediately regardless of this value. Shortening the token + * only narrows the window for a stolen token that is not otherwise revoked, and an hour is the + * ordinary trade for that. + * + *
Stated once, as a string constant, because {@code @Value} defaults must be compile-time + * constants and this value used to be a literal repeated at each injection point. A test asserts + * both constants and their relative order. + */ + static final String DEFAULT_ACCESS_LIFETIME = "PT1H"; + + /** + * How long a refresh token family stays valid: 30 days, unchanged. + * + *
This is the value the access lifetime is measured against — it has to stay comfortably longer + * than an access token, or a refresh would be pointless. It was already 720 hours, so raising the + * access lifetime to an hour does not move it. + */ + static final String DEFAULT_REFRESH_LIFETIME = "PT720H"; + @Bean Clock identityClock() { return Clock.systemUTC(); @@ -99,7 +126,7 @@ CustomerJwtCodec customerJwtCodec( @Value("${identity.jwt.issuer:https://ideatostrategy.com}") String issuer, @Value("${identity.jwt.access-audience:idea2strategy-api}") String accessAudience, @Value("${identity.jwt.refresh-audience:idea2strategy-refresh}") String refreshAudience, - @Value("${identity.jwt.access-lifetime:PT5M}") Duration accessLifetime) { + @Value("${identity.jwt.access-lifetime:" + DEFAULT_ACCESS_LIFETIME + "}") Duration accessLifetime) { return new CustomerJwtCodec( decode(key), identityClock, issuer, accessAudience, refreshAudience, accessLifetime); } @@ -189,7 +216,7 @@ EmailAuthenticationService emailAuthenticationService( AesGcmEmailProtector emailProtector, HmacRefreshTokenSecrets refreshTokenSecrets, Clock identityClock, - @Value("${identity.jwt.refresh-lifetime:PT720H}") Duration refreshLifetime) { + @Value("${identity.jwt.refresh-lifetime:" + DEFAULT_REFRESH_LIFETIME + "}") Duration refreshLifetime) { return new EmailAuthenticationService( queries, commands, @@ -211,7 +238,7 @@ RefreshTokenService refreshTokenService( IdentityJpaCommandAdapter commands, HmacRefreshTokenSecrets refreshTokenSecrets, Clock identityClock, - @Value("${identity.jwt.refresh-lifetime:PT720H}") Duration refreshLifetime) { + @Value("${identity.jwt.refresh-lifetime:" + DEFAULT_REFRESH_LIFETIME + "}") Duration refreshLifetime) { return new RefreshTokenService(queries, commands, identityClock, refreshTokenSecrets, refreshLifetime); } @@ -283,7 +310,7 @@ OidcAuthenticationService oidcAuthenticationService( HmacOidcSubjectProtector subjectProtector, HmacRefreshTokenSecrets refreshTokenSecrets, Clock identityClock, - @Value("${identity.jwt.refresh-lifetime:PT720H}") Duration refreshLifetime, + @Value("${identity.jwt.refresh-lifetime:" + DEFAULT_REFRESH_LIFETIME + "}") Duration refreshLifetime, AesGcmEmailProtector emailProtector, @Value("${identity.preferences.default-language:ko}") String defaultLanguage, @Value("${identity.preferences.default-timezone:America/New_York}") String defaultTimezone) { diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityTokenLifetimeDefaultsTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityTokenLifetimeDefaultsTest.java new file mode 100644 index 00000000..1e14a03a --- /dev/null +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityTokenLifetimeDefaultsTest.java @@ -0,0 +1,92 @@ +package com.idea2strategy.backend.api.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.idea2strategy.backend.application.identity.AuthenticationRejectedException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +/** + * Pins the token lifetimes the application runs with when nothing overrides them. + * + *
The wired defaults were previously literals inside {@code @Value} annotations, which no test + * could see: every codec test constructs a codec with its own explicit lifetime, so all of them would + * keep passing while the deployed value drifted. Five minutes reached the deployed environment that + * way and rejected a session in the middle of one continuous task. + */ +class IdentityTokenLifetimeDefaultsTest { + + @Test + void issuesAccessTokensThatLastAnHourAndRefreshTokensThatLastThirtyDays() { + assertEquals( + Duration.ofHours(1), + Duration.parse(IdentityAuthConfiguration.DEFAULT_ACCESS_LIFETIME), + "a customer access token must last an hour unless identity.jwt.access-lifetime overrides it"); + assertEquals( + Duration.ofDays(30), + Duration.parse(IdentityAuthConfiguration.DEFAULT_REFRESH_LIFETIME), + "the refresh family lifetime must stay 30 days"); + assertTrue( + Duration.parse(IdentityAuthConfiguration.DEFAULT_ACCESS_LIFETIME) + .compareTo(Duration.parse(IdentityAuthConfiguration.DEFAULT_REFRESH_LIFETIME)) < 0, + "an access token that outlived its refresh token would make refreshing pointless"); + } + + @Test + void acceptsAnAccessTokenUpToTheDefaultLifetimeAndRejectsItAfter() { + // The default read end to end rather than asserted as a string: a token issued at t must still + // verify at t+59m — which is exactly what five minutes could not do — and must fail at t+61m. + Instant issuedAt = Instant.parse("2026-08-09T00:00:00Z"); + var clock = new MutableClock(issuedAt); + var codec = new CustomerJwtCodec( + new byte[32], + clock, + "https://ideatostrategy.com", + "idea2strategy-api", + "idea2strategy-refresh", + Duration.parse(IdentityAuthConfiguration.DEFAULT_ACCESS_LIFETIME)); + + UUID accountId = UUID.randomUUID(); + UUID loginIdentityId = UUID.randomUUID(); + String token = codec.issueAccess(accountId, loginIdentityId, 1L, 1L); + + clock.set(issuedAt.plus(Duration.ofMinutes(59))); + assertEquals(accountId, codec.verifyAccess(token).accountId()); + + clock.set(issuedAt.plus(Duration.ofMinutes(61))); + assertThrows(AuthenticationRejectedException.class, () -> codec.verifyAccess(token)); + } + + private static final class MutableClock extends Clock { + private Instant now; + + private MutableClock(Instant now) { + this.now = now; + } + + private void set(Instant next) { + this.now = next; + } + + @Override + public Instant instant() { + return now; + } + + @Override + public java.time.ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + } +}