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

Closed
opened 2026-05-29 20:53:34 -05:00 by GiteaMirror · 12 comments
Owner

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

Originally assigned to: @MikesGlitch on GitHub.

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 Originally assigned to: @MikesGlitch on GitHub. ### 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 openidbug labels 2026-05-29 20:53:35 -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.
Author
Owner

@MikesGlitch commented on GitHub (May 27, 2026):

Does this only fail in Safari mobile? Does it work on other browsers/devices with Safari?

<!-- gh-comment-id:4552616189 --> @MikesGlitch commented on GitHub (May 27, 2026): Does this only fail in Safari mobile? Does it work on other browsers/devices with Safari?
Author
Owner

@hirolerEXE commented on GitHub (May 27, 2026):

Does this only fail in Safari mobile? Does it work on other browsers/devices with Safari?

I personally am also having the issue. Works fine on Safari on my mac. Fails on Safari on my iPhone, also fails on Firefox on my iPhone

Works fine on all of my devices personally, except any browser on my iPhone

<!-- gh-comment-id:4554616127 --> @hirolerEXE commented on GitHub (May 27, 2026): > Does this only fail in Safari mobile? Does it work on other browsers/devices with Safari? I personally am also having the issue. Works fine on Safari on my mac. Fails on Safari on my iPhone, also fails on Firefox on my iPhone Works fine on all of my devices personally, except any browser on my iPhone
Author
Owner

@MikesGlitch commented on GitHub (May 27, 2026):

Tempted to just turn off the shared worker for iphones.

Is anyone seeing this issue on Android?

<!-- gh-comment-id:4555222441 --> @MikesGlitch commented on GitHub (May 27, 2026): Tempted to just turn off the shared worker for iphones. Is anyone seeing this issue on Android?
Author
Owner

@MikesGlitch commented on GitHub (May 27, 2026):

Can someone check this to see if this fixes it? https://deploy-preview-7971.demo.actualbudget.org/

You'd just have to go to that url on your iphone, connect to your server and follow the repro instructions.

<!-- gh-comment-id:4555596891 --> @MikesGlitch commented on GitHub (May 27, 2026): Can someone check this to see if this fixes it? https://deploy-preview-7971.demo.actualbudget.org/ You'd just have to go to that url on your iphone, connect to your server and follow the repro instructions.
Author
Owner

@hirolerEXE commented on GitHub (May 27, 2026):

Can someone check this to see if this fixes it? https://deploy-preview-7971.demo.actualbudget.org/

You'd just have to go to that url on your iphone, connect to your server and follow the repro instructions.

image

This is what I get on Safari. Different error than Previously

<!-- gh-comment-id:4556135377 --> @hirolerEXE commented on GitHub (May 27, 2026): > Can someone check this to see if this fixes it? https://deploy-preview-7971.demo.actualbudget.org/ > > You'd just have to go to that url on your iphone, connect to your server and follow the repro instructions. ![image](https://github.com/user-attachments/assets/55a5cfd0-54c5-4712-9af0-a24db8bd5124) This is what I get on Safari. Different error than Previously
Author
Owner

@MikesGlitch commented on GitHub (May 27, 2026):

Can someone check this to see if this fixes it? https://deploy-preview-7971.demo.actualbudget.org/
You'd just have to go to that url on your iphone, connect to your server and follow the repro instructions.

image

This is what I get on Safari. Different error than Previously

Might need to add the preview environment url to your redirect URL config in your open ID provider

<!-- gh-comment-id:4556632738 --> @MikesGlitch commented on GitHub (May 27, 2026): > > Can someone check this to see if this fixes it? https://deploy-preview-7971.demo.actualbudget.org/ > > You'd just have to go to that url on your iphone, connect to your server and follow the repro instructions. > > ![image](https://github.com/user-attachments/assets/55a5cfd0-54c5-4712-9af0-a24db8bd5124) > > This is what I get on Safari. Different error than Previously Might need to add the preview environment url to your redirect URL config in your open ID provider
Author
Owner

@hirolerEXE commented on GitHub (May 27, 2026):

Can someone check this to see if this fixes it? https://deploy-preview-7971.demo.actualbudget.org/
You'd just have to go to that url on your iphone, connect to your server and follow the repro instructions.

image
This is what I get on Safari. Different error than Previously

Might need to add the preview environment url to your redirect URL config in your open ID provider

Hmmm. I tried adding the callback in PocketID, but that still didnt work. I'm wondering if OIDC through a Preview like this will even work because in Docker I have the environment variable for ACTUAL_OPENID_SERVER_HOSTNAME set to my server's URL. Id imagine PocketID is denying this because its trying to come back through the preview link.

I'm also an idiot and have no idea what Im talking about, just a thought :)

<!-- gh-comment-id:4558215016 --> @hirolerEXE commented on GitHub (May 27, 2026): > > > Can someone check this to see if this fixes it? https://deploy-preview-7971.demo.actualbudget.org/ > > > You'd just have to go to that url on your iphone, connect to your server and follow the repro instructions. > > > > > > ![image](https://github.com/user-attachments/assets/55a5cfd0-54c5-4712-9af0-a24db8bd5124) > > This is what I get on Safari. Different error than Previously > > Might need to add the preview environment url to your redirect URL config in your open ID provider Hmmm. I tried adding the callback in PocketID, but that still didnt work. I'm wondering if OIDC through a Preview like this will even work because in Docker I have the environment variable for ACTUAL_OPENID_SERVER_HOSTNAME set to my server's URL. Id imagine PocketID is denying this because its trying to come back through the preview link. I'm also an idiot and have no idea what Im talking about, just a thought :)
Author
Owner

@MikesGlitch commented on GitHub (May 27, 2026):

Hmmm. I tried adding the callback in PocketID, but that still didnt work. I'm wondering if OIDC through a Preview like this will even work because in Docker I have the environment variable for ACTUAL_OPENID_SERVER_HOSTNAME set to my server's URL. Id imagine PocketID is denying this because its trying to come back through the preview link.

Yeah I've had a look as well, it looks like we can't test it with the current code. Unfortunately it's very much tied to the server url 🫤, even if you change it to point to another client url it doesn't seem to work.

Ah well. I have just been able to replicate with browserstack on a slower phone. I'm pretty sure the fix in the PR will work.

<!-- gh-comment-id:4558360938 --> @MikesGlitch commented on GitHub (May 27, 2026): > Hmmm. I tried adding the callback in PocketID, but that still didnt work. I'm wondering if OIDC through a Preview like this will even work because in Docker I have the environment variable for ACTUAL_OPENID_SERVER_HOSTNAME set to my server's URL. Id imagine PocketID is denying this because its trying to come back through the preview link. Yeah I've had a look as well, it looks like we can't test it with the current code. Unfortunately it's very much tied to the server url 🫤, even if you change it to point to another client url it doesn't seem to work. Ah well. I have just been able to replicate with browserstack on a slower phone. I'm pretty sure the fix in the PR will work.
Author
Owner

@MikesGlitch commented on GitHub (May 29, 2026):

Will be fixed for next release

<!-- gh-comment-id:4572633750 --> @MikesGlitch commented on GitHub (May 29, 2026): Will be fixed for next release
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/actual#100605