[GH-ISSUE #6071] [Bug]: OIDC With Entra Not Authenticating Additional Users #28384

Open
opened 2026-04-18 05:06:42 -05:00 by GiteaMirror · 7 comments
Owner

Originally created by @n-tropy247 on GitHub (Nov 4, 2025).
Original GitHub issue: https://github.com/actualbudget/actual/issues/6071

Verified issue does not already exist?

  • I have searched and found no existing issue

What happened?

I have configured OIDC with an app registration in Entra and it is working fine to authenticate the account set as server owner. Whenever I authentication a second user (already added under User Directory) it throws "openid-grant-failed". The journal is linked below for review, most relevant line reads OAuth2 Authorization code was already redeemed, please retry with a new valid code or use an existing refresh token. The sign-in logs Entra-side show successful authentication. I also noticed that when I authenticated my account with OIDC, it set my username in User Directory as some sort of guid:

Image

Log: https://pastebin.com/YeWFFVUJ

How can we reproduce the issue?

Actual is being hosted on a home server running Debian 12. Entra is set up as IdP using an App Registration.

Where are you hosting Actual?

Locally via Yarn

What browsers are you seeing the problem on?

Chrome

Operating System

Windows 11

Originally created by @n-tropy247 on GitHub (Nov 4, 2025). Original GitHub issue: https://github.com/actualbudget/actual/issues/6071 ### Verified issue does not already exist? - [x] I have searched and found no existing issue ### What happened? I have configured OIDC with an app registration in Entra and it is working fine to authenticate the account set as server owner. Whenever I authentication a second user (already added under User Directory) it throws "openid-grant-failed". The journal is linked below for review, most relevant line reads **OAuth2 Authorization code was already redeemed, please retry with a new valid code or use an existing refresh token**. The sign-in logs Entra-side show successful authentication. I also noticed that when I authenticated my account with OIDC, it set my username in User Directory as some sort of guid: <img width="1141" height="113" alt="Image" src="https://github.com/user-attachments/assets/1c861549-bedd-4e16-8f63-ed767b47ccdf" /> Log: https://pastebin.com/YeWFFVUJ ### How can we reproduce the issue? Actual is being hosted on a home server running Debian 12. Entra is set up as IdP using an App Registration. ### Where are you hosting Actual? Locally via Yarn ### What browsers are you seeing the problem on? Chrome ### Operating System Windows 11
GiteaMirror added the openidbug labels 2026-04-18 05:06:42 -05:00
Author
Owner

@coderabbitai[bot] commented on GitHub (Nov 4, 2025):

A summary of the changes CodeRabbit can apply:

  • Delete the pending OpenID request immediately in packages/sync-server/src/accounts/openid.js (preventing replayed OAuth code redemption and AADSTS54005 errors) and update useEffect to run once in packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts (fixing repeated effect runs), resulting in single-use code processing and more reliable authentication.

  • Update OpenId callback handling: make the client effect in packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts run only once by adding an empty dependency array to the useEffect, and harden the server in packages/sync-server/src/accounts/openid.js by immediately deleting the matching pending_openid_requests row (by state) to prevent replay of the authorization code before processing.

  • Create PR with these edits
  • 📋 Get copyable edits
<!-- gh-comment-id:3487692297 --> @coderabbitai[bot] commented on GitHub (Nov 4, 2025): <!-- This is an auto-generated issue plan by CodeRabbit --> A summary of the changes CodeRabbit can apply: > - Delete the pending OpenID request immediately in packages/sync-server/src/accounts/openid.js (preventing replayed OAuth code redemption and AADSTS54005 errors) and update useEffect to run once in packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts (fixing repeated effect runs), resulting in single-use code processing and more reliable authentication. > > - Update OpenId callback handling: make the client effect in packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts run only once by adding an empty dependency array to the useEffect, and harden the server in packages/sync-server/src/accounts/openid.js by immediately deleting the matching pending_openid_requests row (by state) to prevent replay of the authorization code before processing. - [ ] <!-- {"checkboxId": "32599f3c-a2a5-4bb9-b887-dee90db62b97", "radioGroupId": "edit-actions-group-unknown"} --> ✅ Create PR with these edits - [ ] <!-- {"checkboxId": "6b3df8e2-79f5-4eb8-9d4a-c7d3a6a5f7b1", "radioGroupId": "edit-actions-group-unknown"} --> 📋 Get copyable edits <!-- <agentComponentType>plan-edits</agentComponentType> --> <!-- <rawResChunk>## Summary I've successfully identified and fixed the bug causing "OIDC With Entra Not Authenticating Additional Users" error. Here's what I found and fixed: ### **Root Cause** The `loginWithOpenIdFinalize()` function in `packages/sync-server/src/accounts/openid.js` had a critical flaw: it never deleted the `pending_openid_requests` database entry after retrieving it. This allowed the OAuth callback endpoint to be invoked multiple times with the same authorization code, but **OAuth authorization codes can only be used once**. When the code was redeemed a second time, Entra correctly rejected it with "AADSTS54005: OAuth2 Authorization code was already redeemed." ### **Fixes Applied** #### **Fix 1: Delete Pending Request Immediately** (Primary Fix) **File:** `packages/sync-server/src/accounts/openid.js` (lines 207-212) Added code to delete the pending OAuth request from the database immediately after retrieval and validation, **before** attempting to exchange the authorization code: ```javascript // Delete the pending request immediately to prevent replay attacks // OAuth authorization codes can only be used once, so we must ensure // this callback cannot be processed multiple times with the same code accountDb.mutate('DELETE FROM pending_openid_requests WHERE state = ?', [ body.state, ]); ``` This ensures: - Authorization code can only be processed once - Prevents race conditions from duplicate callback requests - Follows OAuth 2.0 RFC standards for single-use codes - Duplicate requests fail fast with "invalid-or-expired-state" instead of attempting code redemption #### **Fix 2: Fix React useEffect Dependencies** **File:** `packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts` (line 15) Changed `useEffect(() => { ... })` to `useEffect(() => { ... }, [])` to ensure the effect runs only once on mount, not on every render. ### **Security Benefits** - Prevents replay attacks (authorization codes cannot be reused) - Reduces attack surface (failed authentications fail faster with clearer errors) - Follows OAuth 2.0 best practices ### **Regarding the GUID Username Issue** This is expected behavior when Entra doesn't provide `preferred_username` or `email` claims in the userinfo response. The code falls back to the `sub` (subject identifier), which is typically a GUID. To fix this, configure your Entra app registration to include "email" and "profile" scopes and optional claims.</rawResChunk> --> <!-- <rawResChunk><overallGitDiffFiles>[{"path":"packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts","isNew":false,"isDeleted":false,"isRenamed":false,"hunks":[{"oldStart":12,"oldLines":6,"newStart":12,"newLines":6,"lines":[{"type":"context","content":" send('subscribe-set-token', { token: token as string }).then(() => {","oldLineNumber":12,"newLineNumber":12},{"type":"context","content":" dispatch(loggedIn());","oldLineNumber":13,"newLineNumber":13},{"type":"context","content":" });","oldLineNumber":14,"newLineNumber":14},{"type":"deletion","content":" });","oldLineNumber":15},{"type":"addition","content":" }, []);","newLineNumber":15},{"type":"context","content":" return null;","oldLineNumber":16,"newLineNumber":16},{"type":"context","content":"}","oldLineNumber":17,"newLineNumber":17}]}]},{"path":"packages/sync-server/src/accounts/openid.js","isNew":false,"isDeleted":false,"isRenamed":false,"hunks":[{"oldStart":204,"oldLines":6,"newStart":204,"newLines":13,"lines":[{"type":"context","content":" return { error: 'invalid-or-expired-state' };","oldLineNumber":204,"newLineNumber":204},{"type":"context","content":" }","oldLineNumber":205,"newLineNumber":205},{"type":"context","content":"","oldLineNumber":206,"newLineNumber":206},{"type":"addition","content":" // Delete the pending request immediately to prevent replay attacks","newLineNumber":207},{"type":"addition","content":" // OAuth authorization codes can only be used once, so we must ensure","newLineNumber":208},{"type":"addition","content":" // this callback cannot be processed multiple times with the same code","newLineNumber":209},{"type":"addition","content":" accountDb.mutate('DELETE FROM pending_openid_requests WHERE state = ?', [","newLineNumber":210},{"type":"addition","content":" body.state,","newLineNumber":211},{"type":"addition","content":" ]);","newLineNumber":212},{"type":"addition","content":"","newLineNumber":213},{"type":"context","content":" const { code_verifier, return_url } = pendingRequest;","oldLineNumber":207,"newLineNumber":214},{"type":"context","content":"","oldLineNumber":208,"newLineNumber":215},{"type":"context","content":" try {","oldLineNumber":209,"newLineNumber":216}]}]}]</overallGitDiffFiles></rawResChunk> --> <!-- <rawResChunk><planSummary>- Delete the pending OpenID request immediately in packages/sync-server/src/accounts/openid.js (preventing replayed OAuth code redemption and AADSTS54005 errors) and update useEffect to run once in packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts (fixing repeated effect runs), resulting in single-use code processing and more reliable authentication. - Update OpenId callback handling: make the client effect in packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts run only once by adding an empty dependency array to the useEffect, and harden the server in packages/sync-server/src/accounts/openid.js by immediately deleting the matching pending_openid_requests row (by state) to prevent replay of the authorization code before processing.</planSummary></rawResChunk> -->
Author
Owner

@n-tropy247 commented on GitHub (Nov 5, 2025):

After redoing the entire OpenID configuration, I was able to get my own username to display correctly:

Image

But the other user is still seeing a failure despite existing in the User Directory:

Image

Additionally, the server is not logging a reused authorization code anymore, now its just throwing a 400 error:

Image

I configured OpenID through the GUI again, but here's the entry from the auth table (with redactions):

Image
<!-- gh-comment-id:3488937146 --> @n-tropy247 commented on GitHub (Nov 5, 2025): After redoing the entire OpenID configuration, I was able to get my own username to display correctly: <img width="2044" height="128" alt="Image" src="https://github.com/user-attachments/assets/c0fc719e-ebac-40c9-a07a-6fbeca8ae9e6" /> But the other user is still seeing a failure despite existing in the User Directory: <img width="521" height="112" alt="Image" src="https://github.com/user-attachments/assets/e9d9644e-6649-4c09-8db8-e43ca4c58de6" /> Additionally, the server is not logging a reused authorization code anymore, now its just throwing a 400 error: <img width="1045" height="67" alt="Image" src="https://github.com/user-attachments/assets/e1f9272c-7268-480b-a111-bf027a4122e5" /> I configured OpenID through the GUI again, but here's the entry from the auth table (with redactions): <img width="2534" height="50" alt="Image" src="https://github.com/user-attachments/assets/7c718ddd-9ff3-4b21-85a7-2c23b3a24d92" />
Author
Owner

@tabedzki commented on GitHub (Nov 11, 2025):

I believe that the email address associated with it has to already exist for the authentication to work. I can access it via "Server Online" in the top right -> User Directory -> Add new user.

Try that

<!-- gh-comment-id:3517930216 --> @tabedzki commented on GitHub (Nov 11, 2025): I believe that the email address associated with it has to already exist for the authentication to work. I can access it via "Server Online" in the top right -> User Directory -> Add new user. Try that
Author
Owner

@altwohill commented on GitHub (Dec 15, 2025):

I'm also seeing this issue. I have added the additional user into the directory, but they cannot log in - they get "openid-grant-failed"

<!-- gh-comment-id:3653149418 --> @altwohill commented on GitHub (Dec 15, 2025): I'm also seeing this issue. I have added the additional user into the directory, but they cannot log in - they get "openid-grant-failed"
Author
Owner

@AnthIste commented on GitHub (Jan 5, 2026):

I ran into a similar issue. You can override the default behaviour to automatically provision new users when they sign in (you will of course need to restrict access at the auth provider).

To enable automatic provisioning of users, set ACTUAL_USER_CREATION_MODE=login.

Example docker-compose.yml:

services:
  actual_server:
    image: docker.io/actualbudget/actual-server:latest
    ports:
      - '5006:5006'
    environment:
      - ACTUAL_PORT=5006
      - ACTUAL_USER_CREATION_MODE=login
    volumes:
      - ./data:/data
    healthcheck:
      test: ['CMD-SHELL', 'node src/scripts/health-check.js']
      interval: s
      timeout: 10s
      retries: 3
      start_period: 20s
    restart: unless-stopped

I also struggled to find the user management page. To access the user directory, click on the "Server Online" status to open the settings dropdown:

Image

References:

  1. actualbudget.org/docs/config
  2. packages/sync-server/src/load-config.js
<!-- gh-comment-id:3710661271 --> @AnthIste commented on GitHub (Jan 5, 2026): I ran into a similar issue. You can override the default behaviour to automatically provision new users when they sign in (you will of course need to restrict access at the auth provider). To enable automatic provisioning of users, set `ACTUAL_USER_CREATION_MODE=login`. Example `docker-compose.yml`: ```yaml services: actual_server: image: docker.io/actualbudget/actual-server:latest ports: - '5006:5006' environment: - ACTUAL_PORT=5006 - ACTUAL_USER_CREATION_MODE=login volumes: - ./data:/data healthcheck: test: ['CMD-SHELL', 'node src/scripts/health-check.js'] interval: s timeout: 10s retries: 3 start_period: 20s restart: unless-stopped ``` I also struggled to find the user management page. To access the user directory, click on the "Server Online" status to open the settings dropdown: <img width="296" height="204" alt="Image" src="https://github.com/user-attachments/assets/48b8af3a-a48b-494a-9e77-b2ea65b47be7" /> References: 1. [actualbudget.org/docs/config](https://actualbudget.org/docs/config/) 2. [packages/sync-server/src/load-config.js](https://github.com/actualbudget/actual/blob/f1faf456596e06a7e3d886994366ba95ecd8836a/packages/sync-server/src/load-config.js#L274C1-L279C5)
Author
Owner

@Itay1787 commented on GitHub (Jan 10, 2026):

Hi, I also have a similar problem.

I get {"status":"error","reason":"openid-grant-failed"}
And in the logs, I get

OpenID grant failed: OPError: expected 200 OK, got: 405 Method Not Allowed

    at processResponse (/app/node_modules/openid-client/lib/helpers/process_response.js:41:11)

    at Client.grant (/app/node_modules/openid-client/lib/client.js:1381:22)

    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)

    at async Client.callback (/app/node_modules/openid-client/lib/client.js:520:24)

    at async loginWithOpenIdFinalize (file:///app/src/accounts/openid.js:159:24)

    at async file:///app/src/app-openid.js:72:28 {

  error: 'expected 200 OK, got: 405 Method Not Allowed'

}

It passes the auth from Authentik, and I get the error from Actual, and I don't know how to proceed from here…

<!-- gh-comment-id:3733064097 --> @Itay1787 commented on GitHub (Jan 10, 2026): Hi, I also have a similar problem. I get `{"status":"error","reason":"openid-grant-failed"}` And in the logs, I get ``` OpenID grant failed: OPError: expected 200 OK, got: 405 Method Not Allowed at processResponse (/app/node_modules/openid-client/lib/helpers/process_response.js:41:11) at Client.grant (/app/node_modules/openid-client/lib/client.js:1381:22) at process.processTicksAndRejections (node:internal/process/task_queues:105:5) at async Client.callback (/app/node_modules/openid-client/lib/client.js:520:24) at async loginWithOpenIdFinalize (file:///app/src/accounts/openid.js:159:24) at async file:///app/src/app-openid.js:72:28 { error: 'expected 200 OK, got: 405 Method Not Allowed' } ``` It passes the auth from Authentik, and I get the error from Actual, and I don't know how to proceed from here…
Author
Owner

@DraviaVemal commented on GitHub (Jan 24, 2026):

I faced a similar issue and, after several combination tests, observed that the system appears to persist and reference the user OpenID/UUID as the primary identifier.

Observations

When I manually added the OpenID/UUID of a second user, login worked as expected.

When I added the actual email address, it did not match and resulted in an authentication error.

There is no documented environment configuration to control which claim/property is used for email matching.

Based on behavior, it seems the automatic property being mapped to id is the OpenID/UUID, not the email.

Workaround

Disabling pre-created users and enabling auto user creation on login resolves the issue.

Access is instead restricted on the Azure side.

ACTUAL_USER_CREATION_MODE=login Thanks @AnthIste for pointing this out

Question

Is there a supported configuration to explicitly define which claim (email vs OpenID/UUID) is used for user matching?

<!-- gh-comment-id:3793862940 --> @DraviaVemal commented on GitHub (Jan 24, 2026): I faced a similar issue and, after several combination tests, observed that the system appears to persist and reference the user OpenID/UUID as the primary identifier. ## Observations When I manually added the OpenID/UUID of a second user, login worked as expected. When I added the actual email address, it did not match and resulted in an authentication error. There is no documented environment configuration to control which claim/property is used for email matching. Based on behavior, it seems the automatic property being mapped to id is the OpenID/UUID, not the email. ## Workaround Disabling pre-created users and enabling auto user creation on login resolves the issue. Access is instead restricted on the Azure side. `ACTUAL_USER_CREATION_MODE=login` Thanks @AnthIste for pointing this out ## Question Is there a supported configuration to explicitly define which claim (email vs OpenID/UUID) is used for user matching?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/actual#28384