Skip to content

Core: Fix child AuthSession inheriting parent's expiresAtMillis#15999

Open
bharos wants to merge 2 commits intoapache:mainfrom
bharos:fix/oauth2-child-session-expiry
Open

Core: Fix child AuthSession inheriting parent's expiresAtMillis#15999
bharos wants to merge 2 commits intoapache:mainfrom
bharos:fix/oauth2-child-session-expiry

Conversation

@bharos
Copy link
Copy Markdown
Contributor

@bharos bharos commented Apr 16, 2026

What

Fix child AuthSession inheriting the parent session's expiresAtMillis instead of computing it from its own token.

Why

When creating a child AuthSession via fromTokenResponse() or fromAccessToken(), the AuthConfig builder calls .from(parent.config()) to inherit configuration (scope, credential, oauth2ServerUri, etc.). However, Immutables' from() copies all values from the parent, including expiresAtMillis. This overrides the @Value.Default method that would otherwise recompute the expiry from the child's own JWT exp claim.

Impact: The child session schedules its first token refresh based on the parent's token expiry, not its own. In token exchange flows where:

  • Catalog session holds a long-lived token (e.g., 1-hour Azure AD token)
  • Exchanged per-user token is short-lived (e.g., 5-minute token)

...the refresh is scheduled ~54 minutes out instead of ~4.5 minutes, causing the short-lived token to expire without being refreshed.

How

Explicitly set expiresAtMillis from the child token in both fromTokenResponse() and fromAccessToken() builders:

.expiresAtMillis(OAuth2Util.expiresAtMillis(response.token()))

This ensures the child session's expiry is always derived from its own token's JWT exp claim (or null for opaque tokens, falling back to response.expiresInSeconds() for scheduling).

Fixes #16002

Testing

Added 3 tests to TestOAuth2Util:

  • fromTokenResponseUsesChildTokenExpiry — verifies child JWT token's exp is used, not parent's
  • fromTokenResponseOpaqueTokenDoesNotInheritParentExpiry — verifies opaque child tokens don't inherit parent's exp
  • fromAccessTokenUsesChildTokenExpiry — verifies the same fix for fromAccessToken()

@github-actions github-actions bot added the core label Apr 16, 2026
When creating a child AuthSession via fromTokenResponse() or
fromAccessToken(), the AuthConfig builder uses .from(parent.config())
to inherit configuration. This copies the parent's expiresAtMillis
value, which overrides the @Value.Default that would otherwise
recompute from the new child token's JWT exp claim.

This causes child sessions to schedule their first token refresh
based on the parent's token expiry rather than their own. In
token-exchange flows where a catalog session has a long-lived token
(e.g., 1 hour) and the exchanged per-user token is short-lived
(e.g., 5 minutes), refresh is scheduled ~54 minutes out instead of
~4.5 minutes, causing the short-lived token to expire unrefreshed.

Fix: Explicitly set expiresAtMillis from the child token in both
fromTokenResponse() and fromAccessToken() builders, so the child
session's expiry is always derived from its own token.
@bharos bharos force-pushed the fix/oauth2-child-session-expiry branch from b76cbd5 to 711b09d Compare April 16, 2026 19:47
@bharos bharos marked this pull request as ready for review April 16, 2026 20:02
@bharos
Copy link
Copy Markdown
Contributor Author

bharos commented Apr 16, 2026

@adutra can you please review this change (or kindly let know who can do a review). Thanks!

Copy link
Copy Markdown
Contributor

@adutra adutra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @bharos the changes make perfect sense to me. I only had a few minor remarks.

.from(config())
.token(response.token())
.tokenType(response.issuedTokenType())
.expiresAtMillis(OAuth2Util.expiresAtMillis(response.token()))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is good imho, but untested – it would be good to have it tested, although I agree it's not easy to test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added refreshUsesRefreshedTokenExpiry() test

Comment on lines +240 to +249
private static String tokenWithExp(long expSeconds) {
String header = "{\"alg\":\"none\",\"typ\":\"JWT\"}";
String payload = "{\"sub\":\"test\",\"exp\":" + expSeconds + "}";
Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
return encoder.encodeToString(header.getBytes(StandardCharsets.UTF_8))
+ "."
+ encoder.encodeToString(payload.getBytes(StandardCharsets.UTF_8))
+ "."
+ encoder.encodeToString("fakesig".getBytes(StandardCharsets.UTF_8));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be replaced with Nimbus Jose (which comes transitively through MockServer and other deps):

Suggested change
private static String tokenWithExp(long expSeconds) {
String header = "{\"alg\":\"none\",\"typ\":\"JWT\"}";
String payload = "{\"sub\":\"test\",\"exp\":" + expSeconds + "}";
Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
return encoder.encodeToString(header.getBytes(StandardCharsets.UTF_8))
+ "."
+ encoder.encodeToString(payload.getBytes(StandardCharsets.UTF_8))
+ "."
+ encoder.encodeToString("fakesig".getBytes(StandardCharsets.UTF_8));
}
private static String tokenWithExp(long expSeconds) {
JWTClaimsSet claimsSet =
new JWTClaimsSet.Builder().subject("test").claim("exp", expSeconds).build();
return new PlainJWT(claimsSet).serialize();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — replaced manual Base64 JWT construction with Nimbus PlainJWT

Copy link
Copy Markdown
Contributor

@singhpk234 singhpk234 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to the change

Comment on lines +178 to +197
String parentToken = tokenWithExp(7200);
// child token with no exp claim (opaque)
String childToken = "opaque-access-token";

AuthConfig parentConfig =
AuthConfig.builder()
.token(parentToken)
.tokenType(OAuth2Properties.ACCESS_TOKEN_TYPE)
.keepRefreshed(false)
.build();
OAuth2Util.AuthSession parent = new OAuth2Util.AuthSession(Map.of(), parentConfig);
assertThat(parent.expiresAtMillis()).isEqualTo(TimeUnit.SECONDS.toMillis(7200));

OAuthTokenResponse response =
OAuthTokenResponse.builder()
.withToken(childToken)
.withTokenType(BEARER)
.withIssuedTokenType(OAuth2Properties.ACCESS_TOKEN_TYPE)
.setExpirationInSeconds(600)
.build();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if we can extract these common block in a helper private function below as creating partent token and creating a OAuthTokenResponse from the child token etc .....

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — extracted parentSession(), childTokenResponse(), and tokenWithExp() helpers.

- Replace manual Base64 JWT construction with Nimbus PlainJWT
- Extract parentSession(), childTokenResponse(), tokenWithExp() helpers
- Add refreshUsesRefreshedTokenExpiry test for refresh() code path
- Import OAuth2Util.AuthSession to avoid FQN usage in tests
@bharos
Copy link
Copy Markdown
Contributor Author

bharos commented Apr 17, 2026

Thanks for the review @adutra and @singhpk234 , addressed your comments. PTAL

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Child AuthSession inherits parent's expiresAtMillis, causing incorrect token refresh scheduling

3 participants