feat(oauth): per-request additionalParams and loginHint (#9305)

This commit is contained in:
Gustavo Valverde
2026-05-19 17:18:43 +00:00
committed by GitHub
parent 03e6c94e96
commit e7eb45b065
57 changed files with 999 additions and 121 deletions
+39
View File
@@ -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<string, string>`. 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.
+3 -1
View File
@@ -27,4 +27,6 @@ Razorpay
razorpay
payu
PayU
Mikro
contoso
Contoso
Mikro
@@ -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.
<Callout type="info">
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).
@@ -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.
</Callout>
### 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.
@@ -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" },
});
```
+24
View File
@@ -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.
</Callout>
### 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.
@@ -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) => {
@@ -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) {
@@ -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 = <O extends BetterAuthOptions>() =>
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint,
additionalParams: c.body.additionalParams,
});
if (!c.body.disableRedirect) {
@@ -282,7 +282,10 @@ export const genericOAuth = <const ID extends string>(
accessType: c.accessType,
responseType: c.responseType,
responseMode: c.responseMode,
additionalParams: c.authorizationUrlParams,
additionalParams: {
...(c.authorizationUrlParams ?? {}),
...(data.additionalParams ?? {}),
},
loginHint: data.loginHint,
});
},
@@ -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",
],
},
@@ -225,6 +225,26 @@ function processZodType(zodType: z.ZodType<any>): 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<any>)
: {};
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;
+28
View File
@@ -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({
@@ -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();
@@ -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",
]);
});
});
@@ -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<string> = 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;
}
+6 -1
View File
@@ -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,
@@ -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<string, string> | undefined;
}) => Awaitable<URL>;
name: string;
validateAuthorizationCode: (data: {
+7 -1
View File
@@ -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;
},
@@ -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,
+26 -1
View File
@@ -42,6 +42,19 @@ export interface CognitoOptions extends ProviderOptions<CognitoProfile> {
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
+21 -17
View File
@@ -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<string, any> {
/** 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({
@@ -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<string, string> = {};
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 }) => {
+11 -6
View File
@@ -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 }) => {
+8 -1
View File
@@ -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;
@@ -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 }) => {
@@ -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 }) => {
@@ -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;
@@ -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 }) => {
+2 -1
View File
@@ -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 }) => {
+8 -1
View File
@@ -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 }) {
@@ -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 }) => {
+8 -1
View File
@@ -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 }) => {
@@ -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 }) => {
@@ -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 }) {
+2 -1
View File
@@ -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 }) => {
+8 -1
View File
@@ -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",
},
});
@@ -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;
},
+7 -1
View File
@@ -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;
},
+8 -1
View File
@@ -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 }) => {
@@ -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 }) => {
+2 -1
View File
@@ -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 }) => {
+16 -11
View File
@@ -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<string, any> {
/** 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({
@@ -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,
});
},
+15 -9
View File
@@ -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<string, any> {
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({
@@ -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();
});
});
@@ -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 }) => {
+22 -9
View File
@@ -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 }) => {
+2 -1
View File
@@ -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 }) => {
@@ -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 }) => {
+8 -1
View File
@@ -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 }) => {
+8 -1
View File
@@ -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 ({
+9 -1
View File
@@ -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;
+15 -19
View File
@@ -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,
+20
View File
@@ -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();
+64 -18
View File
@@ -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
+12
View File
@@ -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: {