Skip to content

fix: handle refresh responses without id_token#307

Open
balazsotakomaiya wants to merge 2 commits into
Bdaya-Dev:mainfrom
balazsotakomaiya:fix/refresh-without-id-token
Open

fix: handle refresh responses without id_token#307
balazsotakomaiya wants to merge 2 commits into
Bdaya-Dev:mainfrom
balazsotakomaiya:fix/refresh-without-id-token

Conversation

@balazsotakomaiya
Copy link
Copy Markdown

@balazsotakomaiya balazsotakomaiya commented Apr 7, 2026

Fixes #306

Description

This change fixes the case where a successful refresh token response does not include id_token.

Previously, the library could still reject the session later if it had to keep using the previous id_token and that token was already expired. There was also no regression coverage around token event behavior for this refresh path.

What changed

  • Reuse the previously loaded user during cached-token startup refresh so refresh responses without id_token can still preserve the existing id_token.
  • Persist a narrow internal marker on refreshed tokens when the manager had to reuse the previous id_token because the token response omitted one.
  • During validation, ignore only the JWT expired error for that specific reused-id-token case.
  • Keep all other id_token validation checks intact, including issuer, audience, sub, and iat validation.
  • Add regression tests for: cached-token refresh without id_token, reload after a refresh without id_token, no token expired event after a successful refresh without id_token

Why

Some providers do not return id_token on refresh. In that case, a successful refresh should not cause the session to be rejected solely because the manager had to keep using the prior id_token.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

Summary by CodeRabbit

  • New Features

    • OIDC handling improved to preserve and reuse a cached ID token when refresh responses omit an ID token, with a flag to allow treating an expired ID token as valid during refreshes.
  • Tests

    • Added tests covering refresh flows without ID tokens: token state preservation, persisted store contents, event emission behavior, and re-initialization without extra network calls.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

Adds handling for refresh responses that omit id_token: a persisted allowExpiredIdToken flag is stored on refreshed tokens, reused when appropriate, and JWT-expired validation errors are suppressed for those refreshed sessions.

Changes

Cohort / File(s) Summary
Constants
packages/oidc_core/lib/src/constants.dart
Added OidcConstants_Store.allowExpiredIdToken string constant for persisted token state.
Token Model
packages/oidc_core/lib/src/endpoints/token/token.dart
Added OidcToken.allowExpiredIdToken getter that reads the new flag from token extra.
User Model
packages/oidc_core/lib/src/models/user.dart
OidcUser.replaceToken(...) gained allowExpiredIdToken param; merged token JSON now conditionally sets/removes the persisted flag.
Manager Logic
packages/oidc_core/lib/src/managers/user_manager_base.dart
Refactored refresh/creation paths: added currentUserOverride to createUserFromToken, moved refresh logic to _refreshToken, centralized JWT-expired detection (_isJwtExpiredError), and suppresses JWT-expired validation errors when allowExpiredIdToken is set; refresh uses loaded user context for reuse of prior id_token.
Tests
packages/oidc/test/oidc_test.dart, packages/oidc_core/test/model_test.dart
Added three integration tests for refresh-without-id_token flows (persistence, reload/no-network, event behavior) and a unit test verifying allowExpiredIdToken round-trips via OidcToken.fromJson()/toJson().

Sequence Diagram(s)

sequenceDiagram
    participant Manager
    participant Network as TokenEndpoint
    participant Store
    participant Validator

    Manager->>Store: load cached user/token
    Store-->>Manager: cached token (+ old id_token)
    Manager->>Network: POST refresh (refresh_token)
    Network-->>Manager: success response (no id_token, new access_token)
    Manager->>Manager: set allowExpiredIdToken=true on merged token
    Manager->>Store: persist merged token (includes allowExpiredIdToken)
    Manager->>Validator: validate token (uses allowExpiredIdToken)
    Validator-->>Manager: suppress JWT-expired error when allowExpiredIdToken=true
    Manager-->>Manager: keep session active (no expired event)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I found a token, tattered, old,
Refreshed the bits but kept its gold.
A little flag to ease the pain,
Expiry whispers—ignored again.
Hop—refresh complete, the session stays bold.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: handling token refresh responses that omit id_token.
Linked Issues check ✅ Passed The PR addresses all coding requirements from issue #306: reusing previous id_token during refresh, preserving reused-token state, selective JWT-expired validation, and adding regression tests.
Out of Scope Changes check ✅ Passed All changes are directly related to handling refresh responses without id_token as specified in the linked issue; no unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/oidc_core/lib/src/managers/user_manager_base.dart (1)

1292-1301: ⚠️ Potential issue | 🟡 Minor

Defer offline-mode recovery until the refreshed user is accepted.

recordSuccessfulServerContact() runs before createUserFromToken(). If claim validation or userinfo validation later returns null/throws, the manager still clears offline state and resets failure counters even though no usable session was produced. handleTokenExpiring() already records success after validation; this path should do the same.

💡 Suggested fix
-      // Successful refresh - update last server contact and exit offline mode
-      recordSuccessfulServerContact(newToken: token);
-      return await createUserFromToken(
+      final refreshedUser = await createUserFromToken(
         token: token,
         nonce: null,
         userInfo: null,
         attributes: null,
         metadata: discoveryDocument,
         currentUserOverride: existingUser,
       );
+      if (refreshedUser != null) {
+        recordSuccessfulServerContact(newToken: refreshedUser.token);
+      }
+      return refreshedUser;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/oidc_core/lib/src/managers/user_manager_base.dart` around lines 1292
- 1301, The code currently calls recordSuccessfulServerContact() before
attempting to validate/build the refreshed session via createUserFromToken(),
which can clear offline state even if createUserFromToken() fails or returns
null; move the call to recordSuccessfulServerContact() so it only runs after
createUserFromToken() has successfully returned a non-null user (i.e., call
recordSuccessfulServerContact(newToken: token) immediately before returning the
created user and do not call it if createUserFromToken() throws or returns
null), mirroring the behavior in handleTokenExpiring() and ensuring failure
counters/offline mode are only reset on an actually accepted refreshed user.
packages/oidc_core/lib/src/models/user.dart (1)

161-190: ⚠️ Potential issue | 🟠 Major

Persist idTokenOverride in the merged token.

replaceToken() uses idTokenOverride for OidcUser.idToken / parsedIdToken, but saveUser() later persists user.token.toJson(). Right now the merged JSON is built only from the old/new token payloads, so a settings.getIdToken override is lost on disk and a reload falls back to the stale id_token.

💡 Suggested fix
     final mergedTokenJson = {
       // keep old data and override the new data.
       ...token.toJson(),
       ...newToken.toJson(),
+      OidcConstants_AuthParameters.idToken: idToken,
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/oidc_core/lib/src/models/user.dart` around lines 161 - 190, The
merged token JSON currently only combines token.toJson() and newToken.toJson(),
so an idTokenOverride passed into replaceToken() (the idToken variable used to
set OidcUser.idToken and parsedIdToken) is not persisted; update the
mergedTokenJson before creating OidcToken.fromJson(...) to include the
idTokenOverride value (set the appropriate id_token key in mergedTokenJson when
idTokenOverride != null) so the persisted token reflects the override; reference
mergedTokenJson, idToken (or idTokenOverride), OidcUser._ and OidcToken.fromJson
to locate where to add this assignment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/oidc_core/lib/src/managers/user_manager_base.dart`:
- Around line 1043-1050: createUserFromToken currently derives
allowExpiredIdToken inside the method and can wrongly enable it for non-refresh
flows; change createUserFromToken to accept an explicit bool parameter
allowExpiredIdToken (default false) and pass that to currentUser.replaceToken
instead of computing reusesExistingIdToken locally. Update the refresh code
path(s) that call createUserFromToken to pass allowExpiredIdToken: true, and
leave loginPassword and loginDeviceCodeFlow calls unchanged (or explicitly pass
false) so only refresh callers enable expired-id-token acceptance; adjust
callers referencing createUserFromToken accordingly.
- Around line 1560-1561: The JWT expiry detection is inconsistent:
_isJwtExpiredError(Exception error) currently checks
error.message.startsWith('JWT expired.') (with a period) while another check
uses startsWith('JWT expired') (no period); update _isJwtExpiredError to use a
broader prefix (e.g., startsWith('JWT expired')) or normalize the message check
(trim trailing punctuation) and then replace the ad-hoc check with a call to
_isJwtExpiredError so both validation paths (the helper and the
offline/reused-id_token path) use the same logic (reference _isJwtExpiredError
and the other JWT expiry check in the offline/reused-id_token validation).

---

Outside diff comments:
In `@packages/oidc_core/lib/src/managers/user_manager_base.dart`:
- Around line 1292-1301: The code currently calls
recordSuccessfulServerContact() before attempting to validate/build the
refreshed session via createUserFromToken(), which can clear offline state even
if createUserFromToken() fails or returns null; move the call to
recordSuccessfulServerContact() so it only runs after createUserFromToken() has
successfully returned a non-null user (i.e., call
recordSuccessfulServerContact(newToken: token) immediately before returning the
created user and do not call it if createUserFromToken() throws or returns
null), mirroring the behavior in handleTokenExpiring() and ensuring failure
counters/offline mode are only reset on an actually accepted refreshed user.

In `@packages/oidc_core/lib/src/models/user.dart`:
- Around line 161-190: The merged token JSON currently only combines
token.toJson() and newToken.toJson(), so an idTokenOverride passed into
replaceToken() (the idToken variable used to set OidcUser.idToken and
parsedIdToken) is not persisted; update the mergedTokenJson before creating
OidcToken.fromJson(...) to include the idTokenOverride value (set the
appropriate id_token key in mergedTokenJson when idTokenOverride != null) so the
persisted token reflects the override; reference mergedTokenJson, idToken (or
idTokenOverride), OidcUser._ and OidcToken.fromJson to locate where to add this
assignment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 242c64ad-7088-4b9d-866d-b861ec3090c6

📥 Commits

Reviewing files that changed from the base of the PR and between f27fe3c and 4af363b.

📒 Files selected for processing (6)
  • packages/oidc/test/oidc_test.dart
  • packages/oidc_core/lib/src/constants.dart
  • packages/oidc_core/lib/src/endpoints/token/token.dart
  • packages/oidc_core/lib/src/managers/user_manager_base.dart
  • packages/oidc_core/lib/src/models/user.dart
  • packages/oidc_core/test/model_test.dart

Comment on lines +1043 to +1050
final reusesExistingIdToken =
idTokenOverride == null && token.idToken == null;
newUser = await currentUser.replaceToken(
token,
idTokenOverride: idTokenOverride,
strictVerification: settings.strictJwtVerification,
cacheStore: store,
allowExpiredIdToken: reusesExistingIdToken,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Only enable allowExpiredIdToken on refresh paths.

createUserFromToken() is also used by loginPassword() (Lines 301-311) and loginDeviceCodeFlow() (Lines 411-417), both with nonce: null. If one of those flows runs while a user is already loaded and the response omits id_token, this path now persists the previous session’s JWT with allowExpiredIdToken: true, so a later reload can keep validating stale claims against the new token set. Please thread this flag explicitly from refresh callers instead of inferring it here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/oidc_core/lib/src/managers/user_manager_base.dart` around lines 1043
- 1050, createUserFromToken currently derives allowExpiredIdToken inside the
method and can wrongly enable it for non-refresh flows; change
createUserFromToken to accept an explicit bool parameter allowExpiredIdToken
(default false) and pass that to currentUser.replaceToken instead of computing
reusesExistingIdToken locally. Update the refresh code path(s) that call
createUserFromToken to pass allowExpiredIdToken: true, and leave loginPassword
and loginDeviceCodeFlow calls unchanged (or explicitly pass false) so only
refresh callers enable expired-id-token acceptance; adjust callers referencing
createUserFromToken accordingly.

Comment thread packages/oidc_core/lib/src/managers/user_manager_base.dart Outdated
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/oidc_core/lib/src/managers/user_manager_base.dart (1)

1043-1050: ⚠️ Potential issue | 🟠 Major

Keep allowExpiredIdToken refresh-only.

createUserFromToken() still infers this from “missing id_token + existing user”, but this helper is also used by loginPassword() (Lines 301-311) and loginDeviceCodeFlow() (Lines 411-417). If either flow runs while a user is already loaded and the response omits id_token, this will persist the previous session JWT with allowExpiredIdToken: true, so later reloads can keep validating stale claims against the new token set. Please make this an explicit opt-in from the refresh callers instead of deriving it here.

Suggested direction
 Future<OidcUser?> createUserFromToken({
   required OidcToken token,
   required String? nonce,
   required Map<String, dynamic>? attributes,
   required Map<String, dynamic>? userInfo,
   required OidcProviderMetadata metadata,
   OidcUser? currentUserOverride,
+  bool allowExpiredIdToken = false,
   bool validateAndSave = true,
 }) async {
   final currentUser = currentUserOverride ?? this.currentUser;
   OidcUser? newUser;
   final idTokenOverride = await settings.getIdToken?.call(token);
   if (currentUser == null) {
     ...
   } else {
-    final reusesExistingIdToken =
-        idTokenOverride == null && token.idToken == null;
     newUser = await currentUser.replaceToken(
       token,
       idTokenOverride: idTokenOverride,
       strictVerification: settings.strictJwtVerification,
       cacheStore: store,
-      allowExpiredIdToken: reusesExistingIdToken,
+      allowExpiredIdToken: allowExpiredIdToken,
     );
   }

Then have only the refresh paths pass allowExpiredIdToken: token.idToken == null.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/oidc_core/lib/src/managers/user_manager_base.dart` around lines 1043
- 1050, createUserFromToken currently infers allowExpiredIdToken from a missing
id_token + existing user, which lets non-refresh callers (loginPassword,
loginDeviceCodeFlow) accidentally reuse expired id tokens; change
createUserFromToken to not derive this value internally and require callers to
pass allowExpiredIdToken explicitly, then update loginPassword and
loginDeviceCodeFlow to pass false and update the refresh paths to pass
(token.idToken == null) so only refresh flows opt into allowExpiredIdToken;
reference createUserFromToken, loginPassword, loginDeviceCodeFlow and the
refresh-call sites when making the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/oidc_core/lib/src/managers/user_manager_base.dart`:
- Around line 1043-1050: createUserFromToken currently infers
allowExpiredIdToken from a missing id_token + existing user, which lets
non-refresh callers (loginPassword, loginDeviceCodeFlow) accidentally reuse
expired id tokens; change createUserFromToken to not derive this value
internally and require callers to pass allowExpiredIdToken explicitly, then
update loginPassword and loginDeviceCodeFlow to pass false and update the
refresh paths to pass (token.idToken == null) so only refresh flows opt into
allowExpiredIdToken; reference createUserFromToken, loginPassword,
loginDeviceCodeFlow and the refresh-call sites when making the changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ca8c45f4-efcc-455a-b1a6-cbc7d968a0b3

📥 Commits

Reviewing files that changed from the base of the PR and between 4af363b and d62828c.

📒 Files selected for processing (1)
  • packages/oidc_core/lib/src/managers/user_manager_base.dart

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: library triggers a token expired event if the token refresh call omits id_token

1 participant