[GH-ISSUE #7754] [Bug]: OIDC login fails on iOS Safari — WorkerBridge stays UNASSIGNED on fresh session #72932

Open
opened 2026-05-16 13:52:31 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @DeltaEchoFour on GitHub (May 8, 2026).
Original GitHub issue: https://github.com/actualbudget/actual/issues/7754

What happened?

Describe the bug

On iOS Safari (and as a PWA), OIDC login fails on any fresh browser session. After successfully authenticating with the OpenID provider and being redirected back to /openid-cb?token=…, the app hangs on "Loading global preferences" and displays "No Server Configured" at the bottom of the screen. The user-token key is never written to asyncStorage.

The same flow works correctly on desktop Safari immediately after a password logout (i.e. with an established SharedWorker session). The failure is specific to iOS Safari on a fresh session where no prior SharedWorker state exists.

Expected behaviour

OIDC login completes successfully and the user is authenticated, consistent with the behaviour on desktop browsers.

Actual behaviour

The app loads at the correct /openid-cb?token=… URL (confirmed via window.location.href) but the OIDC callback handler silently fails to write the auth token to asyncStorage. The app shows "No Server Configured" indefinitely.

Root cause (investigated)

Using eruda (mobile JS console) and SharedWorker log inspection, the following was observed on iOS:

[WorkerBridge] Connected to SharedWorker coordinator
[SharedWorker] Tab connected — 2 tab(s), 1 unassigned, groups: ["__lobby": leader + 0 follower(s)]
[WorkerBridge] Role: UNASSIGNED
[SharedWorker] Last tab left budget "__lobby" — removing group
[SharedWorker] Tab resume confirmed while unassigned — 1 tab(s), 1 unassigned, groups: [none]

On a fresh iOS session, the tab connecting at /openid-cb starts as UNASSIGNED and never transitions to a leader role. The OIDC callback handler depends on the WorkerBridge being in an established state to write user-token to asyncStorage. Because the WorkerBridge is UNASSIGNED, this write fails silently and the callback never completes.

On desktop Safari (with an existing SharedWorker session from a prior password login or OIDC session), the tab is already an established lobby leader when the OIDC callback runs, so the write succeeds.

Confirmed via inspection of asyncStorage after failed iOS callback:

Keys: ["did-bootstrap", "server-url"]
// "user-token" is absent — the OIDC callback did not write it

Confirmed via inspection of asyncStorage on working desktop session:

Keys: ["did-bootstrap", "lastBudget", "server-url", "user-token"]
// "user-token" is present — the OIDC callback wrote it successfully

Environment

  • Actual Budget version: 26.5.0
  • Deployment: Self-hosted Docker (actualbudget/actual-server)
  • Affected: iOS Safari (fresh session), iOS PWA (fresh session)
  • Not affected: Desktop Safari, desktop Chrome, desktop Safari with iPhone user-agent emulation
  • Auth method: OpenID Connect (password auth disabled), Pocket ID as provider

Additional context

  • The OpenID provider successfully issues the auth code, and the server correctly processes /openid/callback and issues a token (302 to /openid-cb?token=… is observed in server logs)
  • The token in the URL is valid — manually writing it to asyncStorage under user-token and navigating to / via the eruda console results in successful authentication
  • A manual page reload of /openid-cb?token=… after the initial failure can succeed intermittently, once the SharedWorker has had time to settle
  • Disabling iOS Safari "Prevent Cross-Site Tracking" does not resolve the issue
  • ACTUAL_OPENID_SERVER_HOSTNAME is correctly configured

Suggested fix

The OIDC callback handler at /openid-cb should not depend on the WorkerBridge being in an established (leader) role to write the auth token. On a fresh session, the tab will always start as UNASSIGNED at this route. The handler should either:

  • Write user-token and server-url directly to asyncStorage (bypassing the WorkerBridge), then redirect to /; or
  • Wait for the WorkerBridge to reach a ready state before proceeding with the token write

How can we reproduce the issue?

Steps to reproduce

  1. Deploy Actual Budget with OIDC configured
  2. On iOS Safari, clear all website data for the Actual Budget domain
  3. Navigate to the Actual Budget instance — the login screen loads and correctly displays the server URL
  4. Tap Sign in with OpenID
  5. Authenticate with the OpenID provider
  6. Provider redirects back to /openid-cb?token=<uuid>
  7. App hangs on "Loading global preferences" — "No Server Configured" shown in footer

Where are you hosting Actual?

Docker

What browsers are you seeing the problem on?

Safari

Operating System

Mobile Device

Originally created by @DeltaEchoFour on GitHub (May 8, 2026). Original GitHub issue: https://github.com/actualbudget/actual/issues/7754 ### What happened? ## Describe the bug On iOS Safari (and as a PWA), OIDC login fails on any fresh browser session. After successfully authenticating with the OpenID provider and being redirected back to `/openid-cb?token=…`, the app hangs on "Loading global preferences" and displays "No Server Configured" at the bottom of the screen. The `user-token` key is never written to `asyncStorage`. The same flow works correctly on desktop Safari immediately after a password logout (i.e. with an established SharedWorker session). The failure is specific to iOS Safari on a fresh session where no prior SharedWorker state exists. ## Expected behaviour OIDC login completes successfully and the user is authenticated, consistent with the behaviour on desktop browsers. ## Actual behaviour The app loads at the correct `/openid-cb?token=…` URL (confirmed via `window.location.href`) but the OIDC callback handler silently fails to write the auth token to `asyncStorage`. The app shows "No Server Configured" indefinitely. ## Root cause (investigated) Using eruda (mobile JS console) and SharedWorker log inspection, the following was observed on iOS: ``` [WorkerBridge] Connected to SharedWorker coordinator [SharedWorker] Tab connected — 2 tab(s), 1 unassigned, groups: ["__lobby": leader + 0 follower(s)] [WorkerBridge] Role: UNASSIGNED [SharedWorker] Last tab left budget "__lobby" — removing group [SharedWorker] Tab resume confirmed while unassigned — 1 tab(s), 1 unassigned, groups: [none] ``` On a fresh iOS session, the tab connecting at `/openid-cb` starts as `UNASSIGNED` and never transitions to a leader role. The OIDC callback handler depends on the WorkerBridge being in an established state to write `user-token` to `asyncStorage`. Because the WorkerBridge is `UNASSIGNED`, this write fails silently and the callback never completes. On desktop Safari (with an existing SharedWorker session from a prior password login or OIDC session), the tab is already an established lobby leader when the OIDC callback runs, so the write succeeds. **Confirmed via inspection of asyncStorage after failed iOS callback:** ``` Keys: ["did-bootstrap", "server-url"] // "user-token" is absent — the OIDC callback did not write it ``` **Confirmed via inspection of asyncStorage on working desktop session:** ``` Keys: ["did-bootstrap", "lastBudget", "server-url", "user-token"] // "user-token" is present — the OIDC callback wrote it successfully ``` ## Environment - **Actual Budget version:** 26.5.0 - **Deployment:** Self-hosted Docker (`actualbudget/actual-server`) - **Affected:** iOS Safari (fresh session), iOS PWA (fresh session) - **Not affected:** Desktop Safari, desktop Chrome, desktop Safari with iPhone user-agent emulation - **Auth method:** OpenID Connect (password auth disabled), Pocket ID as provider ## Additional context - The OpenID provider successfully issues the auth code, and the server correctly processes `/openid/callback` and issues a token (302 to `/openid-cb?token=…` is observed in server logs) - The token in the URL is valid — manually writing it to asyncStorage under `user-token` and navigating to `/` via the eruda console results in successful authentication - A manual page reload of `/openid-cb?token=…` after the initial failure can succeed intermittently, once the SharedWorker has had time to settle - Disabling iOS Safari "Prevent Cross-Site Tracking" does not resolve the issue - `ACTUAL_OPENID_SERVER_HOSTNAME` is correctly configured ## Suggested fix The OIDC callback handler at `/openid-cb` should not depend on the WorkerBridge being in an established (leader) role to write the auth token. On a fresh session, the tab will always start as `UNASSIGNED` at this route. The handler should either: - Write `user-token` and `server-url` directly to `asyncStorage` (bypassing the WorkerBridge), then redirect to `/`; or - Wait for the WorkerBridge to reach a ready state before proceeding with the token write ### How can we reproduce the issue? ## Steps to reproduce 1. Deploy Actual Budget with OIDC configured 2. On iOS Safari, clear all website data for the Actual Budget domain 3. Navigate to the Actual Budget instance — the login screen loads and correctly displays the server URL 4. Tap **Sign in with OpenID** 5. Authenticate with the OpenID provider 6. Provider redirects back to `/openid-cb?token=<uuid>` 7. App hangs on "Loading global preferences" — "No Server Configured" shown in footer ### Where are you hosting Actual? Docker ### What browsers are you seeing the problem on? Safari ### Operating System Mobile Device
GiteaMirror added the openidhelp wantedbug labels 2026-05-16 13:52:31 -05:00
Author
Owner

@jshaofa-ui commented on GitHub (May 10, 2026):

🔧 Solution Proposal

Root Cause

On iOS Safari fresh sessions, the SharedWorker coordinator's init handler sends __role-change: UNASSIGNED but does NOT send a connect message when existing budget groups are present. This means:

  1. The OIDC callback tab connects to the SharedWorker but stays UNASSIGNED
  2. The messageQueue in connection/index.ts is never flushed (only flushes on connect)
  3. send('subscribe-set-token', { token }) is queued but never delivered
  4. The auth token is never written to asyncStorage → OIDC login fails silently

Fix — Three-Layer Defense

1. Primary: Direct IndexedDB write (packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts)

Write the auth token directly to IndexedDB from the main thread, bypassing the WorkerBridge entirely:

async function writeTokenToIndexedDB(token: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('actual', 9);
    request.onsuccess = () => {
      const db = request.result;
      const tx = db.transaction(['asyncStorage'], 'readwrite');
      tx.objectStore('asyncStorage').put(token, 'user-token');
      resolve();
    };
  });
}

2. Coordinator fix (packages/desktop-client/src/shared-browser-server-core.ts)

Send synthetic connect to UNASSIGNED tabs:

} else if (budgetGroups.size > 0) {
  port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
  port.postMessage({ type: 'connect' });  // ADD THIS LINE
}

3. SharedWorker timeout fallback (packages/desktop-client/src/browser-preload.js)

5-second timeout that falls back to a direct Worker if SharedWorker doesn't respond.

Why This Works

  • IndexedDB is shared between main thread and worker — writing from main thread is equivalent
  • The subscribe-set-token handler is idempotent (just asyncStorage.setItem)
  • Three layers ensure reliability: direct write, coordinator fix, timeout fallback
<!-- gh-comment-id:4414059378 --> @jshaofa-ui commented on GitHub (May 10, 2026): ## 🔧 Solution Proposal ### Root Cause On iOS Safari fresh sessions, the SharedWorker coordinator's `init` handler sends `__role-change: UNASSIGNED` but does NOT send a `connect` message when existing budget groups are present. This means: 1. The OIDC callback tab connects to the SharedWorker but stays UNASSIGNED 2. The `messageQueue` in `connection/index.ts` is never flushed (only flushes on `connect`) 3. `send('subscribe-set-token', { token })` is queued but never delivered 4. The auth token is never written to `asyncStorage` → OIDC login fails silently ### Fix — Three-Layer Defense **1. Primary: Direct IndexedDB write** (`packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts`) Write the auth token directly to IndexedDB from the main thread, bypassing the WorkerBridge entirely: ```typescript async function writeTokenToIndexedDB(token: string): Promise<void> { return new Promise((resolve, reject) => { const request = indexedDB.open('actual', 9); request.onsuccess = () => { const db = request.result; const tx = db.transaction(['asyncStorage'], 'readwrite'); tx.objectStore('asyncStorage').put(token, 'user-token'); resolve(); }; }); } ``` **2. Coordinator fix** (`packages/desktop-client/src/shared-browser-server-core.ts`) Send synthetic `connect` to UNASSIGNED tabs: ```typescript } else if (budgetGroups.size > 0) { port.postMessage({ type: '__role-change', role: 'UNASSIGNED' }); port.postMessage({ type: 'connect' }); // ADD THIS LINE } ``` **3. SharedWorker timeout fallback** (`packages/desktop-client/src/browser-preload.js`) 5-second timeout that falls back to a direct Worker if SharedWorker doesn't respond. ### Why This Works - IndexedDB is shared between main thread and worker — writing from main thread is equivalent - The `subscribe-set-token` handler is idempotent (just `asyncStorage.setItem`) - Three layers ensure reliability: direct write, coordinator fix, timeout fallback
Author
Owner

@emcbem commented on GitHub (May 12, 2026):

Right when I setup OIDC for the first time I experience this bug, great timing :( Solution looks great though!

<!-- gh-comment-id:4426371968 --> @emcbem commented on GitHub (May 12, 2026): Right when I setup OIDC for the first time I experience this bug, great timing :( Solution looks great though!
Author
Owner

@joel-jeremy commented on GitHub (May 12, 2026):

Thank you for reporting the bug! @jshaofa-ui Please feel free to create a PR so maintainers can have a look at the solution.

<!-- gh-comment-id:4427682381 --> @joel-jeremy commented on GitHub (May 12, 2026): Thank you for reporting the bug! @jshaofa-ui Please feel free to create a PR so maintainers can have a look at the solution.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/actual#72932