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
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,33 @@
OidcStepUpChallengeJpaAdapter.class
})
public class IdentityAuthConfiguration {
/**
* How long a customer access token stays valid.
*
* <p>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.
*
* <p>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.
*
* <p>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();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
}