Skip to content

fix(security): on Flutter web a logout during a refresh's access-token write is undone - the secure-storage write lands after the clear and leaves a live access token on a signed-out browser #986

Closed

Description

@koniz-dev

Found by the G3 security review of #984 (for #939), from the code at main be66fbd and flutter_secure_storage_web 2.1.1 (the version in pubspec.lock).

Summary

The session-generation guard in AuthRepositoryImpl.refreshToken() (lib/features/auth/data/repositories/auth_repository_impl.dart:324-330) only works if the token store applies calls in the order they are issued. The code comment at lines 317-323 says so: "Where the platform store handles calls in order (flutter_secure_storage on Android and iOS), the clear also lands after it; elsewhere this narrows the window rather than closing it." That residual has never been filed, and on Flutter web, a shipped target (web/, deploy-web.yml), the order is reversed:

  • FlutterSecureStorageWeb.write awaits _getEncryptionKey and crypto.subtle.encrypt (both WebCrypto, async) before localStorage.setItem.
  • FlutterSecureStorageWeb.delete and deleteAll call localStorage.removeItem synchronously.

So a delete issued after a pending write runs before that write lands.

Exposure path

  1. A refresh returns. The generation check at line 324 passes, and cacheToken(NEW_ACCESS) starts. SecureTokenStore.setAccessToken -> write, which is now waiting on WebCrypto.
  2. Logout starts. logout() invalidates the generation, awaits the remote call (this fails at once when offline), then clearCache() -> clearAllTokens() -> removeItem. AuthInterceptor._logoutUser() (auth_interceptor.dart:829) goes straight to clearAllTokens() with no network await in between. Either way the removal runs while the encryption is still pending.
  3. The encryption finishes, and setItem writes NEW_ACCESS after the clear.
  4. The check at line 327 fails, so the refresh token is not written. refreshToken() returns SESSION_TERMINATED. The interceptor rejects without _logoutUser() (auth_interceptor.dart:503-516), by design. Nothing clears the store again.

Result: a signed-out browser holds a freshly minted access token for the previous user. It is the only credential, with no refresh token and no cached user, so getCurrentUser() reports signed out and the UI shows the login screen. But AuthInterceptor.onRequest (auth_interceptor.dart:327-328) attaches any non-empty stored access token to every API-origin request. Every request the signed-out app makes, including the next user's sign-in request, carries the previous user's bearer token until it expires. And per #797, that token sits in localStorage next to its AES key.

The same reordering already applied to the interceptor's own setAccessToken (auth_interceptor.dart:544). #169 and #290 recorded that as tier 3 and did not claim it. This issue claims it for web from the plugin source. It is a race window, not deterministic, so P1 hardening.

What is and is not verified

  • Verified by reading the code: the plugin's write is encrypt-then-setItem and its delete is a synchronous removeItem (~/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-2.1.1/lib/flutter_secure_storage_web.dart, write ~221-247, delete ~57-62, deleteAll ~66-84). The repository never re-clears after a stale return.
  • Not verified, because it needs a real browser: the WebCrypto vs. remote-logout timing that makes step 3 land in practice. Desktop (macOS/Windows/Linux) ordering is likewise unverified.

Acceptance criteria

  1. A tier-1 test uses a token store that models the web plugin: a write completes after a delete issued later. In that test, a logout() issued while refreshToken()'s access-token write is pending ends with getAccessToken() and getRefreshToken() both null, and refreshToken() returns AuthErrorCodes.sessionTerminated.
  2. The same holds for a forced logout through AuthInterceptor._logoutUser() landing during the interceptor's setAccessToken (auth_interceptor.dart:544).
  3. A session that started during the stale refresh is not damaged. A login() that completes after the stale write keeps its own tokens, so the fix must not "clear on stale" blindly. Serializing token-store operations (a clear waits for in-flight writes) is one approach that meets this.
  4. The comment at auth_repository_impl.dart:317-323 no longer describes a window that the store leaves open.

Activity

  1. added
    type:bugSomething that is broken relative to documented behavior
    epic:core-securitylib/core/security: RASP providers, hardening, security docs
    priority:P1Next up: blocks adopters or a documented workflow is wrong
    status:todoTriaged and startable: has acceptance criteria, nobody assigned
    status:in-progressClaimed by an assignee and being worked right now
    and removed
    status:todoTriaged and startable: has acceptance criteria, nobody assigned
    on Oct 30, 2026
  2. self-assigned this
    on Oct 30, 2026
  3. koniz-dev commented on Oct 30, 2026

    @koniz-dev
    OwnerAuthor

    Security review (G3) of 9ddf193 (#1001): PASS for the shipped wiring. One new hardening finding was filed.

    What I checked, all by reading the code. Nothing ran on a device. Web storage behaviour is tier 3.

    • One queue at runtime. tokenStoreProvider is a non-autoDispose Provider, and nothing invalidates it. authInterceptorProvider, CacheInterceptor (through authInterceptor.tokenStore), AuthLocalDataSourceImpl and AuthRepositoryImpl all share that one instance. A startup retry disposes the old container before runApp, so two live containers never exist. The only other SecureTokenStore( in lib/ is the AuthInterceptor fallback (auth_interceptor.dart:51), and the app never uses it.
    • Logout paths. AuthRepositoryImpl.logout() and AuthInterceptor._logoutUser() both call sessionGeneration.invalidate() synchronously before clearAllTokens(). The guarded writes, the repository refresh and the interceptor's setAccessToken at line 544, have no await between their check and the issue. So every write that passes its check is queued ahead of the clear. No raw SecureStorageService access to auth_token/refresh_token exists outside the store. The migration service touches secure storage only during startup, before the session is restored.
    • Queued reads. Reads follow issue order. A read issued after the clear returns null. The queue completer never errors, so a failure cannot leak or wedge it (the hang case is fix(storage): one hung secure-storage call now blocks every later token operation, including each request's token read and logout's clear #1002).
    • Cached user blob and HTTP cache. Both are cleared in both logout paths. An authenticated response that arrives after logout is not cached, because its request still carries Authorization (_noCacheHeaders secondary guard).

    New finding: #1004 (P1). The ordering holds per instance only. The documented legacy AuthInterceptor(secureStorageService:) path builds a second store with its own queue, which reopens this race for any adopter who uses it.

    Already filed, not re-filed: #985 (login/register writes are unguarded), #1002, #1003.

  4. koniz-dev commented on Oct 30, 2026

    @koniz-dev
    OwnerAuthor

    Fixed by #1001 (squash 9ddf193). SecureTokenStore queues every token read, write and delete behind those issued before it, and ITokenStore documents the ordering an implementation must keep.

    Criterion Result Artifact
    1. With a store that models the web plugin, logout() during refreshToken()'s access-token write leaves both tokens null and returns sessionTerminated PASS g2-and-tests.log: "a logout during the refresh write leaves no token behind", in secure_token_store_order_test.dart, which runs over a storage whose writes land after an async step and whose deletes land at once. mutant-unqueued-store.log: it fails against the unqueued store
    2. The same for a forced logout through AuthInterceptor._logoutUser() during the interceptor's setAccessToken PASS (store level) _logoutUser() ends in clearAllTokens() on the same shared store (tokenStoreProvider, confirmed by G3). "a clear issued after a pending write lands after it" pins that exact ordering, and it fails in the same mutant log. No interceptor-level test drives it
    3. A login() completing after the stale write keeps its own tokens PASS Same log: "a login completing after the stale refresh write keeps its own tokens". Nothing clears on a stale return
    4. The refreshToken() comment no longer describes an open window PASS auth_repository_impl.dart now names the store's ordering guarantee

    G2: audit_template.log exits 0, and check_epic_coverage and check_deliverable exit 0. PR checks: pr1001-checks.txt. G3 passed (comment above). It filed #1004: the queue is per instance, and AuthInterceptor(secureStorageService:) builds a second store. The pre-merge review led to #1002 (a hung platform call holds the queue) and #1003 (SecureStorageService.clear() bypasses it). Real browser timing is tier 3 and is not claimed. No screenshots were produced or cited. Self-QA: each artifact was opened and matches its row.

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

Metadata

Metadata

Assignees

Labels

epic:core-securitylib/core/security: RASP providers, hardening, security docspriority:P1Next up: blocks adopters or a documented workflow is wrongstatus:in-progressClaimed by an assignee and being worked right nowtype:bugSomething that is broken relative to documented behavior

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions