From e7eb45b065903f5fccddae491696cb069814a3c8 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Tue, 19 May 2026 18:18:43 +0100 Subject: [PATCH] feat(oauth): per-request `additionalParams` and `loginHint` (#9305) --- .changeset/oauth-additional-params.md | 39 +++ .cspell/company-names.txt | 4 +- docs/content/docs/authentication/cognito.mdx | 13 + docs/content/docs/authentication/google.mdx | 16 ++ .../content/docs/authentication/microsoft.mdx | 12 + docs/content/docs/concepts/oauth.mdx | 24 ++ .../src/api/routes/account.test.ts | 41 +++ .../better-auth/src/api/routes/account.ts | 19 ++ .../better-auth/src/api/routes/sign-in.ts | 8 + .../src/plugins/generic-oauth/index.ts | 5 +- .../__snapshots__/open-api.test.ts.snap | 39 ++- .../src/plugins/open-api/generator.ts | 20 ++ packages/better-auth/src/social.test.ts | 28 +++ .../core/src/oauth2/authorization-params.ts | 28 +++ .../oauth2/create-authorization-url.test.ts | 65 +++++ .../src/oauth2/create-authorization-url.ts | 25 +- packages/core/src/oauth2/index.ts | 7 +- packages/core/src/oauth2/oauth-provider.ts | 7 + packages/core/src/social-providers/apple.ts | 8 +- .../core/src/social-providers/atlassian.ts | 9 +- packages/core/src/social-providers/cognito.ts | 27 +- packages/core/src/social-providers/discord.ts | 38 +-- packages/core/src/social-providers/dropbox.ts | 12 +- .../core/src/social-providers/facebook.ts | 17 +- packages/core/src/social-providers/figma.ts | 9 +- packages/core/src/social-providers/github.ts | 2 + packages/core/src/social-providers/gitlab.ts | 2 + packages/core/src/social-providers/google.ts | 2 + .../core/src/social-providers/huggingface.ts | 9 +- packages/core/src/social-providers/kakao.ts | 3 +- packages/core/src/social-providers/kick.ts | 9 +- packages/core/src/social-providers/line.ts | 2 + packages/core/src/social-providers/linear.ts | 9 +- .../core/src/social-providers/linkedin.ts | 2 + .../social-providers/microsoft-entra-id.ts | 1 + packages/core/src/social-providers/naver.ts | 3 +- packages/core/src/social-providers/notion.ts | 9 +- packages/core/src/social-providers/paybin.ts | 2 + packages/core/src/social-providers/paypal.ts | 8 +- packages/core/src/social-providers/polar.ts | 9 +- packages/core/src/social-providers/railway.ts | 9 +- packages/core/src/social-providers/reddit.ts | 3 +- packages/core/src/social-providers/roblox.ts | 27 +- .../core/src/social-providers/salesforce.ts | 9 +- packages/core/src/social-providers/slack.ts | 24 +- .../social-providers/social-providers.test.ts | 236 ++++++++++++++++++ packages/core/src/social-providers/spotify.ts | 9 +- packages/core/src/social-providers/tiktok.ts | 31 ++- packages/core/src/social-providers/twitch.ts | 3 +- packages/core/src/social-providers/twitter.ts | 1 + packages/core/src/social-providers/vercel.ts | 9 +- packages/core/src/social-providers/vk.ts | 9 +- packages/core/src/social-providers/wechat.ts | 10 +- packages/core/src/social-providers/zoom.ts | 34 ++- packages/sso/src/oidc.test.ts | 20 ++ packages/sso/src/routes/sso.ts | 82 ++++-- packages/sso/src/saml.test.ts | 12 + 57 files changed, 999 insertions(+), 121 deletions(-) create mode 100644 .changeset/oauth-additional-params.md create mode 100644 packages/core/src/oauth2/authorization-params.ts create mode 100644 packages/core/src/oauth2/create-authorization-url.test.ts create mode 100644 packages/core/src/social-providers/social-providers.test.ts diff --git a/.changeset/oauth-additional-params.md b/.changeset/oauth-additional-params.md new file mode 100644 index 0000000000..45267a55f7 --- /dev/null +++ b/.changeset/oauth-additional-params.md @@ -0,0 +1,39 @@ +--- +"better-auth": minor +"@better-auth/core": minor +"@better-auth/sso": minor +--- + +feat(oauth): per-request `additionalParams` and `loginHint` parity across `signIn.social`, `linkSocial`, and `signIn.sso` + +Unified escape hatch for customizing the provider authorization URL on a per-request basis. Previously, dynamic parameters like Google's `access_type=offline` / `prompt=consent`, Cognito's `identity_provider=Google`, or Microsoft's `domain_hint` could only be set as static server configuration. + +### New capabilities + +- `signIn.social`, `linkSocial`, and `signIn.sso` accept `additionalParams: Record`. Values are appended to the authorization URL as query parameters. +- `linkSocial` also accepts `loginHint`, matching the surface of `signIn.social` and `signIn.sso`. +- `OAuthProvider.createAuthorizationURL` gains `additionalParams` in its input contract; every built-in provider forwards it to the shared helper. +- Generic-OAuth providers merge call-time `additionalParams` with the config-level `authorizationUrlParams`; call-time wins on key collision. +- Cognito exposes a typed `identityProvider?: string` config option that maps to the `identity_provider` query parameter, avoiding magic strings. + +### Security + +- The shared `createAuthorizationURL` helper silently drops any caller-supplied key in `RESERVED_AUTHORIZATION_PARAMS` (`state`, `client_id`, `redirect_uri`, `response_type`, `code_challenge`, `code_challenge_method`, `scope`). The request-body Zod schema rejects the same keys with 400, so misuse is visible at the edge rather than silently overriding security-critical parameters. +- Providers that use non-standard client identifiers (`wechat` → `appid`, `tiktok` → `client_key`) additionally filter those keys so a caller cannot swap the configured OAuth app. +- Provider protocol constants that are required for the integration to function (`atlassian` → `audience`, `notion` → `owner`) are merged last so caller-supplied `additionalParams` cannot override them. Configured defaults that represent operator intent (e.g. Google `include_granted_scopes`, Cognito `identityProvider`) remain caller-overridable. +- `signIn.sso` rejects `additionalParams` with 400 when the resolved provider is SAML; the SAML AuthnRequest is signed and cannot carry caller-supplied query parameters, so silently dropping them would mislead integrators. + +### OpenAPI + +- Added `ZodRecord` handling to the OpenAPI generator so `z.record()` fields emit `type: object` with typed `additionalProperties`. Incidentally fixes a long-standing bug where `additionalData` was rendered as `type: string`. + +### Refactors + +- `discord`, `roblox`, `zoom`, and `slack` providers now delegate to the shared `createAuthorizationURL` helper and inherit its RFC behavior and reserved-key guard. +- `tiktok` and `wechat` keep their manual URL construction (non-standard OAuth2 parameter names and URL fragment requirements) but thread `additionalParams` with the same reserved-key filter. + +Closes #2351. +Closes #5441. +Closes #5592. +Closes #5604. +Supersedes #4992 and #5443. diff --git a/.cspell/company-names.txt b/.cspell/company-names.txt index 0203272d6d..db460a7607 100644 --- a/.cspell/company-names.txt +++ b/.cspell/company-names.txt @@ -27,4 +27,6 @@ Razorpay razorpay payu PayU -Mikro \ No newline at end of file +contoso +Contoso +Mikro diff --git a/docs/content/docs/authentication/cognito.mdx b/docs/content/docs/authentication/cognito.mdx index 372c83e2e2..26f9991e94 100644 --- a/docs/content/docs/authentication/cognito.mdx +++ b/docs/content/docs/authentication/cognito.mdx @@ -41,6 +41,7 @@ description: Amazon Cognito provider setup and usage. domain: process.env.COGNITO_DOMAIN as string, // e.g. "your-app.auth.us-east-1.amazoncognito.com" [!code highlight] region: process.env.COGNITO_REGION as string, // e.g. "us-east-1" [!code highlight] userPoolId: process.env.COGNITO_USERPOOL_ID as string, // [!code highlight] + identityProvider: "Google", // optional: skip the hosted UI picker [!code highlight] }, }, }) @@ -64,6 +65,17 @@ description: Amazon Cognito provider setup and usage. } ``` + To override the preselected identity provider per call (for example, to render + a "Sign in with Okta" button that bypasses the Cognito picker), pass the + `identity_provider` key through `additionalParams`: + + ```ts + await authClient.signIn.social({ + provider: "cognito", + additionalParams: { identity_provider: "Okta" }, + }) + ``` + ### Additional Options: * `scope`: Additional OAuth2 scopes to request (combined with default permissions). @@ -76,6 +88,7 @@ description: Amazon Cognito provider setup and usage. * `aws.cognito.signin.user.admin`: Grants access to Cognito-specific APIs * Note: You must configure the scopes in your Cognito App Client settings. [available scopes](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html#token-endpoint-userinfo) * `getUserInfo`: Custom function to retrieve user information from the Cognito UserInfo endpoint. + * `identityProvider`: Preselect a Cognito-configured identity provider to skip the hosted-UI picker. Valid values are `"COGNITO"`, a SAML/OIDC provider name on the User Pool, or one of `"Google"`, `"Facebook"`, `"LoginWithAmazon"`, `"SignInWithApple"`. Per-call overrides via `additionalParams.identity_provider` take precedence. For more information about Amazon Cognito's scopes and API capabilities, refer to the [official documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html?utm_source). diff --git a/docs/content/docs/authentication/google.mdx b/docs/content/docs/authentication/google.mdx index dd80966b33..d486d389f6 100644 --- a/docs/content/docs/authentication/google.mdx +++ b/docs/content/docs/authentication/google.mdx @@ -210,3 +210,19 @@ socialProviders: { already authorized your app, you must have them revoke your app's access in their Google account settings, then re-authorize. + +### Restricting to a Google Workspace domain + +To limit sign-in to accounts in a specific Google Workspace domain, forward the +`hd` parameter on the call. This mirrors Google's `hd` authorization-URL +parameter and is resolved per request: + +```ts +await authClient.signIn.social({ + provider: "google", + additionalParams: { hd: "example.com" }, +}); +``` + +You can also set `hd` at the provider config level if the restriction is static +for your application. diff --git a/docs/content/docs/authentication/microsoft.mdx b/docs/content/docs/authentication/microsoft.mdx index 6df68b5ab1..9be7a518bd 100644 --- a/docs/content/docs/authentication/microsoft.mdx +++ b/docs/content/docs/authentication/microsoft.mdx @@ -64,3 +64,15 @@ const signIn = async () => { }); }; ``` + +### Preselecting an organizational domain + +To skip the account picker for users you know belong to a given tenant or +domain, forward Microsoft's `domain_hint` parameter on the call: + +```ts +await authClient.signIn.social({ + provider: "microsoft", + additionalParams: { domain_hint: "contoso.com" }, +}); +``` diff --git a/docs/content/docs/concepts/oauth.mdx b/docs/content/docs/concepts/oauth.mdx index 5ffb1167cb..3fc3b9fc45 100644 --- a/docs/content/docs/concepts/oauth.mdx +++ b/docs/content/docs/concepts/oauth.mdx @@ -132,6 +132,30 @@ const requestAdditionalScopes = async () => { Make sure you're running Better Auth version 1.2.7 or later. Earlier versions (like 1.2.2) may show a "Social account already linked" error when trying to link with an existing provider for additional scopes. +### Customizing the Authorization URL + +To forward extra query parameters to the provider's authorization endpoint, pass `additionalParams` when calling `signIn.social` or `linkSocial`. The values are applied after the framework has written the OAuth state, PKCE challenge, and `redirect_uri`; the reserved keys `state`, `client_id`, `redirect_uri`, `response_type`, `code_challenge`, `code_challenge_method`, and `scope` are rejected with a 400 so a caller cannot break the callback correlation. + +```ts +await authClient.signIn.social({ + provider: "cognito", + additionalParams: { + identity_provider: "Google", // skip the Cognito hosted-UI picker + }, +}); + +await authClient.linkSocial({ + provider: "google", + loginHint: "user@example.com", + additionalParams: { + access_type: "offline", + prompt: "consent", + }, +}); +``` + +The provider's own baked-in query parameters (for example Google's `include_granted_scopes=true`, Facebook's `config_id`, Cognito's `identity_provider` when set via `identityProvider`) are merged with call-time `additionalParams`; the call-time value wins on key collisions. + ### Passing Additional Data Through OAuth Flow Better Auth allows you to pass additional data through the OAuth flow without storing it in the database. This is useful for scenarios like tracking referral codes, analytics sources, or other temporary data that should be processed during authentication but not persisted. diff --git a/packages/better-auth/src/api/routes/account.test.ts b/packages/better-auth/src/api/routes/account.test.ts index c2cf107cff..18fe04c26d 100644 --- a/packages/better-auth/src/api/routes/account.test.ts +++ b/packages/better-auth/src/api/routes/account.test.ts @@ -251,6 +251,47 @@ describe("account", async () => { }); }); + /** + * @see https://github.com/better-auth/better-auth/issues/2351 + */ + it("should forward additionalParams and loginHint to the authorization URL", async () => { + const { runWithUser: runWithClient2 } = await signInWithTestUser(); + await runWithClient2(async () => { + const linkAccountRes = await client.linkSocial({ + provider: "google", + callbackURL: "/callback", + loginHint: "user@example.com", + additionalParams: { + access_type: "offline", + prompt: "consent", + }, + }); + + expect(linkAccountRes.data).toMatchObject({ + url: expect.stringContaining("google.com"), + redirect: true, + }); + const url = new URL(linkAccountRes.data!.url); + expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("prompt")).toBe("consent"); + expect(url.searchParams.get("login_hint")).toBe("user@example.com"); + }); + }); + + it("should reject additionalParams that override reserved OAuth params", async () => { + const { runWithUser: runWithClient2 } = await signInWithTestUser(); + await runWithClient2(async () => { + const linkAccountRes = await client.linkSocial({ + provider: "google", + callbackURL: "/callback", + additionalParams: { + state: "attacker-controlled", + }, + }); + expect(linkAccountRes.error?.status).toBe(400); + }); + }); + it("should link second account from the same provider", async () => { const { runWithUser: runWithClient2 } = await signInWithTestUser(); await runWithClient2(async (headers) => { diff --git a/packages/better-auth/src/api/routes/account.ts b/packages/better-auth/src/api/routes/account.ts index fd0c175d69..7caf82f929 100644 --- a/packages/better-auth/src/api/routes/account.ts +++ b/packages/better-auth/src/api/routes/account.ts @@ -2,6 +2,7 @@ import { createAuthEndpoint } from "@better-auth/core/api"; import type { Account } from "@better-auth/core/db"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import type { OAuth2Tokens } from "@better-auth/core/oauth2"; +import { additionalAuthorizationParamsSchema } from "@better-auth/core/oauth2"; import { SocialProviderListEnum } from "@better-auth/core/social-providers"; import * as z from "zod"; @@ -170,6 +171,22 @@ export const linkSocialAccount = createAuthEndpoint( "Disable automatic redirection to the provider. Useful for handling the redirection yourself", }) .optional(), + /** + * The login hint to forward to the provider authorization endpoint. + */ + loginHint: z + .string() + .meta({ + description: + "The login hint to use for the authorization code request", + }) + .optional(), + /** + * Extra query parameters to append to the provider authorization URL. + * Reserved OAuth keys (state, client_id, redirect_uri, response_type, + * code_challenge, code_challenge_method, scope) are rejected. + */ + additionalParams: additionalAuthorizationParamsSchema, /** * Any additional data to pass through the oauth flow. */ @@ -372,6 +389,8 @@ export const linkSocialAccount = createAuthEndpoint( codeVerifier: state.codeVerifier, redirectURI: `${c.context.baseURL}/callback/${provider.id}`, scopes: c.body.scopes, + loginHint: c.body.loginHint, + additionalParams: c.body.additionalParams, }); if (!c.body.disableRedirect) { diff --git a/packages/better-auth/src/api/routes/sign-in.ts b/packages/better-auth/src/api/routes/sign-in.ts index a0e7c7893f..878e551f96 100644 --- a/packages/better-auth/src/api/routes/sign-in.ts +++ b/packages/better-auth/src/api/routes/sign-in.ts @@ -2,6 +2,7 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import type { User } from "@better-auth/core/db"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; +import { additionalAuthorizationParamsSchema } from "@better-auth/core/oauth2"; import { SocialProviderListEnum } from "@better-auth/core/social-providers"; import * as z from "zod"; import { getAwaitableValue } from "../../context/helpers"; @@ -165,6 +166,12 @@ const socialSignInBodySchema = z.object({ description: "The login hint to use for the authorization code request", }) .optional(), + /** + * Extra query parameters to append to the provider authorization URL. + * Reserved OAuth keys (state, client_id, redirect_uri, response_type, + * code_challenge, code_challenge_method, scope) are rejected. + */ + additionalParams: additionalAuthorizationParamsSchema, /** * Additional data to be passed through the OAuth flow */ @@ -345,6 +352,7 @@ export const signInSocial = () => redirectURI: `${c.context.baseURL}/callback/${provider.id}`, scopes: c.body.scopes, loginHint: c.body.loginHint, + additionalParams: c.body.additionalParams, }); if (!c.body.disableRedirect) { diff --git a/packages/better-auth/src/plugins/generic-oauth/index.ts b/packages/better-auth/src/plugins/generic-oauth/index.ts index 74e5cb46e6..b2882c447f 100644 --- a/packages/better-auth/src/plugins/generic-oauth/index.ts +++ b/packages/better-auth/src/plugins/generic-oauth/index.ts @@ -282,7 +282,10 @@ export const genericOAuth = ( accessType: c.accessType, responseType: c.responseType, responseMode: c.responseMode, - additionalParams: c.authorizationUrlParams, + additionalParams: { + ...(c.authorizationUrlParams ?? {}), + ...(data.additionalParams ?? {}), + }, loginHint: data.loginHint, }); }, diff --git a/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap b/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap index 3bd8900c96..2a2e624947 100644 --- a/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap +++ b/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap @@ -1876,9 +1876,23 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = ` "schema": { "properties": { "additionalData": { + "additionalProperties": { + "description": undefined, + }, "description": undefined, "type": [ - "string", + "object", + "null", + ], + }, + "additionalParams": { + "additionalProperties": { + "description": undefined, + "type": "string", + }, + "description": "Extra query parameters to append to the provider authorization URL (e.g. Cognito identity_provider, Google hd).", + "type": [ + "object", "null", ], }, @@ -1947,6 +1961,13 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = ` "null", ], }, + "loginHint": { + "description": "The login hint to use for the authorization code request", + "type": [ + "string", + "null", + ], + }, "provider": { "description": undefined, "type": "string", @@ -3938,9 +3959,23 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = ` "schema": { "properties": { "additionalData": { + "additionalProperties": { + "description": undefined, + }, "description": undefined, "type": [ - "string", + "object", + "null", + ], + }, + "additionalParams": { + "additionalProperties": { + "description": undefined, + "type": "string", + }, + "description": "Extra query parameters to append to the provider authorization URL (e.g. Cognito identity_provider, Google hd).", + "type": [ + "object", "null", ], }, diff --git a/packages/better-auth/src/plugins/open-api/generator.ts b/packages/better-auth/src/plugins/open-api/generator.ts index e1eb4f2751..15febb0aca 100644 --- a/packages/better-auth/src/plugins/open-api/generator.ts +++ b/packages/better-auth/src/plugins/open-api/generator.ts @@ -225,6 +225,26 @@ function processZodType(zodType: z.ZodType): any { default: defaultValue, }; } + // record: map to an open-ended object with typed values + if (zodType instanceof z.ZodRecord) { + const valueType = (zodType as any)._zod?.def?.valueType; + const additionalProperties = + valueType instanceof z.ZodType + ? processZodType(valueType as z.ZodType) + : {}; + return { + type: "object", + additionalProperties, + description: (zodType as any).description, + }; + } + + // unconstrained value types: emit an empty schema so consumers don't + // infer a narrower shape than the runtime accepts + if (zodType instanceof z.ZodAny || zodType instanceof z.ZodUnknown) { + return { description: (zodType as any).description }; + } + // object unwrapping if (zodType instanceof z.ZodObject) { const shape = (zodType as any).shape; diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index fc93cd3767..574ded1128 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -229,6 +229,34 @@ describe("Social Providers", async (c) => { }); }); + /** + * @see https://github.com/better-auth/better-auth/issues/5441 + */ + it("should forward additionalParams to the authorization URL", async () => { + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + additionalParams: { + access_type: "offline", + hd: "example.com", + }, + }); + const url = new URL(signInRes.data!.url!); + expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("hd")).toBe("example.com"); + }); + + it("should reject additionalParams that collide with reserved OAuth params", async () => { + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + additionalParams: { + redirect_uri: "https://attacker.example/callback", + }, + }); + expect(signInRes.error?.status).toBe(400); + }); + it("should be able to sign in with social providers", async () => { const headers = new Headers(); const signInRes = await client.signIn.social({ diff --git a/packages/core/src/oauth2/authorization-params.ts b/packages/core/src/oauth2/authorization-params.ts new file mode 100644 index 0000000000..42942a214c --- /dev/null +++ b/packages/core/src/oauth2/authorization-params.ts @@ -0,0 +1,28 @@ +import * as z from "zod"; +import { + RESERVED_AUTHORIZATION_PARAMS, + RESERVED_AUTHORIZATION_PARAMS_SET, +} from "./create-authorization-url"; + +/** + * Zod schema for the `additionalParams` field on social sign-in and + * account-linking request bodies. Rejects any key reserved by the + * authorization-URL builder (see `RESERVED_AUTHORIZATION_PARAMS`), so + * a caller cannot overwrite `state`, PKCE, `redirect_uri`, etc. + */ +export const additionalAuthorizationParamsSchema = z + .record(z.string(), z.string()) + .refine( + (value) => + !Object.keys(value).some((key) => + RESERVED_AUTHORIZATION_PARAMS_SET.has(key), + ), + { + message: `additionalParams cannot include reserved OAuth parameters: ${RESERVED_AUTHORIZATION_PARAMS.join(", ")}`, + }, + ) + .meta({ + description: + "Extra query parameters to append to the provider authorization URL (e.g. Cognito identity_provider, Google hd).", + }) + .optional(); diff --git a/packages/core/src/oauth2/create-authorization-url.test.ts b/packages/core/src/oauth2/create-authorization-url.test.ts new file mode 100644 index 0000000000..30a991a3d4 --- /dev/null +++ b/packages/core/src/oauth2/create-authorization-url.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + createAuthorizationURL, + RESERVED_AUTHORIZATION_PARAMS, +} from "./create-authorization-url"; + +const baseInput = { + id: "test", + options: { clientId: "client-123", clientSecret: "s", redirectURI: "" }, + authorizationEndpoint: "https://idp.example/authorize", + state: "state-xyz", + redirectURI: "https://app.example/callback", + scopes: ["openid"], +}; + +describe("createAuthorizationURL", () => { + it("appends additionalParams as query string entries", async () => { + const url = await createAuthorizationURL({ + ...baseInput, + additionalParams: { + identity_provider: "Google", + hd: "example.com", + }, + }); + expect(url.searchParams.get("identity_provider")).toBe("Google"); + expect(url.searchParams.get("hd")).toBe("example.com"); + }); + + it("silently drops reserved OAuth params supplied via additionalParams", async () => { + const url = await createAuthorizationURL({ + ...baseInput, + additionalParams: { + state: "attacker-controlled", + client_id: "attacker", + redirect_uri: "https://attacker.example/callback", + response_type: "token", + code_challenge: "malicious", + code_challenge_method: "plain", + scope: "admin", + identity_provider: "OktaSSO", + }, + }); + expect(url.searchParams.get("state")).toBe("state-xyz"); + expect(url.searchParams.get("client_id")).toBe("client-123"); + expect(url.searchParams.get("redirect_uri")).toBe( + "https://app.example/callback", + ); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("code_challenge")).toBeNull(); + expect(url.searchParams.get("scope")).toBe("openid"); + expect(url.searchParams.get("identity_provider")).toBe("OktaSSO"); + }); + + it("exposes the reserved param list for downstream consumers", () => { + expect(RESERVED_AUTHORIZATION_PARAMS).toEqual([ + "state", + "client_id", + "redirect_uri", + "response_type", + "code_challenge", + "code_challenge_method", + "scope", + ]); + }); +}); diff --git a/packages/core/src/oauth2/create-authorization-url.ts b/packages/core/src/oauth2/create-authorization-url.ts index 905229415e..1697883c85 100644 --- a/packages/core/src/oauth2/create-authorization-url.ts +++ b/packages/core/src/oauth2/create-authorization-url.ts @@ -2,6 +2,26 @@ import type { AwaitableFunction } from "../types"; import type { ProviderOptions } from "./index"; import { generateCodeChallenge, getPrimaryClientId } from "./utils"; +/** + * Query-parameter names that are populated by the framework as part of the + * authorization request and must not be overridden by caller-supplied + * `additionalParams`. Overriding `state`, PKCE, or `redirect_uri` would + * break the callback correlation and session pinning guarantees. + */ +export const RESERVED_AUTHORIZATION_PARAMS = [ + "state", + "client_id", + "redirect_uri", + "response_type", + "code_challenge", + "code_challenge_method", + "scope", +] as const; + +export const RESERVED_AUTHORIZATION_PARAMS_SET: ReadonlySet = new Set( + RESERVED_AUTHORIZATION_PARAMS, +); + export async function createAuthorizationURL({ id, options, @@ -82,9 +102,10 @@ export async function createAuthorizationURL({ ); } if (additionalParams) { - Object.entries(additionalParams).forEach(([key, value]) => { + for (const [key, value] of Object.entries(additionalParams)) { + if (RESERVED_AUTHORIZATION_PARAMS_SET.has(key)) continue; url.searchParams.set(key, value); - }); + } } return url; } diff --git a/packages/core/src/oauth2/index.ts b/packages/core/src/oauth2/index.ts index 0015b1d663..3b2059238f 100644 --- a/packages/core/src/oauth2/index.ts +++ b/packages/core/src/oauth2/index.ts @@ -1,3 +1,4 @@ +export { additionalAuthorizationParamsSchema } from "./authorization-params"; export { decodeBasicCredentials, encodeBasicCredentials, @@ -20,7 +21,11 @@ export { clientCredentialsToken, clientCredentialsTokenRequest, } from "./client-credentials-token"; -export { createAuthorizationURL } from "./create-authorization-url"; +export { + createAuthorizationURL, + RESERVED_AUTHORIZATION_PARAMS, + RESERVED_AUTHORIZATION_PARAMS_SET, +} from "./create-authorization-url"; export type { OAuth2Tokens, OAuth2UserInfo, diff --git a/packages/core/src/oauth2/oauth-provider.ts b/packages/core/src/oauth2/oauth-provider.ts index 746986878b..e11b670d73 100644 --- a/packages/core/src/oauth2/oauth-provider.ts +++ b/packages/core/src/oauth2/oauth-provider.ts @@ -35,6 +35,13 @@ export interface OAuthProvider< redirectURI: string; display?: string | undefined; loginHint?: string | undefined; + /** + * Extra query parameters to append to the authorization URL. + * Providers forward these to the shared `createAuthorizationURL` helper, + * which drops any keys present in `RESERVED_AUTHORIZATION_PARAMS` + * before applying them. + */ + additionalParams?: Record | undefined; }) => Awaitable; name: string; validateAuthorizationCode: (data: { diff --git a/packages/core/src/social-providers/apple.ts b/packages/core/src/social-providers/apple.ts index 4452039dde..462161d1c5 100644 --- a/packages/core/src/social-providers/apple.ts +++ b/packages/core/src/social-providers/apple.ts @@ -82,7 +82,12 @@ export const apple = (options: AppleOptions) => { return { id: "apple", name: "Apple", - async createAuthorizationURL({ state, scopes, redirectURI }) { + async createAuthorizationURL({ + state, + scopes, + redirectURI, + additionalParams, + }) { if (!getPrimaryClientId(options.clientId) || !options.clientSecret) { logger.error( "Client ID and client secret are required for Apple. Make sure to provide them in the options.", @@ -101,6 +106,7 @@ export const apple = (options: AppleOptions) => { redirectURI, responseMode: "form_post", responseType: "code id_token", + additionalParams, }); return url; }, diff --git a/packages/core/src/social-providers/atlassian.ts b/packages/core/src/social-providers/atlassian.ts index 43c0177bcf..9e3fe924d1 100644 --- a/packages/core/src/social-providers/atlassian.ts +++ b/packages/core/src/social-providers/atlassian.ts @@ -35,7 +35,13 @@ export const atlassian = (options: AtlassianOptions) => { id: "atlassian", name: "Atlassian", - async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + async createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { if (!options.clientId || !options.clientSecret) { logger.error("Client Id and Secret are required for Atlassian"); throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED"); @@ -59,6 +65,7 @@ export const atlassian = (options: AtlassianOptions) => { codeVerifier, redirectURI, additionalParams: { + ...(additionalParams ?? {}), audience: "api.atlassian.com", }, prompt: options.prompt, diff --git a/packages/core/src/social-providers/cognito.ts b/packages/core/src/social-providers/cognito.ts index 60286a9be1..1d9b5202a5 100644 --- a/packages/core/src/social-providers/cognito.ts +++ b/packages/core/src/social-providers/cognito.ts @@ -42,6 +42,19 @@ export interface CognitoOptions extends ProviderOptions { region: string; userPoolId: string; requireClientSecret?: boolean | undefined; + /** + * Skip the Cognito hosted-UI identity-provider picker by preselecting an + * IdP (maps to the `identity_provider` query parameter on the authorize + * request). Accepts `"COGNITO"`, a SAML/OIDC provider name configured on + * the User Pool, or one of the social providers (`"Google"`, `"Facebook"`, + * `"LoginWithAmazon"`, `"SignInWithApple"`). + * + * Per-request overrides via `signIn.social({ additionalParams: { identity_provider } })` + * take precedence over this value. + * + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/authorization-endpoint.html + */ + identityProvider?: string | undefined; } export const cognito = (options: CognitoOptions) => { @@ -60,7 +73,13 @@ export const cognito = (options: CognitoOptions) => { return { id: "cognito", name: "Cognito", - async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + async createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { if (!getPrimaryClientId(options.clientId)) { logger.error( "ClientId is required for Amazon Cognito. Make sure to provide them in the options.", @@ -91,6 +110,12 @@ export const cognito = (options: CognitoOptions) => { codeVerifier, redirectURI, prompt: options.prompt, + additionalParams: { + ...(options.identityProvider + ? { identity_provider: options.identityProvider } + : {}), + ...(additionalParams ?? {}), + }, }); // AWS Cognito requires scopes to be encoded with %20 instead of + // URLSearchParams encodes spaces as + by default, so we need to fix this diff --git a/packages/core/src/social-providers/discord.ts b/packages/core/src/social-providers/discord.ts index fd2c16c631..93616a259b 100644 --- a/packages/core/src/social-providers/discord.ts +++ b/packages/core/src/social-providers/discord.ts @@ -1,6 +1,10 @@ import { betterFetch } from "@better-fetch/fetch"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; -import { refreshAccessToken, validateAuthorizationCode } from "../oauth2"; +import { + createAuthorizationURL, + refreshAccessToken, + validateAuthorizationCode, +} from "../oauth2"; export interface DiscordProfile extends Record { /** the user's id (i.e. the numerical snowflake) */ id: string; @@ -84,26 +88,26 @@ export const discord = (options: DiscordOptions) => { return { id: "discord", name: "Discord", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["identify", "email"]; if (scopes) _scopes.push(...scopes); if (options.scope) _scopes.push(...options.scope); const hasBotScope = _scopes.includes("bot"); - const permissionsParam = - hasBotScope && options.permissions !== undefined - ? `&permissions=${options.permissions}` - : ""; - return new URL( - `https://discord.com/api/oauth2/authorize?scope=${_scopes.join( - "+", - )}&response_type=code&client_id=${ - options.clientId - }&redirect_uri=${encodeURIComponent( - options.redirectURI || redirectURI, - )}&state=${state}&prompt=${ - options.prompt || "none" - }${permissionsParam}`, - ); + return createAuthorizationURL({ + id: "discord", + options, + authorizationEndpoint: "https://discord.com/api/oauth2/authorize", + scopes: _scopes, + state, + redirectURI, + prompt: options.prompt || "none", + additionalParams: { + ...(hasBotScope && options.permissions !== undefined + ? { permissions: String(options.permissions) } + : {}), + ...(additionalParams ?? {}), + }, + }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { return validateAuthorizationCode({ diff --git a/packages/core/src/social-providers/dropbox.ts b/packages/core/src/social-providers/dropbox.ts index 9853af4530..81f37976ee 100644 --- a/packages/core/src/social-providers/dropbox.ts +++ b/packages/core/src/social-providers/dropbox.ts @@ -36,14 +36,11 @@ export const dropbox = (options: DropboxOptions) => { scopes, codeVerifier, redirectURI, + additionalParams, }) => { const _scopes = options.disableDefaultScope ? [] : ["account_info.read"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); - const additionalParams: Record = {}; - if (options.accessType) { - additionalParams.token_access_type = options.accessType; - } return await createAuthorizationURL({ id: "dropbox", options, @@ -52,7 +49,12 @@ export const dropbox = (options: DropboxOptions) => { state, redirectURI, codeVerifier, - additionalParams, + additionalParams: { + ...(options.accessType + ? { token_access_type: options.accessType } + : {}), + ...(additionalParams ?? {}), + }, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/facebook.ts b/packages/core/src/social-providers/facebook.ts index 9f07163fbc..eb92139e28 100644 --- a/packages/core/src/social-providers/facebook.ts +++ b/packages/core/src/social-providers/facebook.ts @@ -43,7 +43,13 @@ export const facebook = (options: FacebookOptions) => { return { id: "facebook", name: "Facebook", - async createAuthorizationURL({ state, scopes, redirectURI, loginHint }) { + async createAuthorizationURL({ + state, + scopes, + redirectURI, + loginHint, + additionalParams, + }) { if (!getPrimaryClientId(options.clientId) || !options.clientSecret) { logger.error( "Client ID and client secret are required for Facebook. Make sure to provide them in the options.", @@ -63,11 +69,10 @@ export const facebook = (options: FacebookOptions) => { state, redirectURI, loginHint, - additionalParams: options.configId - ? { - config_id: options.configId, - } - : {}, + additionalParams: { + ...(options.configId ? { config_id: options.configId } : {}), + ...(additionalParams ?? {}), + }, }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { diff --git a/packages/core/src/social-providers/figma.ts b/packages/core/src/social-providers/figma.ts index 0a1bc72620..a1ab45e4d5 100644 --- a/packages/core/src/social-providers/figma.ts +++ b/packages/core/src/social-providers/figma.ts @@ -24,7 +24,13 @@ export const figma = (options: FigmaOptions) => { return { id: "figma", name: "Figma", - async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + async createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { if (!options.clientId || !options.clientSecret) { logger.error( "Client Id and Client Secret are required for Figma. Make sure to provide them in the options.", @@ -47,6 +53,7 @@ export const figma = (options: FigmaOptions) => { state, codeVerifier, redirectURI, + additionalParams, }); return url; diff --git a/packages/core/src/social-providers/github.ts b/packages/core/src/social-providers/github.ts index 9d95fc3e3c..37ed63cac2 100644 --- a/packages/core/src/social-providers/github.ts +++ b/packages/core/src/social-providers/github.ts @@ -69,6 +69,7 @@ export const github = (options: GithubOptions) => { loginHint, codeVerifier, redirectURI, + additionalParams, }) { const _scopes = options.disableDefaultScope ? [] @@ -85,6 +86,7 @@ export const github = (options: GithubOptions) => { redirectURI, loginHint, prompt: options.prompt, + additionalParams, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/gitlab.ts b/packages/core/src/social-providers/gitlab.ts index 566b5ce979..5ae507ce28 100644 --- a/packages/core/src/social-providers/gitlab.ts +++ b/packages/core/src/social-providers/gitlab.ts @@ -87,6 +87,7 @@ export const gitlab = (options: GitlabOptions) => { codeVerifier, loginHint, redirectURI, + additionalParams, }) => { const _scopes = options.disableDefaultScope ? [] : ["read_user"]; if (options.scope) _scopes.push(...options.scope); @@ -100,6 +101,7 @@ export const gitlab = (options: GitlabOptions) => { redirectURI, codeVerifier, loginHint, + additionalParams, }); }, validateAuthorizationCode: async ({ code, redirectURI, codeVerifier }) => { diff --git a/packages/core/src/social-providers/google.ts b/packages/core/src/social-providers/google.ts index b85a5b39ee..25707737e9 100644 --- a/packages/core/src/social-providers/google.ts +++ b/packages/core/src/social-providers/google.ts @@ -64,6 +64,7 @@ export const google = (options: GoogleOptions) => { redirectURI, loginHint, display, + additionalParams, }) { if (!getPrimaryClientId(options.clientId) || !options.clientSecret) { logger.error( @@ -94,6 +95,7 @@ export const google = (options: GoogleOptions) => { hd: options.hd, additionalParams: { include_granted_scopes: "true", + ...(additionalParams ?? {}), }, }); return url; diff --git a/packages/core/src/social-providers/huggingface.ts b/packages/core/src/social-providers/huggingface.ts index c7523830ba..35be491570 100644 --- a/packages/core/src/social-providers/huggingface.ts +++ b/packages/core/src/social-providers/huggingface.ts @@ -47,7 +47,13 @@ export const huggingface = (options: HuggingFaceOptions) => { return { id: "huggingface", name: "Hugging Face", - createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { const _scopes = options.disableDefaultScope ? [] : ["openid", "profile", "email"]; @@ -61,6 +67,7 @@ export const huggingface = (options: HuggingFaceOptions) => { state, codeVerifier, redirectURI, + additionalParams, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/kakao.ts b/packages/core/src/social-providers/kakao.ts index d232ccc8d6..8c63beeb58 100644 --- a/packages/core/src/social-providers/kakao.ts +++ b/packages/core/src/social-providers/kakao.ts @@ -106,7 +106,7 @@ export const kakao = (options: KakaoOptions) => { return { id: "kakao", name: "Kakao", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["account_email", "profile_image", "profile_nickname"]; @@ -119,6 +119,7 @@ export const kakao = (options: KakaoOptions) => { scopes: _scopes, state, redirectURI, + additionalParams, }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { diff --git a/packages/core/src/social-providers/kick.ts b/packages/core/src/social-providers/kick.ts index 6711601b2b..df486e8a30 100644 --- a/packages/core/src/social-providers/kick.ts +++ b/packages/core/src/social-providers/kick.ts @@ -33,7 +33,13 @@ export const kick = (options: KickOptions) => { return { id: "kick", name: "Kick", - createAuthorizationURL({ state, scopes, redirectURI, codeVerifier }) { + createAuthorizationURL({ + state, + scopes, + redirectURI, + codeVerifier, + additionalParams, + }) { const _scopes = options.disableDefaultScope ? [] : ["user:read"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); @@ -46,6 +52,7 @@ export const kick = (options: KickOptions) => { scopes: _scopes, codeVerifier, state, + additionalParams, }); }, async validateAuthorizationCode({ code, redirectURI, codeVerifier }) { diff --git a/packages/core/src/social-providers/line.ts b/packages/core/src/social-providers/line.ts index 6c9b154833..84ad20dc8d 100644 --- a/packages/core/src/social-providers/line.ts +++ b/packages/core/src/social-providers/line.ts @@ -56,6 +56,7 @@ export const line = (options: LineOptions) => { codeVerifier, redirectURI, loginHint, + additionalParams, }) { const _scopes = options.disableDefaultScope ? [] @@ -71,6 +72,7 @@ export const line = (options: LineOptions) => { codeVerifier, redirectURI, loginHint, + additionalParams, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/linear.ts b/packages/core/src/social-providers/linear.ts index 759872058a..78892c2f4d 100644 --- a/packages/core/src/social-providers/linear.ts +++ b/packages/core/src/social-providers/linear.ts @@ -31,7 +31,13 @@ export const linear = (options: LinearOptions) => { return { id: "linear", name: "Linear", - createAuthorizationURL({ state, scopes, loginHint, redirectURI }) { + createAuthorizationURL({ + state, + scopes, + loginHint, + redirectURI, + additionalParams, + }) { const _scopes = options.disableDefaultScope ? [] : ["read"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); @@ -43,6 +49,7 @@ export const linear = (options: LinearOptions) => { state, redirectURI, loginHint, + additionalParams, }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { diff --git a/packages/core/src/social-providers/linkedin.ts b/packages/core/src/social-providers/linkedin.ts index 94d7478f0d..cec105ebcb 100644 --- a/packages/core/src/social-providers/linkedin.ts +++ b/packages/core/src/social-providers/linkedin.ts @@ -37,6 +37,7 @@ export const linkedin = (options: LinkedInOptions) => { scopes, redirectURI, loginHint, + additionalParams, }) => { const _scopes = options.disableDefaultScope ? [] @@ -51,6 +52,7 @@ export const linkedin = (options: LinkedInOptions) => { state, loginHint, redirectURI, + additionalParams, }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { diff --git a/packages/core/src/social-providers/microsoft-entra-id.ts b/packages/core/src/social-providers/microsoft-entra-id.ts index 0a83d4d93a..2c5c2668af 100644 --- a/packages/core/src/social-providers/microsoft-entra-id.ts +++ b/packages/core/src/social-providers/microsoft-entra-id.ts @@ -174,6 +174,7 @@ export const microsoft = (options: MicrosoftOptions) => { redirectURI: data.redirectURI, prompt: options.prompt, loginHint: data.loginHint, + additionalParams: data.additionalParams, }); }, validateAuthorizationCode({ code, codeVerifier, redirectURI }) { diff --git a/packages/core/src/social-providers/naver.ts b/packages/core/src/social-providers/naver.ts index 0898f8fe10..5a32b01ce7 100644 --- a/packages/core/src/social-providers/naver.ts +++ b/packages/core/src/social-providers/naver.ts @@ -44,7 +44,7 @@ export const naver = (options: NaverOptions) => { return { id: "naver", name: "Naver", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["profile", "email"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); @@ -55,6 +55,7 @@ export const naver = (options: NaverOptions) => { scopes: _scopes, state, redirectURI, + additionalParams, }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { diff --git a/packages/core/src/social-providers/notion.ts b/packages/core/src/social-providers/notion.ts index 7db9c46fb6..b16e84781d 100644 --- a/packages/core/src/social-providers/notion.ts +++ b/packages/core/src/social-providers/notion.ts @@ -28,7 +28,13 @@ export const notion = (options: NotionOptions) => { return { id: "notion", name: "Notion", - createAuthorizationURL({ state, scopes, loginHint, redirectURI }) { + createAuthorizationURL({ + state, + scopes, + loginHint, + redirectURI, + additionalParams, + }) { const _scopes: string[] = options.disableDefaultScope ? [] : []; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); @@ -41,6 +47,7 @@ export const notion = (options: NotionOptions) => { redirectURI, loginHint, additionalParams: { + ...(additionalParams ?? {}), owner: "user", }, }); diff --git a/packages/core/src/social-providers/paybin.ts b/packages/core/src/social-providers/paybin.ts index aca15616f6..e10101ee25 100644 --- a/packages/core/src/social-providers/paybin.ts +++ b/packages/core/src/social-providers/paybin.ts @@ -42,6 +42,7 @@ export const paybin = (options: PaybinOptions) => { codeVerifier, redirectURI, loginHint, + additionalParams, }) { if (!options.clientId || !options.clientSecret) { logger.error( @@ -67,6 +68,7 @@ export const paybin = (options: PaybinOptions) => { redirectURI, prompt: options.prompt, loginHint, + additionalParams, }); return url; }, diff --git a/packages/core/src/social-providers/paypal.ts b/packages/core/src/social-providers/paypal.ts index 9266b0c262..6d2be733ba 100644 --- a/packages/core/src/social-providers/paypal.ts +++ b/packages/core/src/social-providers/paypal.ts @@ -78,7 +78,12 @@ export const paypal = (options: PayPalOptions) => { return { id: "paypal", name: "PayPal", - async createAuthorizationURL({ state, codeVerifier, redirectURI }) { + async createAuthorizationURL({ + state, + codeVerifier, + redirectURI, + additionalParams, + }) { if (!options.clientId || !options.clientSecret) { logger.error( "Client Id and Client Secret is required for PayPal. Make sure to provide them in the options.", @@ -103,6 +108,7 @@ export const paypal = (options: PayPalOptions) => { codeVerifier, redirectURI, prompt: options.prompt, + additionalParams, }); return url; }, diff --git a/packages/core/src/social-providers/polar.ts b/packages/core/src/social-providers/polar.ts index 8f2fff4f6d..9de75c15ab 100644 --- a/packages/core/src/social-providers/polar.ts +++ b/packages/core/src/social-providers/polar.ts @@ -37,7 +37,13 @@ export const polar = (options: PolarOptions) => { return { id: "polar", name: "Polar", - createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { const _scopes = options.disableDefaultScope ? [] : ["openid", "profile", "email"]; @@ -52,6 +58,7 @@ export const polar = (options: PolarOptions) => { codeVerifier, redirectURI, prompt: options.prompt, + additionalParams, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/railway.ts b/packages/core/src/social-providers/railway.ts index 995be40a97..0dad19335f 100644 --- a/packages/core/src/social-providers/railway.ts +++ b/packages/core/src/social-providers/railway.ts @@ -29,7 +29,13 @@ export const railway = (options: RailwayOptions) => { return { id: "railway", name: "Railway", - createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { const _scopes = options.disableDefaultScope ? [] : ["openid", "email", "profile"]; @@ -43,6 +49,7 @@ export const railway = (options: RailwayOptions) => { state, codeVerifier, redirectURI, + additionalParams, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/reddit.ts b/packages/core/src/social-providers/reddit.ts index 9904ae6acb..bc91af66a8 100644 --- a/packages/core/src/social-providers/reddit.ts +++ b/packages/core/src/social-providers/reddit.ts @@ -25,7 +25,7 @@ export const reddit = (options: RedditOptions) => { return { id: "reddit", name: "Reddit", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["identity"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); @@ -37,6 +37,7 @@ export const reddit = (options: RedditOptions) => { state, redirectURI, duration: options.duration, + additionalParams, }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { diff --git a/packages/core/src/social-providers/roblox.ts b/packages/core/src/social-providers/roblox.ts index 33d08b9819..8985a9dca2 100644 --- a/packages/core/src/social-providers/roblox.ts +++ b/packages/core/src/social-providers/roblox.ts @@ -1,6 +1,10 @@ import { betterFetch } from "@better-fetch/fetch"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; -import { refreshAccessToken, validateAuthorizationCode } from "../oauth2"; +import { + createAuthorizationURL, + refreshAccessToken, + validateAuthorizationCode, +} from "../oauth2"; export interface RobloxProfile extends Record { /** the user's id */ @@ -37,19 +41,20 @@ export const roblox = (options: RobloxOptions) => { return { id: "roblox", name: "Roblox", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["openid", "profile"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); - return new URL( - `https://apis.roblox.com/oauth/v1/authorize?scope=${_scopes.join( - "+", - )}&response_type=code&client_id=${ - options.clientId - }&redirect_uri=${encodeURIComponent( - options.redirectURI || redirectURI, - )}&state=${state}&prompt=${options.prompt || "select_account consent"}`, - ); + return createAuthorizationURL({ + id: "roblox", + options, + authorizationEndpoint: "https://apis.roblox.com/oauth/v1/authorize", + scopes: _scopes, + state, + redirectURI, + prompt: options.prompt || "select_account consent", + additionalParams, + }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { return validateAuthorizationCode({ diff --git a/packages/core/src/social-providers/salesforce.ts b/packages/core/src/social-providers/salesforce.ts index 8dff5c3fd3..78131b3a50 100644 --- a/packages/core/src/social-providers/salesforce.ts +++ b/packages/core/src/social-providers/salesforce.ts @@ -64,7 +64,13 @@ export const salesforce = (options: SalesforceOptions) => { id: "salesforce", name: "Salesforce", - async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + async createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { if (!options.clientId || !options.clientSecret) { logger.error( "Client Id and Client Secret are required for Salesforce. Make sure to provide them in the options.", @@ -89,6 +95,7 @@ export const salesforce = (options: SalesforceOptions) => { state, codeVerifier, redirectURI: options.redirectURI || redirectURI, + additionalParams, }); }, diff --git a/packages/core/src/social-providers/slack.ts b/packages/core/src/social-providers/slack.ts index 7dcd267c19..e6a15f2c18 100644 --- a/packages/core/src/social-providers/slack.ts +++ b/packages/core/src/social-providers/slack.ts @@ -1,6 +1,10 @@ import { betterFetch } from "@better-fetch/fetch"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; -import { refreshAccessToken, validateAuthorizationCode } from "../oauth2"; +import { + createAuthorizationURL, + refreshAccessToken, + validateAuthorizationCode, +} from "../oauth2"; export interface SlackProfile extends Record { ok: boolean; @@ -42,19 +46,21 @@ export const slack = (options: SlackOptions) => { return { id: "slack", name: "Slack", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["openid", "profile", "email"]; if (scopes) _scopes.push(...scopes); if (options.scope) _scopes.push(...options.scope); - const url = new URL("https://slack.com/openid/connect/authorize"); - url.searchParams.set("scope", _scopes.join(" ")); - url.searchParams.set("response_type", "code"); - url.searchParams.set("client_id", options.clientId); - url.searchParams.set("redirect_uri", options.redirectURI || redirectURI); - url.searchParams.set("state", state); - return url; + return createAuthorizationURL({ + id: "slack", + options, + authorizationEndpoint: "https://slack.com/openid/connect/authorize", + scopes: _scopes, + state, + redirectURI, + additionalParams, + }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { return validateAuthorizationCode({ diff --git a/packages/core/src/social-providers/social-providers.test.ts b/packages/core/src/social-providers/social-providers.test.ts new file mode 100644 index 0000000000..9871266873 --- /dev/null +++ b/packages/core/src/social-providers/social-providers.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { cognito } from "./cognito"; +import { discord } from "./discord"; +import { roblox } from "./roblox"; +import { slack } from "./slack"; +import { tiktok } from "./tiktok"; +import { wechat } from "./wechat"; +import { zoom } from "./zoom"; + +const baseCallback = "https://app.example/callback"; +const baseState = "state-xyz"; +const baseVerifier = "v".repeat(64); + +const credentials = { clientId: "client-123", clientSecret: "secret-abc" }; + +const baseInput = { + state: baseState, + codeVerifier: baseVerifier, + redirectURI: baseCallback, +}; + +describe("discord provider", () => { + it("preserves the authorize URL shape after the shared-helper refactor", async () => { + const provider = discord({ ...credentials }); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.origin + url.pathname).toBe( + "https://discord.com/api/oauth2/authorize", + ); + expect(url.searchParams.get("client_id")).toBe(credentials.clientId); + expect(url.searchParams.get("state")).toBe(baseState); + expect(url.searchParams.get("redirect_uri")).toBe(baseCallback); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("identify email"); + expect(url.searchParams.get("prompt")).toBe("none"); + expect(url.searchParams.get("permissions")).toBeNull(); + }); + + it("appends permissions when bot scope is requested with options.permissions", async () => { + const provider = discord({ ...credentials, permissions: 8 }); + const url = await provider.createAuthorizationURL({ + ...baseInput, + scopes: ["bot"], + }); + expect(url.searchParams.get("permissions")).toBe("8"); + }); + + it("forwards additionalParams while dropping reserved keys", async () => { + const provider = discord({ ...credentials }); + const url = await provider.createAuthorizationURL({ + ...baseInput, + additionalParams: { custom: "value", state: "attacker" }, + }); + expect(url.searchParams.get("custom")).toBe("value"); + expect(url.searchParams.get("state")).toBe(baseState); + }); +}); + +describe("roblox provider", () => { + it("preserves the authorize URL shape after the shared-helper refactor", async () => { + const provider = roblox({ ...credentials }); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.origin + url.pathname).toBe( + "https://apis.roblox.com/oauth/v1/authorize", + ); + expect(url.searchParams.get("client_id")).toBe(credentials.clientId); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("openid profile"); + expect(url.searchParams.get("prompt")).toBe("select_account consent"); + }); + + it("forwards additionalParams while dropping reserved keys", async () => { + const provider = roblox({ ...credentials }); + const url = await provider.createAuthorizationURL({ + ...baseInput, + additionalParams: { custom: "value", scope: "admin" }, + }); + expect(url.searchParams.get("custom")).toBe("value"); + expect(url.searchParams.get("scope")).toBe("openid profile"); + }); +}); + +describe("slack provider", () => { + it("preserves the authorize URL shape after the shared-helper refactor", async () => { + const provider = slack({ ...credentials }); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.origin + url.pathname).toBe( + "https://slack.com/openid/connect/authorize", + ); + expect(url.searchParams.get("client_id")).toBe(credentials.clientId); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("openid profile email"); + expect(url.searchParams.get("state")).toBe(baseState); + }); + + it("forwards additionalParams while dropping reserved keys", async () => { + const provider = slack({ ...credentials }); + const url = await provider.createAuthorizationURL({ + ...baseInput, + additionalParams: { team: "T01ABC", client_id: "attacker" }, + }); + expect(url.searchParams.get("team")).toBe("T01ABC"); + expect(url.searchParams.get("client_id")).toBe(credentials.clientId); + }); +}); + +describe("zoom provider", () => { + it("preserves the authorize URL shape after the shared-helper refactor", async () => { + const provider = zoom({ ...credentials, pkce: false }); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.origin + url.pathname).toBe("https://zoom.us/oauth/authorize"); + expect(url.searchParams.get("client_id")).toBe(credentials.clientId); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("redirect_uri")).toBe(baseCallback); + expect(url.searchParams.get("scope")).toBeNull(); + expect(url.searchParams.get("code_challenge")).toBeNull(); + }); + + it("adds PKCE challenge by default", async () => { + const provider = zoom({ ...credentials }); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).not.toBeNull(); + }); + + it("forwards additionalParams while dropping reserved keys", async () => { + const provider = zoom({ ...credentials, pkce: false }); + const url = await provider.createAuthorizationURL({ + ...baseInput, + additionalParams: { custom: "value", redirect_uri: "https://attacker" }, + }); + expect(url.searchParams.get("custom")).toBe("value"); + expect(url.searchParams.get("redirect_uri")).toBe(baseCallback); + }); +}); + +describe("tiktok provider", () => { + it("preserves the manual authorize URL shape with non-standard client_key", async () => { + const provider = tiktok({ + clientKey: "tk-key-1", + clientSecret: "secret", + }); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.origin + url.pathname).toBe( + "https://www.tiktok.com/v2/auth/authorize", + ); + expect(url.searchParams.get("client_key")).toBe("tk-key-1"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("redirect_uri")).toBe(baseCallback); + expect(url.searchParams.get("state")).toBe(baseState); + expect(url.searchParams.get("scope")).toBe("user.info.profile"); + }); + + it("forwards additionalParams but drops reserved keys and client_key", async () => { + const provider = tiktok({ + clientKey: "tk-key-1", + clientSecret: "secret", + }); + const url = await provider.createAuthorizationURL({ + ...baseInput, + additionalParams: { + custom: "value", + state: "attacker", + client_key: "attacker-key", + }, + }); + expect(url.searchParams.get("custom")).toBe("value"); + expect(url.searchParams.get("state")).toBe(baseState); + expect(url.searchParams.get("client_key")).toBe("tk-key-1"); + }); +}); + +describe("wechat provider", () => { + it("preserves the manual authorize URL shape with appid and wechat_redirect fragment", async () => { + const provider = wechat({ clientId: "wx-app-1", clientSecret: "secret" }); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.origin + url.pathname).toBe( + "https://open.weixin.qq.com/connect/qrconnect", + ); + expect(url.searchParams.get("appid")).toBe("wx-app-1"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("snsapi_login"); + expect(url.searchParams.get("lang")).toBe("cn"); + expect(url.hash).toBe("#wechat_redirect"); + }); + + it("forwards additionalParams but drops reserved keys and appid", async () => { + const provider = wechat({ clientId: "wx-app-1", clientSecret: "secret" }); + const url = await provider.createAuthorizationURL({ + ...baseInput, + additionalParams: { + custom: "value", + state: "attacker", + appid: "attacker-app", + }, + }); + expect(url.searchParams.get("custom")).toBe("value"); + expect(url.searchParams.get("state")).toBe(baseState); + expect(url.searchParams.get("appid")).toBe("wx-app-1"); + }); +}); + +describe("cognito provider", () => { + const cognitoConfig = { + ...credentials, + domain: "test.auth.us-east-1.amazoncognito.com", + region: "us-east-1", + userPoolId: "us-east-1_pool", + }; + + it("applies the configured identityProvider as identity_provider", async () => { + const provider = cognito({ + ...cognitoConfig, + identityProvider: "Google", + }); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.searchParams.get("identity_provider")).toBe("Google"); + }); + + it("lets call-time additionalParams override the configured identityProvider", async () => { + const provider = cognito({ + ...cognitoConfig, + identityProvider: "Google", + }); + const url = await provider.createAuthorizationURL({ + ...baseInput, + additionalParams: { identity_provider: "Okta" }, + }); + expect(url.searchParams.get("identity_provider")).toBe("Okta"); + }); + + it("omits identity_provider when neither config nor additionalParams set it", async () => { + const provider = cognito(cognitoConfig); + const url = await provider.createAuthorizationURL(baseInput); + expect(url.searchParams.get("identity_provider")).toBeNull(); + }); +}); diff --git a/packages/core/src/social-providers/spotify.ts b/packages/core/src/social-providers/spotify.ts index 6853b0c968..77ff1395fc 100644 --- a/packages/core/src/social-providers/spotify.ts +++ b/packages/core/src/social-providers/spotify.ts @@ -24,7 +24,13 @@ export const spotify = (options: SpotifyOptions) => { return { id: "spotify", name: "Spotify", - createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { const _scopes = options.disableDefaultScope ? [] : ["user-read-email"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); @@ -36,6 +42,7 @@ export const spotify = (options: SpotifyOptions) => { state, codeVerifier, redirectURI, + additionalParams, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/tiktok.ts b/packages/core/src/social-providers/tiktok.ts index 4b55a76a35..15dde612d1 100644 --- a/packages/core/src/social-providers/tiktok.ts +++ b/packages/core/src/social-providers/tiktok.ts @@ -1,6 +1,10 @@ import { betterFetch } from "@better-fetch/fetch"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; -import { refreshAccessToken, validateAuthorizationCode } from "../oauth2"; +import { + RESERVED_AUTHORIZATION_PARAMS_SET, + refreshAccessToken, + validateAuthorizationCode, +} from "../oauth2"; /** * [More info](https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info/) @@ -131,17 +135,26 @@ export const tiktok = (options: TiktokOptions) => { return { id: "tiktok", name: "TikTok", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["user.info.profile"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); - return new URL( - `https://www.tiktok.com/v2/auth/authorize?scope=${_scopes.join( - ",", - )}&response_type=code&client_key=${options.clientKey}&redirect_uri=${encodeURIComponent( - options.redirectURI || redirectURI, - )}&state=${state}`, - ); + // TikTok uses `client_key` instead of the standard `client_id`, so the + // shared createAuthorizationURL helper cannot be used directly. + const url = new URL("https://www.tiktok.com/v2/auth/authorize"); + url.searchParams.set("scope", _scopes.join(",")); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_key", options.clientKey); + url.searchParams.set("redirect_uri", options.redirectURI || redirectURI); + url.searchParams.set("state", state); + if (additionalParams) { + for (const [key, value] of Object.entries(additionalParams)) { + if (RESERVED_AUTHORIZATION_PARAMS_SET.has(key)) continue; + if (key === "client_key") continue; + url.searchParams.set(key, value); + } + } + return url; }, validateAuthorizationCode: async ({ code, redirectURI }) => { diff --git a/packages/core/src/social-providers/twitch.ts b/packages/core/src/social-providers/twitch.ts index 543449ce7b..da048c93c4 100644 --- a/packages/core/src/social-providers/twitch.ts +++ b/packages/core/src/social-providers/twitch.ts @@ -42,7 +42,7 @@ export const twitch = (options: TwitchOptions) => { return { id: "twitch", name: "Twitch", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["user:read:email", "openid"]; @@ -61,6 +61,7 @@ export const twitch = (options: TwitchOptions) => { "preferred_username", "picture", ], + additionalParams, }); }, validateAuthorizationCode: async ({ code, redirectURI }) => { diff --git a/packages/core/src/social-providers/twitter.ts b/packages/core/src/social-providers/twitter.ts index 3055724958..c4998a6ebe 100644 --- a/packages/core/src/social-providers/twitter.ts +++ b/packages/core/src/social-providers/twitter.ts @@ -122,6 +122,7 @@ export const twitter = (options: TwitterOption) => { state: data.state, codeVerifier: data.codeVerifier, redirectURI: data.redirectURI, + additionalParams: data.additionalParams, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/vercel.ts b/packages/core/src/social-providers/vercel.ts index 35c41180c6..522ebaaa91 100644 --- a/packages/core/src/social-providers/vercel.ts +++ b/packages/core/src/social-providers/vercel.ts @@ -20,7 +20,13 @@ export const vercel = (options: VercelOptions) => { return { id: "vercel", name: "Vercel", - createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { if (!codeVerifier) { throw new BetterAuthError("codeVerifier is required for Vercel"); } @@ -40,6 +46,7 @@ export const vercel = (options: VercelOptions) => { state, codeVerifier, redirectURI, + additionalParams, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { diff --git a/packages/core/src/social-providers/vk.ts b/packages/core/src/social-providers/vk.ts index b0898ceb5b..3ad5a5ba03 100644 --- a/packages/core/src/social-providers/vk.ts +++ b/packages/core/src/social-providers/vk.ts @@ -30,7 +30,13 @@ export const vk = (options: VkOption) => { return { id: "vk", name: "VK", - async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + async createAuthorizationURL({ + state, + scopes, + codeVerifier, + redirectURI, + additionalParams, + }) { const _scopes = options.disableDefaultScope ? [] : ["email", "phone"]; if (options.scope) _scopes.push(...options.scope); if (scopes) _scopes.push(...scopes); @@ -44,6 +50,7 @@ export const vk = (options: VkOption) => { state, redirectURI, codeVerifier, + additionalParams, }); }, validateAuthorizationCode: async ({ diff --git a/packages/core/src/social-providers/wechat.ts b/packages/core/src/social-providers/wechat.ts index 8e9681cb2e..c324deda8f 100644 --- a/packages/core/src/social-providers/wechat.ts +++ b/packages/core/src/social-providers/wechat.ts @@ -1,5 +1,6 @@ import { betterFetch } from "@better-fetch/fetch"; import type { OAuth2Tokens, OAuthProvider, ProviderOptions } from "../oauth2"; +import { RESERVED_AUTHORIZATION_PARAMS_SET } from "../oauth2"; /** * WeChat user profile information @@ -58,7 +59,7 @@ export const wechat = (options: WeChatOptions) => { return { id: "wechat", name: "WeChat", - createAuthorizationURL({ state, scopes, redirectURI }) { + createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) { const _scopes = options.disableDefaultScope ? [] : ["snsapi_login"]; options.scope && _scopes.push(...options.scope); scopes && _scopes.push(...scopes); @@ -72,6 +73,13 @@ export const wechat = (options: WeChatOptions) => { url.searchParams.set("redirect_uri", options.redirectURI || redirectURI); url.searchParams.set("state", state); url.searchParams.set("lang", options.lang || "cn"); + if (additionalParams) { + for (const [key, value] of Object.entries(additionalParams)) { + if (RESERVED_AUTHORIZATION_PARAMS_SET.has(key)) continue; + if (key === "appid") continue; + url.searchParams.set(key, value); + } + } url.hash = "wechat_redirect"; return url; diff --git a/packages/core/src/social-providers/zoom.ts b/packages/core/src/social-providers/zoom.ts index f0c957cf5e..4772b7901d 100644 --- a/packages/core/src/social-providers/zoom.ts +++ b/packages/core/src/social-providers/zoom.ts @@ -1,7 +1,7 @@ import { betterFetch } from "@better-fetch/fetch"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; import { - generateCodeChallenge, + createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, } from "../oauth2"; @@ -152,25 +152,21 @@ export const zoom = (userOptions: ZoomOptions) => { return { id: "zoom", name: "Zoom", - createAuthorizationURL: async ({ state, redirectURI, codeVerifier }) => { - const params = new URLSearchParams({ - response_type: "code", - redirect_uri: options.redirectURI ? options.redirectURI : redirectURI, - client_id: options.clientId, + createAuthorizationURL: async ({ + state, + redirectURI, + codeVerifier, + additionalParams, + }) => + createAuthorizationURL({ + id: "zoom", + options, + authorizationEndpoint: "https://zoom.us/oauth/authorize", state, - }); - - if (options.pkce) { - const codeChallenge = await generateCodeChallenge(codeVerifier); - params.set("code_challenge_method", "S256"); - params.set("code_challenge", codeChallenge); - } - - const url = new URL("https://zoom.us/oauth/authorize"); - url.search = params.toString(); - - return url; - }, + redirectURI, + codeVerifier: options.pkce ? codeVerifier : undefined, + additionalParams, + }), validateAuthorizationCode: async ({ code, redirectURI, codeVerifier }) => { return validateAuthorizationCode({ code, diff --git a/packages/sso/src/oidc.test.ts b/packages/sso/src/oidc.test.ts index 87d765c638..89458de16c 100644 --- a/packages/sso/src/oidc.test.ts +++ b/packages/sso/src/oidc.test.ts @@ -255,6 +255,26 @@ describe("SSO", async () => { expect(callbackURL).toContain("/dashboard"); }); + it("should forward additionalParams to the SSO authorization URL", async () => { + const res = await authClient.signIn.sso({ + providerId: "test", + callbackURL: "/dashboard", + additionalParams: { domain_hint: "contoso.com" }, + fetchOptions: { throw: true }, + }); + const url = new URL(res.url); + expect(url.searchParams.get("domain_hint")).toBe("contoso.com"); + }); + + it("should reject SSO additionalParams that collide with reserved OAuth params", async () => { + const res = await authClient.signIn.sso({ + providerId: "test", + callbackURL: "/dashboard", + additionalParams: { redirect_uri: "https://attacker.example/callback" }, + }); + expect(res.error?.status).toBe(400); + }); + it("should hydrate authorizationEndpoint via discovery when missing from stored config", async () => { const { headers } = await signInWithTestUser(); diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index 5eed8f159d..f221830fe3 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -21,7 +21,10 @@ import { } from "better-auth/api"; import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies"; import { generateRandomString } from "better-auth/crypto"; -import { handleOAuthUserInfo } from "better-auth/oauth2"; +import { + additionalAuthorizationParamsSchema, + handleOAuthUserInfo, +} from "better-auth/oauth2"; import { decodeJwt } from "jose"; import type { BindingContext } from "samlify/types/src/entity"; import * as z from "zod"; @@ -655,41 +658,43 @@ const signInSSOBodySchema = z.object({ .string({}) .meta({ description: - "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided", + "The email address to sign in with. Used to resolve the provider via the email domain; optional if providerId, domain, or organizationSlug is provided.", }) .optional(), organizationSlug: z .string({}) .meta({ - description: "The slug of the organization to sign in with", + description: "The slug of the organization to sign in with.", }) .optional(), providerId: z .string({}) .meta({ description: - "The ID of the provider to sign in with. This can be provided instead of email or issuer", + "The ID of the provider to sign in with. Can be provided instead of email.", }) .optional(), domain: z .string({}) .meta({ - description: "The domain of the provider.", + description: + "The email domain of the provider. Can be provided instead of email.", }) .optional(), callbackURL: z.string({}).meta({ - description: "The URL to redirect to after login", + description: "The URL to redirect to after successful sign-in.", }), errorCallbackURL: z .string({}) .meta({ - description: "The URL to redirect to after login", + description: "The URL to redirect to if the sign-in flow fails.", }) .optional(), newUserCallbackURL: z .string({}) .meta({ - description: "The URL to redirect to after login if the user is new", + description: + "The URL to redirect to after sign-in if the user is newly registered.", }) .optional(), scopes: z @@ -702,17 +707,23 @@ const signInSSOBodySchema = z.object({ .string({}) .meta({ description: - "Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'.", + "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'.", }) .optional(), + additionalParams: additionalAuthorizationParamsSchema, requestSignUp: z .boolean({}) .meta({ description: - "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider", + "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider.", + }) + .optional(), + providerType: z + .enum(["oidc", "saml"]) + .meta({ + description: "The provider protocol to sign in with.", }) .optional(), - providerType: z.enum(["oidc", "saml"]).optional(), }); export const signInSSO = (options?: SSOOptions) => { @@ -736,36 +747,64 @@ export const signInSSO = (options?: SSOOptions) => { email: { type: "string", description: - "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided", + "The email address to sign in with. Used to resolve the provider via the email domain; optional if providerId, domain, or organizationSlug is provided.", }, - issuer: { + organizationSlug: { type: "string", description: - "The issuer identifier, this is the URL of the provider and can be used to verify the provider and identify the provider during login. It's optional if the email is provided", + "The slug of the organization to sign in with.", }, providerId: { type: "string", description: - "The ID of the provider to sign in with. This can be provided instead of email or issuer", + "The ID of the provider to sign in with. Can be provided instead of email.", + }, + domain: { + type: "string", + description: + "The email domain of the provider. Can be provided instead of email.", }, callbackURL: { type: "string", - description: "The URL to redirect to after login", + description: + "The URL to redirect to after successful sign-in.", }, errorCallbackURL: { type: "string", - description: "The URL to redirect to after login", + description: + "The URL to redirect to if the sign-in flow fails.", }, newUserCallbackURL: { type: "string", description: - "The URL to redirect to after login if the user is new", + "The URL to redirect to after sign-in if the user is newly registered.", + }, + scopes: { + type: "array", + items: { type: "string" }, + description: "Scopes to request from the provider.", }, loginHint: { type: "string", description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'.", }, + additionalParams: { + type: "object", + additionalProperties: { type: "string" }, + description: + "Extra query parameters to append to the OIDC provider authorization URL. RFC 6749 reserved keys (state, client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope) are rejected. Not supported for SAML providers.", + }, + requestSignUp: { + type: "boolean", + description: + "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider.", + }, + providerType: { + type: "string", + enum: ["oidc", "saml"], + description: "The provider protocol to sign in with.", + }, }, required: ["callbackURL"], }, @@ -998,6 +1037,7 @@ export const signInSSO = (options?: SSOOptions) => { config.scopes || ["openid", "email", "profile", "offline_access"], loginHint: ctx.body.loginHint || email, authorizationEndpoint: config.authorizationEndpoint, + additionalParams: ctx.body.additionalParams, }); return ctx.json({ url: authorizationURL.toString(), @@ -1005,6 +1045,12 @@ export const signInSSO = (options?: SSOOptions) => { }); } if (provider.samlConfig) { + if (ctx.body.additionalParams) { + throw new APIError("BAD_REQUEST", { + message: + "additionalParams is not supported for SAML providers; the SAML AuthnRequest is signed and cannot carry caller-supplied query parameters.", + }); + } const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig diff --git a/packages/sso/src/saml.test.ts b/packages/sso/src/saml.test.ts index 9598a4b845..4252ba5316 100644 --- a/packages/sso/src/saml.test.ts +++ b/packages/sso/src/saml.test.ts @@ -680,6 +680,18 @@ describe("SAML SSO with defaultSSO array", async () => { }); }); + it("should reject additionalParams when routing to a SAML provider", async () => { + await expect( + auth.api.signInSSO({ + body: { + providerId: "default-saml", + callbackURL: "http://localhost:3000/dashboard", + additionalParams: { domain_hint: "contoso.com" }, + }, + }), + ).rejects.toThrow(/additionalParams is not supported for SAML/); + }); + it("should fetch sp metadata for a defaultSSO provider", async () => { const spMetadataRes = await auth.api.spMetadata({ query: {