From 0de5ff5d3bd9f47601ca0149c1a9b61360578324 Mon Sep 17 00:00:00 2001 From: Ryan Min Date: Mon, 10 Aug 2026 00:03:07 +0900 Subject: [PATCH] feat: give a customer access token an hour instead of five minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five minutes did not survive real use. A session that logged in and then worked through authoring, validation and release was rejected mid-sequence, so one continuous task required re-authenticating inside itself. The token's real revocation levers are the auth epoch and the credential version, both checked on every request and both effective immediately whatever this value is. A short expiry only narrows the window for a stolen token that is not otherwise revoked, and an hour is the ordinary trade for that. The refresh lifetime is unchanged at 30 days: it was already far longer than an access token and nothing about this change moves it. Both defaults are now named constants instead of literals repeated inside @Value annotations, because no test could see the wired values — every codec test constructs its own lifetime, so all of them kept passing while the deployed default was five minutes. The new test asserts the constants and reads the access default end to end: a token issued at t verifies at t+59m and is rejected at t+61m. --- .../identity/IdentityAuthConfiguration.java | 35 ++++++- .../IdentityTokenLifetimeDefaultsTest.java | 92 +++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityTokenLifetimeDefaultsTest.java 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; + } + } +}