[GH-ISSUE #8179] okta(), auth0(), and keycloak() provider helpers hardcode userinfo URL instead of using discovery #19646

Closed
opened 2026-04-15 18:55:52 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @stychu on GitHub (Feb 26, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8179

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create an Okta custom authorization server (e.g., https://xxxxx.okta.com/oauth2/default)
  2. Configure the okta() helper with this issuer:
genericOAuth({
  config: [
    okta({
      clientId: process.env.OKTA_CLIENT_ID,
      clientSecret: process.env.OKTA_CLIENT_SECRET,
      issuer: "https://xxxxx.okta.com/oauth2/default",
    }),
  ],
})
  1. Attempt to sign in

Current vs. Expected behavior

Actual Behavior

The getUserInfo function hardcodes the URL as ${issuer}/oauth2/v1/userinfo, producing:

https://xxxxx.okta.com/oauth2/default/oauth2/v1/userinfo

The /oauth2/ segment is duplicated, and the request fails silently (returns null).

Expected Behavior

The userinfo endpoint should be resolved from the discovery document at https://xxxxx.okta.com/oauth2/default/.well-known/openid-configuration, which returns:

{
  "userinfo_endpoint": "https://xxxxx.okta.com/oauth2/default/v1/userinfo"
}

What version of Better Auth are you using?

1.4.19

System info

{
	"system": {
		"platform": "darwin",
		"arch": "arm64",
		"version": "Darwin Kernel Version 24.6.0: Wed Nov  5 21:33:58 PST 2025; root:xnu-11417.140.69.705.2~1/RELEASE_ARM64_T6000",
		"release": "24.6.0",
		"cpuCount": 10,
		"cpuModel": "Apple M1 Pro",
		"totalMemory": "32.00 GB",
		"freeMemory": "0.20 GB"
	},
	"node": {
		"version": "v22.12.0",
		"env": "development"
	},
	"packageManager": {
		"name": "npm",
		"version": "10.9.0"
	},
	"frameworks": [{
			"name": "react",
			"version": "^18.3.1"
		},
		{
			"name": "express",
			"version": "^4.21.1"
		}
	],
	"databases": null,
	"betterAuth": {
		"version": "^1.4.19",
		"config": {
			"session": {
				"cookieCache": {
					"enabled": true,
					"maxAge": 604800,
					"strategy": "jwe",
					"refreshCache": true
				}
			},
			"account": {
				"storeStateStrategy": "cookie",
				"storeAccountCookie": true
			},
			"plugins": [{
				"name": "generic-oauth",
				"config": {
					"id": "generic-oauth",
					"endpoints": {},
					"options": {
						"config": [{
							"providerId": "okta",
							"discoveryUrl": "https://xxxx.okta.com/.well-known/openid-configuration",
							"clientId": "[REDACTED]",
							"clientSecret": "[REDACTED]",
							"scopes": [
								"openid"
							],
							"pkce": true
						}]
					},
					"$ERROR_CODES": {
						"INVALID_OAUTH_CONFIGURATION": "Invalid OAuth configuration",
						"TOKEN_URL_NOT_FOUND": "Invalid OAuth configuration. Token URL not found.",
						"PROVIDER_CONFIG_NOT_FOUND": "No config found for provider",
						"PROVIDER_ID_REQUIRED": "Provider ID is required",
						"INVALID_OAUTH_CONFIG": "Invalid OAuth configuration.",
						"SESSION_REQUIRED": "Session is required",
						"ISSUER_MISMATCH": "OAuth issuer mismatch. The authorization server issuer does not match the expected value (RFC 9207).",
						"ISSUER_MISSING": "OAuth issuer parameter missing. The authorization server did not include the required iss parameter (RFC 9207)."
					}
				}
			}]
		}
	}
}

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
  plugins: [
    
genericOAuth({
  config: [
    okta({
      pkce: true,
      clientId: process.env.OKTA_CLIENT_ID,
      clientSecret: process.env.OKTA_CLIENT_SECRET,
      issuer: "https://xxxxx.okta.com/oauth2/default",
    }),
  ],
})

  ]
});

Additional context

The okta(), auth0(), and keycloak() provider helpers in the genericOAuth plugin hardcode the userinfo endpoint URL in their getUserInfo functions instead of resolving it from the OIDC discovery document (.well-known/openid-configuration).

The authorization_endpoint and token_endpoint are correctly resolved from discovery (they are not set in the returned config, so the framework fetches them from well-known). However, getUserInfo bypasses discovery entirely by hardcoding the URL. This is inconsistent and causes failures when the hardcoded path doesn't match the provider's actual userinfo endpoint.

Affected Providers

Provider Hardcoded userinfo URL Breaks when
okta() ${issuer}/oauth2/v1/userinfo Issuer is a custom authorization server (e.g., https://dev-xxx.okta.com/oauth2/default) — /oauth2/ is duplicated
auth0() https://${domain}/userinfo Less likely, but fragile if Auth0 changes URL structure or custom domains route differently
keycloak() ${issuer}/protocol/openid-connect/userinfo Less likely, but fragile if realm configuration or reverse proxy alters the path

Root Cause

All three provider helpers follow the same pattern — hardcoding the userinfo URL and providing a custom getUserInfo that bypasses discovery:

Okta (src/plugins/generic-oauth/providers/okta.ts):

const getUserInfo = async (tokens) => {
  const { data: profile, error } = await betterFetch(
    `${issuer}/oauth2/v1/userinfo`, // hardcoded
    { headers: { Authorization: `Bearer ${tokens.accessToken}` } }
  );
  if (error || !profile) return null; // fails silently
  // ...
};

Auth0 (src/plugins/generic-oauth/providers/auth0.ts):

const getUserInfo = async (tokens) => {
  const { data: profile, error } = await betterFetch(
    `https://${domain}/userinfo`, // hardcoded
    { headers: { Authorization: `Bearer ${tokens.accessToken}` } }
  );
  if (error || !profile) return null;
  // ...
};

Keycloak (src/plugins/generic-oauth/providers/keycloak.ts):

const getUserInfo = async (tokens) => {
  const { data: profile, error } = await betterFetch(
    `${issuer}/protocol/openid-connect/userinfo`, // hardcoded
    { headers: { Authorization: `Bearer ${tokens.accessToken}` } }
  );
  if (error || !profile) return null;
  // ...
};

Suggested Fix

Option A (preferred): Remove the custom getUserInfo from all three helpers. The genericOAuth framework already resolves userinfo_endpoint from discovery when no custom getUserInfo is provided — this is how it handles authorization_endpoint and token_endpoint. This would make all three endpoints consistent and eliminate the hardcoded URLs entirely.

Option B: Fetch the userinfo URL from the discovery document inside each getUserInfo:

const getUserInfo = async (tokens) => {
  const { data: discovery } = await betterFetch(discoveryUrl);
  const userInfoUrl = discovery?.userinfo_endpoint;
  if (!userInfoUrl) return null;
  const { data: profile, error } = await betterFetch(userInfoUrl, {
    headers: { Authorization: `Bearer ${tokens.accessToken}` },
  });
  // ...
};

Additional note

All three helpers also silently return null on error (if (error || !profile) return null), which makes debugging difficult. Consider logging or surfacing the error.

Environment

  • better-auth version: 1.4.19
  • Okta authorization server type: Custom (/oauth2/default)
Originally created by @stychu on GitHub (Feb 26, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8179 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create an Okta custom authorization server (e.g., `https://xxxxx.okta.com/oauth2/default`) 2. Configure the `okta()` helper with this issuer: ```ts genericOAuth({ config: [ okta({ clientId: process.env.OKTA_CLIENT_ID, clientSecret: process.env.OKTA_CLIENT_SECRET, issuer: "https://xxxxx.okta.com/oauth2/default", }), ], }) ``` 3. Attempt to sign in ### Current vs. Expected behavior ## Actual Behavior The `getUserInfo` function hardcodes the URL as `${issuer}/oauth2/v1/userinfo`, producing: ``` https://xxxxx.okta.com/oauth2/default/oauth2/v1/userinfo ``` The `/oauth2/` segment is duplicated, and the request fails silently (returns `null`). ## Expected Behavior The userinfo endpoint should be resolved from the discovery document at `https://xxxxx.okta.com/oauth2/default/.well-known/openid-configuration`, which returns: ```json { "userinfo_endpoint": "https://xxxxx.okta.com/oauth2/default/v1/userinfo" } ``` ### What version of Better Auth are you using? 1.4.19 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 24.6.0: Wed Nov 5 21:33:58 PST 2025; root:xnu-11417.140.69.705.2~1/RELEASE_ARM64_T6000", "release": "24.6.0", "cpuCount": 10, "cpuModel": "Apple M1 Pro", "totalMemory": "32.00 GB", "freeMemory": "0.20 GB" }, "node": { "version": "v22.12.0", "env": "development" }, "packageManager": { "name": "npm", "version": "10.9.0" }, "frameworks": [{ "name": "react", "version": "^18.3.1" }, { "name": "express", "version": "^4.21.1" } ], "databases": null, "betterAuth": { "version": "^1.4.19", "config": { "session": { "cookieCache": { "enabled": true, "maxAge": 604800, "strategy": "jwe", "refreshCache": true } }, "account": { "storeStateStrategy": "cookie", "storeAccountCookie": true }, "plugins": [{ "name": "generic-oauth", "config": { "id": "generic-oauth", "endpoints": {}, "options": { "config": [{ "providerId": "okta", "discoveryUrl": "https://xxxx.okta.com/.well-known/openid-configuration", "clientId": "[REDACTED]", "clientSecret": "[REDACTED]", "scopes": [ "openid" ], "pkce": true }] }, "$ERROR_CODES": { "INVALID_OAUTH_CONFIGURATION": "Invalid OAuth configuration", "TOKEN_URL_NOT_FOUND": "Invalid OAuth configuration. Token URL not found.", "PROVIDER_CONFIG_NOT_FOUND": "No config found for provider", "PROVIDER_ID_REQUIRED": "Provider ID is required", "INVALID_OAUTH_CONFIG": "Invalid OAuth configuration.", "SESSION_REQUIRED": "Session is required", "ISSUER_MISMATCH": "OAuth issuer mismatch. The authorization server issuer does not match the expected value (RFC 9207).", "ISSUER_MISSING": "OAuth issuer parameter missing. The authorization server did not include the required iss parameter (RFC 9207)." } } }] } } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ plugins: [ genericOAuth({ config: [ okta({ pkce: true, clientId: process.env.OKTA_CLIENT_ID, clientSecret: process.env.OKTA_CLIENT_SECRET, issuer: "https://xxxxx.okta.com/oauth2/default", }), ], }) ] }); ``` ### Additional context The `okta()`, `auth0()`, and `keycloak()` provider helpers in the `genericOAuth` plugin hardcode the userinfo endpoint URL in their `getUserInfo` functions instead of resolving it from the OIDC discovery document (`.well-known/openid-configuration`). The `authorization_endpoint` and `token_endpoint` are correctly resolved from discovery (they are not set in the returned config, so the framework fetches them from well-known). However, `getUserInfo` bypasses discovery entirely by hardcoding the URL. This is inconsistent and causes failures when the hardcoded path doesn't match the provider's actual userinfo endpoint. ### Affected Providers | Provider | Hardcoded userinfo URL | Breaks when | |----------|----------------------|-------------| | `okta()` | `${issuer}/oauth2/v1/userinfo` | Issuer is a custom authorization server (e.g., `https://dev-xxx.okta.com/oauth2/default`) — `/oauth2/` is duplicated | | `auth0()` | `https://${domain}/userinfo` | Less likely, but fragile if Auth0 changes URL structure or custom domains route differently | | `keycloak()` | `${issuer}/protocol/openid-connect/userinfo` | Less likely, but fragile if realm configuration or reverse proxy alters the path | ## Root Cause All three provider helpers follow the same pattern — hardcoding the userinfo URL and providing a custom `getUserInfo` that bypasses discovery: **Okta** (`src/plugins/generic-oauth/providers/okta.ts`): ```ts const getUserInfo = async (tokens) => { const { data: profile, error } = await betterFetch( `${issuer}/oauth2/v1/userinfo`, // hardcoded { headers: { Authorization: `Bearer ${tokens.accessToken}` } } ); if (error || !profile) return null; // fails silently // ... }; ``` **Auth0** (`src/plugins/generic-oauth/providers/auth0.ts`): ```ts const getUserInfo = async (tokens) => { const { data: profile, error } = await betterFetch( `https://${domain}/userinfo`, // hardcoded { headers: { Authorization: `Bearer ${tokens.accessToken}` } } ); if (error || !profile) return null; // ... }; ``` **Keycloak** (`src/plugins/generic-oauth/providers/keycloak.ts`): ```ts const getUserInfo = async (tokens) => { const { data: profile, error } = await betterFetch( `${issuer}/protocol/openid-connect/userinfo`, // hardcoded { headers: { Authorization: `Bearer ${tokens.accessToken}` } } ); if (error || !profile) return null; // ... }; ``` ## Suggested Fix **Option A (preferred):** Remove the custom `getUserInfo` from all three helpers. The `genericOAuth` framework already resolves `userinfo_endpoint` from discovery when no custom `getUserInfo` is provided — this is how it handles `authorization_endpoint` and `token_endpoint`. This would make all three endpoints consistent and eliminate the hardcoded URLs entirely. **Option B:** Fetch the userinfo URL from the discovery document inside each `getUserInfo`: ```ts const getUserInfo = async (tokens) => { const { data: discovery } = await betterFetch(discoveryUrl); const userInfoUrl = discovery?.userinfo_endpoint; if (!userInfoUrl) return null; const { data: profile, error } = await betterFetch(userInfoUrl, { headers: { Authorization: `Bearer ${tokens.accessToken}` }, }); // ... }; ``` ### Additional note All three helpers also silently return `null` on error (`if (error || !profile) return null`), which makes debugging difficult. Consider logging or surfacing the error. ## Environment - `better-auth` version: 1.4.19 - Okta authorization server type: Custom (`/oauth2/default`)
GiteaMirror added the lockedbug labels 2026-04-15 18:55:52 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Feb 26, 2026):

Thanks for the detailed analysis! Your findings are accurate. The Okta provider hardcodes the userinfo URL as ${issuer}/oauth2/v1/userinfo, and Auth0 and Keycloak follow the same pattern.

Interestingly, PR #7602 (merged 2026-01-25) added the /oauth2/ segment to the Okta provider to comply with Okta's API specs, but this inadvertently introduced the duplication issue for custom authorization servers like yours.

Workaround: You can override userInfoUrl directly in your config:

genericOAuth({
  config: [{
    ...okta({
      issuer: "https://xxxxx.okta.com/oauth2/default",
      clientId: process.env.OKTA_CLIENT_ID,
      clientSecret: process.env.OKTA_CLIENT_SECRET,
    }),
    userInfoUrl: "https://xxxxx.okta.com/oauth2/default/v1/userinfo"
  }]
})

Your suggested fix (Option A) makes sense - removing the custom getUserInfo and letting the framework resolve it from discovery would be the most consistent approach.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3969652262 --> @dosubot[bot] commented on GitHub (Feb 26, 2026): <!-- Answer --> Thanks for the detailed analysis! Your findings are accurate. The [Okta provider hardcodes the userinfo URL as `${issuer}/oauth2/v1/userinfo`](https://github.com/better-auth/better-auth/blob/074478e928624ca038a579817b47174ac15ef56a/packages/better-auth/src/plugins/generic-oauth/providers/okta.ts#L56), and [Auth0](https://github.com/better-auth/better-auth/blob/074478e928624ca038a579817b47174ac15ef56a/packages/better-auth/src/plugins/generic-oauth/providers/auth0.ts#L46-L93) and [Keycloak](https://github.com/better-auth/better-auth/blob/074478e928624ca038a579817b47174ac15ef56a/packages/better-auth/src/plugins/generic-oauth/providers/keycloak.ts#L46-L96) follow the same pattern. Interestingly, [PR #7602](https://github.com/better-auth/better-auth/pull/7602) (merged 2026-01-25) added the `/oauth2/` segment to the Okta provider to comply with Okta's API specs, but this inadvertently introduced the duplication issue for custom authorization servers like yours. **Workaround:** You can override `userInfoUrl` directly in your config: ```typescript genericOAuth({ config: [{ ...okta({ issuer: "https://xxxxx.okta.com/oauth2/default", clientId: process.env.OKTA_CLIENT_ID, clientSecret: process.env.OKTA_CLIENT_SECRET, }), userInfoUrl: "https://xxxxx.okta.com/oauth2/default/v1/userinfo" }] }) ``` Your suggested fix (Option A) makes sense - removing the custom `getUserInfo` and letting the framework resolve it from discovery would be the most consistent approach. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/501b35d5-06d6-4af7-9f31-e7935c8bc29c?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/501b35d5-06d6-4af7-9f31-e7935c8bc29c?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/501b35d5-06d6-4af7-9f31-e7935c8bc29c?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/501b35d5-06d6-4af7-9f31-e7935c8bc29c?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/501b35d5-06d6-4af7-9f31-e7935c8bc29c?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/501b35d5-06d6-4af7-9f31-e7935c8bc29c?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/501b35d5-06d6-4af7-9f31-e7935c8bc29c?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Thanks%20for%20the%20detailed%20analysis%21%20Your%20findings%20are%20accurate.%20The%20%5BOkta%20provider%20hardcodes%20the%20userinfo%20URL%20as%20%60%24%7Bissuer%7D/oauth2/v1/userinfo%60%5D%28https%3A//github.com/better-auth/better-auth/blob/074478e928624ca038a579817b47174ac15ef56a/packages/better-auth/src/plugins/generic-oauth/providers/okta.ts%23L56%29%2C%20and%20%5BAuth0%5D%28https%3A//github.com/better-auth/better-auth/blob/074478e928624ca038a579817b47174ac15ef56a/packages/better-auth/src/plugins/generic-oauth/providers/auth0.ts%23L46-L93%29%20and%20%5BKeycloak%5D%28https%3A//github.com/better-auth/better-auth/blob/074478e928624ca038a579817b47174ac15ef56a/packages/better-auth/src/plugins/generic-oauth/providers/keycloak.ts%23L46-L96%29%20follow%20the%20same%20pattern.%0A%0AInterestingly%2C%20%5BPR%20%237602%5D%28https%3A//github.com/better-auth/better-auth/pull/7602%29%20%28merged%202026-01-25%29%20added%20the%20%60/oauth2/%60%20segment%20to%20the%20Okta%20provider%20to%20comply%20with%20Okta%27s%20API%20specs%2C%20but%20this%20inadvertently%20introduced%20the%20duplication%20issue%20for%20custom%20authorization%20servers%20like%20yours.%0A%0A%2A%2AWorkaround%3A%2A%2A%20You%20can%20override%20%60userInfoUrl%60%20directly%20in%20your%20config%3A%0A%0A%60%60%60typescript%0AgenericOAuth%28%7B%0A%20%20config%3A%20%5B%7B%0A%20%20%20%20...okta%28%7B%0A%20%20%20%20%20%20issuer%3A%20%22https%3A//xxxxx.okta.com/oauth2/default%22%2C%0A%20%20%20%20%20%20clientId%3A%20process.env.OKTA_CLIENT_ID%2C%0A%20%20%20%20%20%20clientSecret%3A%20process.env.OKTA_CLIENT_SECRET%2C%0A%20%20%20%20%7D%29%2C%0A%20%20%20%20userInfoUrl%3A%20%22https%3A//xxxxx.okta.com/oauth2/default/v1/userinfo%22%0A%20%20%7D%5D%0A%7D%29%0A%60%60%60%0A%0AYour%20suggested%20fix%20%28Option%20A%29%20makes%20sense%20-%20removing%20the%20custom%20%60getUserInfo%60%20and%20letting%20the%20framework%20resolve%20it%20from%20discovery%20would%20be%20the%20most%20consistent%20approach.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/8179)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19646