From c9af50a75a074f64377dfdac7ccc78699002b62f Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Mon, 17 Aug 2026 13:12:57 +0530 Subject: [PATCH 1/4] feat!: remove auth layer, re-point Management token to @auth0/auth0-auth-js BREAKING CHANGE: removes AuthenticationClient and UserInfoClient from the auth0 package. Management API token acquisition now delegates to @auth0/auth0-auth-js AuthClient.getTokenByClientCredentials. mTLS now requires an explicit fetch option. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 +- .version | 2 +- eslint.config.mjs | 1 - package.json | 4 +- src/auth/backchannel.ts | 280 -------- src/auth/base-auth-api.ts | 146 ----- src/auth/client-authentication.ts | 66 -- src/auth/database.ts | 208 ------ src/auth/id-token-validator.ts | 174 ----- src/auth/index.ts | 74 --- src/auth/oauth.ts | 655 ------------------- src/auth/passwordless.ts | 263 -------- src/auth/tokenExchange.ts | 223 ------- src/index.ts | 2 - src/lib/middleware/auth0-client-telemetry.ts | 2 +- src/lib/runtime.ts | 279 -------- src/management/wrapper/ManagementClient.ts | 3 +- src/management/wrapper/token-provider.ts | 86 ++- src/userinfo/index.ts | 135 ---- src/utils.ts | 25 - yarn.lock | 28 +- 21 files changed, 103 insertions(+), 2555 deletions(-) delete mode 100644 src/auth/backchannel.ts delete mode 100644 src/auth/base-auth-api.ts delete mode 100644 src/auth/client-authentication.ts delete mode 100644 src/auth/database.ts delete mode 100644 src/auth/id-token-validator.ts delete mode 100644 src/auth/index.ts delete mode 100644 src/auth/oauth.ts delete mode 100644 src/auth/passwordless.ts delete mode 100644 src/auth/tokenExchange.ts delete mode 100644 src/lib/runtime.ts delete mode 100644 src/userinfo/index.ts diff --git a/.gitignore b/.gitignore index 7383de0dcc..87affff823 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules /dist /docs /coverage -*.lcov \ No newline at end of file +*.lcov.forge/ diff --git a/.version b/.version index f4c0c050a4..d18077c214 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -v6.2.0 \ No newline at end of file +v7.0.0 diff --git a/eslint.config.mjs b/eslint.config.mjs index eca8aab58e..039f9c7684 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -168,7 +168,6 @@ export default [ "*.config.mjs", "scripts/", "tests/data/", - "tests/auth/fixtures/", "**/*.d.ts", "**/*.d.mts", // Generated API files - these are auto-generated and should not be linted diff --git a/package.json b/package.json index f38fa8171f..af0b7579b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auth0", - "version": "6.2.0", + "version": "7.0.0", "private": false, "repository": { "type": "git", @@ -1699,7 +1699,7 @@ "validate": "yarn lint:check && yarn format --check && yarn build && yarn test && yarn lint:package" }, "dependencies": { - "uuid": "^11.1.1", + "@auth0/auth0-auth-js": "^1.12.1", "jose": "^5.0.0", "auth0-legacy": "npm:auth0@^4.37.1" }, diff --git a/src/auth/backchannel.ts b/src/auth/backchannel.ts deleted file mode 100644 index 2cd8425c60..0000000000 --- a/src/auth/backchannel.ts +++ /dev/null @@ -1,280 +0,0 @@ -// Wednesday, 8 January, 2025 -// Client Initiated Backchannel Authentication (CIBA) - -// CIBA is an OpenID Foundation standard for a decoupled authentication flow. It enables -// solution developers to build authentication flows where the user logging in does not do so -// directly on the device that receives the ID or access tokens (the “Consumption Device”), but -// instead on a separate “Authorization Device”. - -import { JSONApiResponse } from "../lib/models.js"; -import { BaseAuthAPI } from "./base-auth-api.js"; - -/** - * The response from the authorize endpoint. - */ -export type AuthorizeResponse = { - /** - * The authorization request ID. - */ - auth_req_id: string; - /** - * The duration in seconds until the authentication request expires. - */ - expires_in: number; - /** - * The interval in seconds to wait between poll requests. - */ - interval: number; -}; - -type AuthorizeCredentialsPartial = { - client_id: string; - client_secret?: string; - client_assertion?: string; - client_assertion_type?: string; -}; - -/** - * The login hint containing information about the user for authentication. - */ -type LoginHint = { - /** - * The format of the login hint. - */ - format: "iss_sub"; - /** - * The issuer URL. - */ - iss: string; - /** - * The subject identifier. - */ - sub: string; -}; - -/** - * Generates the login hint for the user. - * - * @param {string} userId - The user ID. - * @param {string} domain - The tenant domain. - * @returns {string} - The login hint as a JSON string. - */ -const getLoginHint = (userId: string, domain: string): string => { - // remove trailing '/' from domain, added later for uniformity - const trimmedDomain = domain.endsWith("/") ? domain.slice(0, -1) : domain; - const loginHint: LoginHint = { - format: "iss_sub", - iss: `https://${trimmedDomain}/`, - sub: `${userId}`, - }; - return JSON.stringify(loginHint); -}; - -/** - * Options for the authorize request. - */ -export type AuthorizeOptions = { - /** - * A human-readable string intended to be displayed on both the device calling /bc-authorize and the user’s authentication device. - */ - binding_message: string; - /** - * A space-separated list of OIDC and custom API scopes. - */ - scope: string; - /** - * Unique identifier of the audience for an issued token. - */ - audience?: string; - /** - * Custom expiry time in seconds for this request. - * @deprecated Use {@link AuthorizeOptions.requested_expiry} instead. - */ - request_expiry?: string; - /** - * Custom expiry time in seconds for this request. - */ - requested_expiry?: string; - /** - * The user ID. - */ - userId: string; - /** - * Optional parameter for subject issuer context. - */ - subjectIssuerContext?: string; - /** - * Optional authorization details to use Rich Authorization Requests (RAR). - * @see https://auth0.com/docs/get-started/apis/configure-rich-authorization-requests - */ - authorization_details?: string; -} & Record; - -type AuthorizeRequest = Omit & - AuthorizeCredentialsPartial & { - login_hint: string; - }; - -export interface AuthorizationDetails { - readonly type: string; - readonly [parameter: string]: unknown; -} - -/** - * The response from the token endpoint. - */ -export type TokenResponse = { - /** - * The access token. - */ - access_token: string; - /** - * The refresh token, available with the `offline_access` scope. - */ - refresh_token?: string; - /** - * The user's ID Token. - */ - id_token: string; - /** - * The token type of the access token. - */ - token_type?: string; - /** - * The duration in seconds that the access token is valid. - */ - expires_in: number; - /** - * The scopes associated with the token. - */ - scope: string; - /** - * Optional authorization details when using Rich Authorization Requests (RAR). - * @see https://auth0.com/docs/get-started/apis/configure-rich-authorization-requests - */ - authorization_details?: AuthorizationDetails[]; -}; - -/** - * Options for the token request. - */ -export type TokenOptions = { - /** - * The authorization request ID. - */ - auth_req_id: string; -}; - -type TokenRequestBody = AuthorizeCredentialsPartial & { - auth_req_id: string; - grant_type: string; -}; - -/** - * Interface for the backchannel authentication. - */ -export interface IBackchannel { - authorize: (options: AuthorizeOptions) => Promise; - backchannelGrant: (options: TokenOptions) => Promise; -} - -const CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba"; -const CIBA_AUTHORIZE_URL = "/bc-authorize"; -const CIBA_TOKEN_URL = "/oauth/token"; - -/** - * Class implementing the backchannel authentication flow. - */ -export class Backchannel extends BaseAuthAPI implements IBackchannel { - /** - * Initiates a CIBA authorization request. - * - * @param {AuthorizeOptions} options - The options for the request. - * @returns {Promise} - The authorization response. - * - * @throws {Error} - If the request fails. - */ - async authorize({ userId, ...options }: AuthorizeOptions): Promise { - const body: AuthorizeRequest = { - ...options, - login_hint: getLoginHint(userId, this.domain), - client_id: this.clientId, - }; - - // The correct parameter is `requested_expiry`, but we also accept the deprecated `request_expiry` for backwards compatibility - const requestedExpiry = options.requested_expiry || options.request_expiry; - if (requestedExpiry) { - body.requested_expiry = requestedExpiry; - } - - await this.addClientAuthentication(body); - - const response = await this.request.bind(this)( - { - path: CIBA_AUTHORIZE_URL, - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams(body), - }, - {}, - ); - - const r: JSONApiResponse = await JSONApiResponse.fromResponse(response); - return r.data; - } - - /** - * Handles the backchannel grant flow for authentication. Client can poll this method at regular intervals to check if the backchannel auth request has been approved. - * - * @param {string} auth_req_id - The authorization request ID. This value is returned from the call to /bc-authorize. Once you have exchanged an auth_req_id for an ID and access token, it is no longer usable. - * @returns {Promise} - A promise that resolves to the token response. - * - * @throws {Error} - Throws an error if the request fails. - * - * If the authorizing user has not yet approved or rejected the request, you will receive a response like this: - * ```json - * { - * "error": "authorization_pending", - * "error_description": "The end-user authorization is pending" - * } - * ``` - * - * If the authorizing user rejects the request, you will receive a response like this: - * ```json - * { - * "error": "access_denied", - * "error_description": "The end-user denied the authorization request or it has been expired" - * } - * ``` - * - * If you are polling too quickly (faster than the interval value returned from /bc-authorize), you will receive a response like this: - * ```json - * { - * "error": "slow_down", - * "error_description": "You are polling faster than allowed. Try again in 10 seconds." - * } - * ``` - */ - async backchannelGrant({ auth_req_id }: TokenOptions): Promise { - const body: TokenRequestBody = { - client_id: this.clientId, - auth_req_id, - grant_type: CIBA_GRANT_TYPE, - }; - - await this.addClientAuthentication(body); - - const response = await this.request.bind(this)( - { - path: CIBA_TOKEN_URL, - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams(body), - }, - {}, - ); - - const r: JSONApiResponse = await JSONApiResponse.fromResponse(response); - return r.data; - } -} diff --git a/src/auth/base-auth-api.ts b/src/auth/base-auth-api.ts deleted file mode 100644 index 5300e861b6..0000000000 --- a/src/auth/base-auth-api.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { ResponseError } from "../lib/errors.js"; -import { BaseAPI, ClientOptions, InitOverrideFunction, JSONApiResponse, RequestOpts } from "../lib/runtime.js"; -import { AddClientAuthenticationPayload, addClientAuthentication } from "./client-authentication.js"; -import { IDTokenValidator } from "./id-token-validator.js"; -import { GrantOptions, TokenSet } from "./oauth.js"; -import { Auth0ClientTelemetry } from "../lib/middleware/auth0-client-telemetry.js"; - -export interface AuthenticationClientOptions extends ClientOptions { - domain: string; - clientId: string; - clientSecret?: string; - clientAssertionSigningKey?: string; - clientAssertionSigningAlg?: string; - idTokenSigningAlg?: string; // default 'RS256' - clockTolerance?: number; // default 60s, - useMTLS?: boolean; -} - -interface AuthApiErrorResponse { - error_description: string; - error: string; -} - -export class AuthApiError extends Error { - override name = "AuthApiError" as const; - constructor( - public error: string, - public error_description: string, - public statusCode: number, - public body: string, - public headers: Headers, - ) { - super(error_description || error); - } -} - -function parseErrorBody(body: any): AuthApiErrorResponse { - const rawData = JSON.parse(body); - let data: AuthApiErrorResponse; - - if (rawData.error) { - data = rawData as AuthApiErrorResponse; - } else { - data = { - error: rawData.code, - error_description: rawData.description, - }; - } - - return data; -} - -async function parseError(response: Response) { - // Errors typically have a specific format: - // { - // error: 'invalid_body', - // error_description: 'Bad Request', - // } - - const body = await response.text(); - - try { - const data = parseErrorBody(body); - - return new AuthApiError(data.error, data.error_description, response.status, body, response.headers); - } catch { - return new ResponseError(response.status, body, response.headers, "Response returned an error code"); - } -} -export class BaseAuthAPI extends BaseAPI { - domain: string; - clientId: string; - clientSecret?: string; - clientAssertionSigningKey?: string; - clientAssertionSigningAlg?: string; - useMTLS?: boolean; - - constructor(options: AuthenticationClientOptions) { - super({ - ...options, - baseUrl: `https://${options.domain}`, - middleware: options.telemetry !== false ? [new Auth0ClientTelemetry(options)] : [], - parseError, - retry: { enabled: false, ...options.retry }, - }); - - this.domain = options.domain; - this.clientId = options.clientId; - this.clientSecret = options.clientSecret; - this.clientAssertionSigningKey = options.clientAssertionSigningKey; - this.clientAssertionSigningAlg = options.clientAssertionSigningAlg; - this.useMTLS = options.useMTLS; - } - - /** - * @private - */ - protected async addClientAuthentication( - payload: AddClientAuthenticationPayload, - ): Promise { - return addClientAuthentication({ - payload, - domain: this.domain, - clientId: this.clientId, - clientSecret: this.clientSecret, - clientAssertionSigningKey: this.clientAssertionSigningKey, - clientAssertionSigningAlg: this.clientAssertionSigningAlg, - useMTLS: this.useMTLS, - }); - } -} - -/** - * @private - * Perform an OAuth 2.0 grant. - */ -export async function grant( - grantType: string, - bodyParameters: Record, - { idTokenValidateOptions, initOverrides }: GrantOptions = {}, - clientId: string, - idTokenValidator: IDTokenValidator, - request: (context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) => Promise, -): Promise> { - const response = await request( - { - path: "/oauth/token", - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: clientId, - ...bodyParameters, - grant_type: grantType, - }), - }, - initOverrides, - ); - - const res: JSONApiResponse = await JSONApiResponse.fromResponse(response); - if (res.data.id_token) { - await idTokenValidator.validate(res.data.id_token, idTokenValidateOptions); - } - return res; -} diff --git a/src/auth/client-authentication.ts b/src/auth/client-authentication.ts deleted file mode 100644 index b4b753d694..0000000000 --- a/src/auth/client-authentication.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as jose from "jose"; -import { v4 as uuid } from "uuid"; - -export interface AddClientAuthenticationPayload { - client_id?: string; - client_secret?: string; - client_assertion?: string; - client_assertion_type?: string; - [key: string]: any; -} - -interface AddClientAuthenticationOptions { - payload: AddClientAuthenticationPayload; - domain: string; - clientId: string; - required?: boolean; - clientAssertionSigningKey?: string; - clientAssertionSigningAlg?: string; - clientSecret?: string; - useMTLS?: boolean; -} - -/** - * Adds client authentication, if available, to the provided payload. - * - * Adds `client_secret` for Client Secret Post token endpoint auth method (the SDK doesn't use Client Secret Basic) - * Adds `client_assertion` and `client_assertion_type` for Private Key JWT token endpoint auth method. - * - * If `clientAssertionSigningKey` is provided it takes precedent over `clientSecret` . - */ -export const addClientAuthentication = async ({ - payload, - domain, - clientId, - clientAssertionSigningKey, - clientAssertionSigningAlg, - clientSecret, - useMTLS, -}: AddClientAuthenticationOptions): Promise> => { - const cid = payload.client_id || clientId; - if (clientAssertionSigningKey && !payload.client_assertion) { - const alg = clientAssertionSigningAlg || "RS256"; - const privateKey = await jose.importPKCS8(clientAssertionSigningKey, alg); - - payload.client_assertion = await new jose.SignJWT({}) - .setProtectedHeader({ alg }) - .setIssuedAt() - .setSubject(cid) - .setJti(uuid()) - .setIssuer(cid) - .setAudience(`https://${domain}/`) - .setExpirationTime("2mins") - .sign(privateKey); - payload.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; - } else if (clientSecret && !payload.client_secret) { - payload.client_secret = clientSecret; - } - if ( - (!payload.client_secret || payload.client_secret.trim().length === 0) && - (!payload.client_assertion || payload.client_assertion.trim().length === 0) && - !useMTLS - ) { - throw new Error("The client_secret or client_assertion field is required, or it should be mTLS request."); - } - return payload; -}; diff --git a/src/auth/database.ts b/src/auth/database.ts deleted file mode 100644 index d19cb305b1..0000000000 --- a/src/auth/database.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { InitOverride, JSONApiResponse, TextApiResponse } from "../lib/models.js"; -import { validateRequiredRequestParams } from "../lib/runtime.js"; -import { BaseAuthAPI } from "./base-auth-api.js"; - -export interface SignUpRequest { - /** - * The client_id of your client. - * Use if you want to override the class's `clientId` - */ - client_id?: string; - /** - * The user's email address. - */ - email: string; - /** - * The user's desired password. - */ - password: string; - /** - * The name of the database configured to your client. - */ - connection: string; - /** - * The user's username. Only valid if the connection requires a username. - */ - username?: string; - /** - * The user's given name(s). - */ - given_name?: string; - /** - * The user's family name(s). - */ - family_name?: string; - /** - * The user's full name. - */ - name?: string; - /** - *The user's nickname. - */ - nickname?: string; - /** - * A URI pointing to the user's picture. - */ - picture?: string; - /** - * The user metadata to be associated with the user. If set, the field must be an object containing no more than ten properties. Property names can have a maximum of 100 characters, and property values must be strings of no more than 500 characters. - */ - user_metadata?: { [key: string]: unknown }; -} - -export interface SignUpResponse { - /** - * Email address of the new user. - */ - email: string; - /** - * Indicates whether the email has been verified or not. - */ - email_verified: boolean; - /** - * The server can return `_id`, `id` or `user_id` depending on various factors. - * For convenience we expose it here as just `id`. - */ - id: string; - /** - * Username of this user. - */ - username?: string; - /** - * The user's given name(s). - */ - given_name?: string; - /** - * The user's family name(s). - */ - family_name?: string; - /** - * The user's full name. - */ - name?: string; - /** - *The user's nickname. - */ - nickname?: string; - /** - * A URI pointing to the user's picture. - */ - picture?: string; - /** - * The user metadata to be associated with the user. If set, the field must be an object containing no more than ten properties. Property names can have a maximum of 100 characters, and property values must be strings of no more than 500 characters. - */ - user_metadata?: { [key: string]: unknown }; -} - -export interface ChangePasswordRequest { - /** - * The client_id of your client. - * Use if you want to override the class's `clientId` - */ - client_id?: string; - /** - * The user's email address. - */ - email: string; - /** - * The name of the database configured to your client. - */ - connection: string; - /** - * The organization_id of the Organization associated with the user. - */ - organization?: string; -} - -/** - * Sign-up and change-password for Database & Active Directory authentication services. - */ -export class Database extends BaseAuthAPI { - /** - * Given a user's credentials, and a connection, this endpoint will create a new user using active authentication. - * - * This endpoint only works for database connections. - * - * See: https://auth0.com/docs/api/authentication#signup - * - * @example - * ```js - * var data = { - * email: '{EMAIL}', - * password: '{PASSWORD}', - * connection: 'Username-Password-Authentication' - * }; - * - * await auth0.database.signUp(data); - * ``` - */ - async signUp( - bodyParameters: SignUpRequest, - initOverrides?: InitOverride, - ): Promise> { - // TODO: call this `validateRequiredParams` so we can use with bodyParameters in the auth api - validateRequiredRequestParams(bodyParameters, ["email", "password", "connection"]); - - const response = await this.request( - { - path: "/dbconnections/signup", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: { client_id: this.clientId, ...bodyParameters }, - }, - initOverrides, - ); - - // Transform the response to ensure id field is always available - const jsonResponse = await JSONApiResponse.fromResponse(response); - - if (jsonResponse.data) { - const data = jsonResponse.data as any; - // Map _id or user_id to id - if (!data.id && (data._id || data.user_id)) { - data.id = data._id || data.user_id; - } - } - - return jsonResponse as JSONApiResponse; - } - - /** - * Given a user's email address and a connection, Auth0 will send a change password email. - * - * This endpoint only works for database connections. - * - * See: https://auth0.com/docs/api/authentication#change-password - * - * @example - * ```js - * var data = { - * email: '{EMAIL}', - * connection: 'Username-Password-Authentication' - * }; - * - * await auth0.database.changePassword(data); - * ``` - */ - async changePassword( - bodyParameters: ChangePasswordRequest, - initOverrides?: InitOverride, - ): Promise { - validateRequiredRequestParams(bodyParameters, ["email", "connection"]); - const response = await this.request( - { - path: "/dbconnections/change_password", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: { client_id: this.clientId, ...bodyParameters }, - }, - initOverrides, - ); - - return TextApiResponse.fromResponse(response); - } -} diff --git a/src/auth/id-token-validator.ts b/src/auth/id-token-validator.ts deleted file mode 100644 index 348c4f0aac..0000000000 --- a/src/auth/id-token-validator.ts +++ /dev/null @@ -1,174 +0,0 @@ -import * as jose from "jose"; -import { AuthenticationClientOptions } from "./base-auth-api.js"; - -const DEFAULT_CLOCK_TOLERANCE = 60; // secs - -export class IdTokenValidatorError extends Error {} - -export interface IDTokenValidateOptions { - nonce?: string; - maxAge?: number; - organization?: string; -} - -export class IDTokenValidator { - private jwks: ( - protectedHeader?: jose.JWSHeaderParameters | undefined, - token?: jose.FlattenedJWSInput | undefined, - ) => Promise; - private alg: string; - private audience: string; - private issuer: string; - private clockTolerance: number; - private secret: Uint8Array; - - constructor({ - domain, - clientId, - clientSecret, - headers, - timeoutDuration, - idTokenSigningAlg = "RS256", - clockTolerance = DEFAULT_CLOCK_TOLERANCE, - }: AuthenticationClientOptions) { - this.jwks = jose.createRemoteJWKSet(new URL(`https://${domain}/.well-known/jwks.json`), { - timeoutDuration, - headers, - }); - - this.alg = idTokenSigningAlg; - this.audience = clientId; - this.secret = new TextEncoder().encode(clientSecret); - this.issuer = `https://${domain}/`; - this.clockTolerance = clockTolerance; - } - - async validate(idToken: string, { nonce, maxAge, organization }: IDTokenValidateOptions = {}) { - const secret = this.alg === "HS256" ? this.secret : this.jwks; - - const header = jose.decodeProtectedHeader(idToken); - const payload = jose.decodeJwt(idToken); - - // Check algorithm - if (header.alg !== "RS256" && header.alg !== "HS256") { - throw new Error( - `Signature algorithm of "${header.alg}" is not supported. Expected the ID token to be signed with "RS256" or "HS256".`, - ); - } - // Issuer - if (!payload.iss || typeof payload.iss !== "string") { - throw new IdTokenValidatorError("Issuer (iss) claim must be a string present in the ID token"); - } - if (payload.iss !== this.issuer) { - throw new IdTokenValidatorError( - `Issuer (iss) claim mismatch in the ID token; expected "${this.issuer}", found "${payload.iss}"`, - ); - } - - // Subject - if (!payload.sub || typeof payload.sub !== "string") { - throw new IdTokenValidatorError("Subject (sub) claim must be a string present in the ID token"); - } - - // Audience - if (!payload.aud || !(typeof payload.aud === "string" || Array.isArray(payload.aud))) { - throw new IdTokenValidatorError( - "Audience (aud) claim must be a string or array of strings present in the ID token", - ); - } - if (Array.isArray(payload.aud) && !payload.aud.includes(this.audience)) { - throw new IdTokenValidatorError( - `Audience (aud) claim mismatch in the ID token; expected "${ - this.audience - }" but was not one of "${payload.aud.join(", ")}"`, - ); - } else if (typeof payload.aud === "string" && payload.aud !== this.audience) { - throw new IdTokenValidatorError( - `Audience (aud) claim mismatch in the ID token; expected "${this.audience}" but found "${payload.aud}"`, - ); - } - - // Organization - if (organization) { - if (organization.indexOf("org_") === 0) { - if (!payload.org_id || typeof payload.org_id !== "string") { - throw new Error("Organization Id (org_id) claim must be a string present in the ID token"); - } - } else { - if (!payload.org_name || typeof payload.org_name !== "string") { - throw new Error("Organization Name (org_name) claim must be a string present in the ID token"); - } - } - } - - // Time validation (epoch) - const now = Math.floor(Date.now() / 1000); - - // Expires at - if (!payload.exp || typeof payload.exp !== "number") { - throw new IdTokenValidatorError("Expiration Time (exp) claim must be a number present in the ID token"); - } - const expTime = payload.exp + this.clockTolerance; - - if (now > expTime) { - throw new IdTokenValidatorError( - `Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expTime})`, - ); - } - - // Issued at - if (!payload.iat || typeof payload.iat !== "number") { - throw new IdTokenValidatorError("Issued At (iat) claim must be a number present in the ID token"); - } - - // Nonce - if (nonce || payload.nonce) { - if (!payload.nonce || typeof payload.nonce !== "string") { - throw new IdTokenValidatorError("Nonce (nonce) claim must be a string present in the ID token"); - } - if (payload.nonce !== nonce) { - throw new IdTokenValidatorError( - `Nonce (nonce) claim mismatch in the ID token; expected "${nonce}", found "${payload.nonce}"`, - ); - } - } - - // Authorized party - if (Array.isArray(payload.aud) && payload.aud.length > 1) { - if (!payload.azp || typeof payload.azp !== "string") { - throw new IdTokenValidatorError( - "Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values", - ); - } - if (payload.azp !== this.audience) { - throw new IdTokenValidatorError( - `Authorized Party (azp) claim mismatch in the ID token; expected "${this.audience}", found "${payload.azp}"`, - ); - } - } - - // Authentication time - if (maxAge) { - if (!payload.auth_time || typeof payload.auth_time !== "number") { - throw new IdTokenValidatorError( - "Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified", - ); - } - - const authValidUntil = payload.auth_time + maxAge + this.clockTolerance; - if (now > authValidUntil) { - throw new IdTokenValidatorError( - `Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Currrent time (${now}) is after last auth at ${authValidUntil}`, - ); - } - } - - await jose.jwtVerify(idToken, secret as any, { - issuer: this.issuer, - audience: this.audience, - clockTolerance: this.clockTolerance, - maxTokenAge: maxAge, - algorithms: ["HS256", "RS256"], - }); - } -} diff --git a/src/auth/index.ts b/src/auth/index.ts deleted file mode 100644 index 4b5f0e73b7..0000000000 --- a/src/auth/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Backchannel, IBackchannel } from "./backchannel.js"; -import { AuthenticationClientOptions } from "./base-auth-api.js"; -import { Database } from "./database.js"; -import { OAuth } from "./oauth.js"; -import { Passwordless } from "./passwordless.js"; -import { CustomTokenExchange, ICustomTokenExchange } from "./tokenExchange.js"; - -export * from "./database.js"; -export * from "./oauth.js"; -export * from "./passwordless.js"; -export { IDTokenValidateOptions, IdTokenValidatorError } from "./id-token-validator.js"; -export { AuthApiError, AuthenticationClientOptions } from "./base-auth-api.js"; - -/** - * Auth0 Authentication API Client - * - * Provides access to Auth0's authentication endpoints for login, signup, - * passwordless authentication, and token exchange operations. - * - * @group Authentication API - * - * @example Basic setup - * ```typescript - * import { AuthenticationClient } from 'auth0'; - * - * const auth0 = new AuthenticationClient({ - * domain: 'your-tenant.auth0.com', - * clientId: 'your-client-id' - * }); - * ``` - * - * @example OAuth login - * ```typescript - * // Exchange authorization code for tokens - * const tokenSet = await auth0.oauth.authorizationCodeGrant({ - * code: 'auth-code', - * redirect_uri: 'https://app.example.com/callback' - * }); - * ``` - * - * @example Database operations - * ```typescript - * // Create user - * const user = await auth0.database.signUp({ - * connection: 'Username-Password-Authentication', - * username: 'john@example.com', - * password: 'secure-password123' - * }); - * ``` - */ -export class AuthenticationClient { - /** Database connection operations (signup, change password) */ - database: Database; - /** OAuth 2.0 and OIDC operations (authorization, token exchange) */ - oauth: OAuth; - /** Passwordless authentication (email/SMS) */ - passwordless: Passwordless; - /** Back-channel authentication (CIBA) */ - backchannel: IBackchannel; - /** Custom token exchange operations */ - tokenExchange: ICustomTokenExchange; - - /** - * Create a new Authentication API client - * @param options - Configuration options for the client - */ - constructor(options: AuthenticationClientOptions) { - this.database = new Database(options); - this.oauth = new OAuth(options); - this.passwordless = new Passwordless(options); - this.backchannel = new Backchannel(options); - this.tokenExchange = new CustomTokenExchange(options); - } -} diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts deleted file mode 100644 index 62e3806e00..0000000000 --- a/src/auth/oauth.ts +++ /dev/null @@ -1,655 +0,0 @@ -import { InitOverride, JSONApiResponse, VoidApiResponse, validateRequiredRequestParams } from "../lib/runtime.js"; -import { BaseAuthAPI, AuthenticationClientOptions, grant } from "./base-auth-api.js"; -import { IDTokenValidateOptions, IDTokenValidator } from "./id-token-validator.js"; -import { mtlsPrefix } from "../utils.js"; - -export interface TokenSet { - /** - * The access token. - */ - access_token: string; - /** - * The refresh token, available with the `offline_access` scope. - */ - refresh_token?: string; - /** - * The user's ID Token. - */ - id_token?: string; - /** - * The token type of the access token. - */ - token_type: "Bearer"; - /** - * The duration in secs that the access token is valid. - */ - expires_in: number; -} - -export interface GrantOptions { - idTokenValidateOptions?: Pick; - initOverrides?: InitOverride; -} - -export interface AuthorizationCodeGrantOptions { - idTokenValidateOptions?: IDTokenValidateOptions; - initOverrides?: InitOverride; -} - -export interface ClientCredentials { - /** - * Specify this to override the parent class's `clientId` - */ - client_id?: string; - /** - * Specify this to override the parent class's `clientSecret` - */ - client_secret?: string; - /** - * Specify this to provide your own client assertion JWT rather than - * the class creating one for you from the `clientAssertionSigningKey`. - */ - client_assertion?: string; - /** - * If you provide your own `client_assertion` you should also provide - * the `client_assertion_type`. - */ - client_assertion_type?: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; -} - -export interface AuthorizationCodeGrantRequest extends ClientCredentials { - /** - * The Authorization Code received from the initial `/authorize` call. - */ - code: string; - /** - * This is required only if it was set at the `/authorize` endpoint. The values must match. - */ - redirect_uri?: string; - - /** - * Allow for any custom property to be sent to Auth0 - */ - [key: string]: any; -} - -export interface AuthorizationCodeGrantWithPKCERequest extends AuthorizationCodeGrantRequest { - /** - * Cryptographically random key that was used to generate the code_challenge passed to `/authorize`. - */ - code_verifier: string; -} - -/** - * Represents a request for a client credentials grant in the OAuth 2.0 framework, specific to Auth0 implementation. - * - * @property audience - The unique identifier of the target API you want to access. - * @property organization - The identifier of the organization for which the request is being made. - */ -export interface ClientCredentialsGrantRequest extends ClientCredentials { - /** - * The unique identifier of the target API you want to access. - */ - audience: string; - organization?: string; -} - -export interface PushedAuthorizationRequest extends ClientCredentials { - /** - * URI to redirect to. - */ - redirect_uri: string; - - /** - * The response_type the client expects. - */ - response_type: string; - - /** - * The response_mode to use. - */ - response_mode?: string; - - /** - * The nonce. - */ - nonce?: string; - - /** - * State value to be passed back on successful authorization. - */ - state?: string; - - /** - * Name of the connection. - */ - connection?: string; - - /** - * Scopes to request. Multiple scopes must be separated by a space character. - */ - scope?: string; - - /** - * The unique identifier of the target API you want to access. - */ - audience?: string; - - /** - * The organization to log the user in to. - */ - organization?: string; - - /** - * The id of an invitation to accept. - */ - invitation?: string; - /** - * A Base64-encoded SHA-256 hash of the {@link AuthorizationCodeGrantWithPKCERequest.code_verifier} used for the Authorization Code Flow with PKCE. - */ - code_challenge?: string; - - /** - * Allows JWT-Secured Authorization Request (JAR), when JAR & PAR request are used together. {@link https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow/authorization-code-flow-with-par-and-jar | Reference} - */ - request?: string; - - /** - * A JSON stringified array of objects. It can carry fine-grained authorization data in OAuth messages as part of Rich Authorization Requests (RAR) {@link https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow/authorization-code-flow-with-rar | Reference} - */ - authorization_details?: string; - - /** - * Allow for any custom property to be sent to Auth0 - */ - [key: string]: any; -} - -export interface PushedAuthorizationResponse { - /** - * The request URI corresponding to the authorization request posted. - * This URI is a single-use reference to the respective request data in the subsequent authorization request. - */ - request_uri: string; - - /** - * This URI is a single-use reference to the respective request data in the subsequent authorization request. - */ - expires_in: number; -} - -export interface PasswordGrantRequest extends ClientCredentials { - /** - * The unique identifier of the target API you want to access. - */ - audience?: string; - /** - * Resource Owner's identifier, such as a username or email address. - */ - username: string; - /** - * Resource Owner's secret. - */ - password: string; - /** - * String value of the different scopes the application is asking for. Multiple scopes are separated with whitespace. - */ - scope?: string; - /** - * String value of the realm the user belongs. Set this if you want to add realm support at this grant. - * For more information on what realms are refer to https://auth0.com/docs/get-started/authentication-and-authorization-flow/resource-owner-password-flow#realm-support. - */ - realm?: string; -} - -export interface DeviceCodeGrantRequest { - /** - * Specify this to override the parent class's `clientId` - */ - client_id?: string; - - /** - * The device code previously returned from the `/oauth/device/code` endpoint. - */ - device_code: string; -} - -export interface RefreshTokenGrantRequest extends ClientCredentials { - /** - * The Refresh Token to use. - */ - refresh_token: string; - - /** - * A space-delimited list of requested scope permissions. - * If not sent, the original scopes will be used; otherwise you can request a reduced set of scopes. - */ - scope?: string; - - /** - * Allow for any custom property to be sent to Auth0 - */ - [key: string]: any; -} - -export interface RevokeRefreshTokenRequest extends ClientCredentials { - /** - * The Refresh Token you want to revoke. - */ - token: string; -} - -export interface TokenExchangeGrantRequest { - /** - * Specify this to override the parent class's `clientId` - */ - client_id?: string; - - /** - * Externally-issued identity artifact, representing the user. - */ - subject_token: string; - - /** - * The unique identifier of the target API you want to access. - */ - audience?: string; - - /** - * String value of the different scopes the application is requesting. - * Multiple scopes are separated with whitespace. - */ - scope?: string; - - /** - * Optional element used for native iOS interactions for which profile updates can occur. - * Expected parameter value will be JSON in the form of: `{ name: { firstName: 'John', lastName: 'Smith }}` - */ - user_profile: string; -} - -/** - * Options to exchange a federated connection token. - */ -export interface TokenForConnectionRequest { - /** - * The subject token to exchange for an access token for a connection. - */ - subject_token: string; - /** - * The target social provider connection (e.g., "google-oauth2"). - */ - connection: string; - /** - * An optional subject token type parameter to pass to the authorization server. If not provided, it defaults to `urn:ietf:params:oauth:token-type:refresh_token`. - */ - subject_token_type?: SUBJECT_TOKEN_TYPES; - /** - * Optional login hint - */ - login_hint?: string; -} - -export interface TokenForConnectionResponse { - access_token: string; - scope?: string; - expires_at: number; // the time at which the access token expires in seconds since epoch - connection: string; - [key: string]: unknown; -} - -export enum SUBJECT_TOKEN_TYPES { - /** - * Constant representing the subject type for a refresh token. - * This is used in OAuth 2.0 token exchange to specify that the token being exchanged is a refresh token. - * - * @see {@link https://tools.ietf.org/html/rfc8693#section-3.1 RFC 8693 Section 3.1} - */ - REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token", - - /** - * Constant representing the subject type for a access token. - * This is used in OAuth 2.0 token exchange to specify that the token being exchanged is an access token. - * - * @see {@link https://tools.ietf.org/html/rfc8693#section-3.1 RFC 8693 Section 3.1} - */ - ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token", -} - -export const TOKEN_FOR_CONNECTION_GRANT_TYPE = - "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token"; - -/** - * @deprecated Use {@link SUBJECT_TOKEN_TYPES.REFRESH_TOKEN} instead. - */ -export const TOKEN_FOR_CONNECTION_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token"; -export const TOKEN_FOR_CONNECTION_REQUESTED_TOKEN_TYPE = - "http://auth0.com/oauth/token-type/federated-connection-access-token"; - -export const TOKEN_URL = "/oauth/token"; - -/** - * OAuth 2.0 flows. - */ -export class OAuth extends BaseAuthAPI { - readonly idTokenValidator: IDTokenValidator; - constructor(options: AuthenticationClientOptions) { - super({ - ...options, - domain: options.useMTLS ? `${mtlsPrefix}.${options.domain}` : options.domain, - }); - this.idTokenValidator = new IDTokenValidator(options); - } - - /** - * This is the flow that regular web apps use to access an API. - * - * Use this endpoint to exchange an Authorization Code for a Token. - * - * See: https://auth0.com/docs/api/authentication#authorization-code-flow44 - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId', - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.oauth.authorizationCodeGrant({ code: 'mycode' }); - * ``` - */ - async authorizationCodeGrant( - bodyParameters: AuthorizationCodeGrantRequest, - options: AuthorizationCodeGrantOptions = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["code"]); - - return grant( - "authorization_code", - await this.addClientAuthentication(bodyParameters), - options, - this.clientId, - this.idTokenValidator, - this.request.bind(this), - ); - } - - /** - * PKCE was originally designed to protect the authorization code flow in mobile apps, - * but its ability to prevent authorization code injection makes it useful for every type of OAuth client, - * even web apps that use client authentication. - * - * See: https://auth0.com/docs/api/authentication#authorization-code-flow-with-pkce45 - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId', - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.oauth.authorizationCodeGrantWithPKCE({ - * code: 'mycode', - * code_verifier: 'mycodeverifier' - * }); - * ``` - */ - async authorizationCodeGrantWithPKCE( - bodyParameters: AuthorizationCodeGrantWithPKCERequest, - options: AuthorizationCodeGrantOptions = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["code", "code_verifier"]); - - return grant( - "authorization_code", - await this.addClientAuthentication(bodyParameters), - options, - this.clientId, - this.idTokenValidator, - this.request.bind(this), - ); - } - - /** - * This is the OAuth 2.0 grant that server processes use to access an API. - * - * Use this endpoint to directly request an Access Token by using the Client's credentials - * (a Client ID and a Client Secret or a Client Assertion). - * - * See: https://auth0.com/docs/api/authentication#client-credentials-flow - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId', - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.oauth.clientCredentialsGrant({ audience: 'myaudience' }); - * ``` - */ - async clientCredentialsGrant( - bodyParameters: ClientCredentialsGrantRequest, - options: { initOverrides?: InitOverride } = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["audience"]); - - return grant( - "client_credentials", - await this.addClientAuthentication(bodyParameters), - options, - this.clientId, - this.idTokenValidator, - this.request.bind(this), - ); - } - - /** - * This is the OAuth 2.0 extension that allows to initiate an OAuth flow from the backchannel instead of by building a URL. - * - * - * See: https://www.rfc-editor.org/rfc/rfc9126.html - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId', - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.oauth.pushedAuthorization({ response_type: 'id_token', redirect_uri: 'http://localhost' }); - * ``` - */ - async pushedAuthorization( - bodyParameters: PushedAuthorizationRequest, - options: { initOverrides?: InitOverride } = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["client_id", "response_type", "redirect_uri"]); - - const bodyParametersWithClientAuthentication = await this.addClientAuthentication(bodyParameters); - - const response = await this.request( - { - path: "/oauth/par", - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: this.clientId, - ...bodyParametersWithClientAuthentication, - }), - }, - options.initOverrides, - ); - - return JSONApiResponse.fromResponse(response); - } - - /** - * This information is typically received from a highly trusted public client like a SPA*. - * (*Note: For single-page applications and native/mobile apps, we recommend using web flows instead.) - * - * See: https://auth0.com/docs/api/authentication#resource-owner-password - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId' - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.oauth.passwordGrant({ - * username: 'myusername@example.com', - * password: 'mypassword' - * }, - * { initOverrides: { headers: { 'auth0-forwarded-for': 'END.USER.IP.123' } } } - * ); - * ``` - * - * Set the'auth0-forwarded-for' header to the end-user IP as a string value if you want - * brute-force protection to work in server-side scenarios. - * - * See https://auth0.com/docs/get-started/authentication-and-authorization-flow/avoid-common-issues-with-resource-owner-password-flow-and-attack-protection - * - */ - async passwordGrant( - bodyParameters: PasswordGrantRequest, - options: GrantOptions = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["username", "password"]); - - return grant( - bodyParameters.realm ? "http://auth0.com/oauth/grant-type/password-realm" : "password", - await this.addClientAuthentication(bodyParameters), - options, - this.clientId, - this.idTokenValidator, - this.request.bind(this), - ); - } - - /** - * Use this endpoint to refresh an Access Token using the Refresh Token you got during authorization. - * - * See: https://auth0.com/docs/api/authentication#refresh-token - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId' - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.oauth.refreshTokenGrant({ refresh_token: 'myrefreshtoken' }) - * ``` - */ - async refreshTokenGrant( - bodyParameters: RefreshTokenGrantRequest, - options: GrantOptions = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["refresh_token"]); - - return grant( - "refresh_token", - await this.addClientAuthentication(bodyParameters), - options, - this.clientId, - this.idTokenValidator, - this.request.bind(this), - ); - } - - /** - * Use this endpoint to invalidate a Refresh Token if it has been compromised. - * - * The behaviour of this endpoint depends on the state of the Refresh Token Revocation Deletes Grant toggle. - * If this toggle is enabled, then each revocation request invalidates not only the specific token, but all other tokens based on the same authorization grant. - * This means that all Refresh Tokens that have been issued for the same user, application, and audience will be revoked. - * If this toggle is disabled, then only the refresh token is revoked, while the grant is left intact. - * - * See: https://auth0.com/docs/api/authentication#revoke-refresh-token - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId' - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.oauth.revokeRefreshToken({ token: 'myrefreshtoken' }) - * ``` - */ - async revokeRefreshToken( - bodyParameters: RevokeRefreshTokenRequest, - options: { initOverrides?: InitOverride } = {}, - ): Promise { - validateRequiredRequestParams(bodyParameters, ["token"]); - - const response = await this.request( - { - path: "/oauth/revoke", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: await this.addClientAuthentication({ client_id: this.clientId, ...bodyParameters }), - }, - options.initOverrides, - ); - - return VoidApiResponse.fromResponse(response); - } - - /** - * Exchanges a subject token for an access token for the connection. - * - * The request body includes: - * - client_id (and client_secret/client_assertion via addClientAuthentication) - * - grant_type set to `urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token` - * - subject_token: the token to exchange - * - subject_token_type: the type of token being exchanged. Defaults to refresh tokens (`urn:ietf:params:oauth:token-type:refresh_token`). - * - requested_token_type (`http://auth0.com/oauth/token-type/federated-connection-access-token`) indicating that a federated connection access token is desired - * - connection name and an optional `login_hint` if provided - * - * @param bodyParameters - The options to retrieve a token for a connection. - * @returns A promise with the token response data. - * @throws An error if the exchange fails. - */ - public async tokenForConnection( - bodyParameters: TokenForConnectionRequest, - options: { initOverrides?: InitOverride } = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["connection", "subject_token"]); - - const body: Record = { - subject_token_type: SUBJECT_TOKEN_TYPES.REFRESH_TOKEN, - ...bodyParameters, - grant_type: TOKEN_FOR_CONNECTION_GRANT_TYPE, - requested_token_type: TOKEN_FOR_CONNECTION_REQUESTED_TOKEN_TYPE, - }; - - await this.addClientAuthentication(body); - - const response = await this.request( - { - path: TOKEN_URL, - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams(body), - }, - options.initOverrides, - ); - - return JSONApiResponse.fromResponse(response); - } -} diff --git a/src/auth/passwordless.ts b/src/auth/passwordless.ts deleted file mode 100644 index 129330a9dd..0000000000 --- a/src/auth/passwordless.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { InitOverride, JSONApiResponse, VoidApiResponse, validateRequiredRequestParams } from "../lib/runtime.js"; -import { BaseAuthAPI, AuthenticationClientOptions, grant } from "./base-auth-api.js"; -import { IDTokenValidator } from "./id-token-validator.js"; -import { ClientCredentials, GrantOptions, TokenSet } from "./oauth.js"; - -export interface SendEmailLinkRequest { - /** - * The user's email address - */ - email: string; - /** - * Use `link` to send a link or `code` to send a verification code. - * If omitted, a `link` will be sent. - */ - send?: "link"; - /** - * Append or override the link parameters (like `scope`, `redirect_uri`, `protocol`, `response_type`), - * when you send a link using email. - */ - authParams?: Record; -} - -export interface SendEmailCodeRequest { - /** - * The user's email address - */ - email: string; - /** - * Use `link` to send a link or `code` to send a verification code. - * If omitted, a `link` will be sent. - */ - send?: "code"; -} - -export type SendEmailRequest = SendEmailLinkRequest | SendEmailCodeRequest; - -export interface SendSmsRequest { - /** - * The users phone number. - */ - phone_number: string; -} - -export interface LoginWithEmailRequest extends ClientCredentials { - /** - * The user's email address. - */ - email: string; - /** - * The user's verification code. - */ - code: string; - /** - * API Identifier of the API for which you want to get an Access Token. - */ - audience?: string; - /** - * Use openid to get an ID Token, or openid profile email to also include user profile information in the ID Token. - */ - scope?: string; -} - -export interface LoginWithSMSRequest extends Omit { - /** - * The user's phone number. - */ - phone_number: string; -} - -/** - * Handles passwordless flows using Email and SMS. - */ -export class Passwordless extends BaseAuthAPI { - private idTokenValidator: IDTokenValidator; - constructor(configuration: AuthenticationClientOptions) { - super(configuration); - - this.idTokenValidator = new IDTokenValidator(configuration); - } - - /** - * Start passwordless flow sending an email. - * - * Given the user `email` address, it will send an email with: - * - *
    - *
  • A link (default, `send:"link"`). You can then authenticate with this - * user opening the link and he will be automatically logged in to the - * application. Optionally, you can append/override parameters to the link - * (like `scope`, `redirect_uri`, `protocol`, `response_type`, etc.) using - * `authParams` object. - *
  • - *
  • - * A verification code (`send:"code"`). You can then authenticate with - * this user using the `/oauth/token` endpoint specifying `email` as - * `username` and `code` as `password`. - *
  • - *
- * - * See: https://auth0.com/docs/api/authentication#get-code-or-link - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId', - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.passwordless.sendEmail({ - * email: '{EMAIL}', - * send: 'link', - * authParams: {} // Optional auth params. - * }); - * ``` - */ - async sendEmail(bodyParameters: SendEmailRequest, initOverrides?: InitOverride): Promise { - validateRequiredRequestParams(bodyParameters, ["email"]); - - const response = await this.request( - { - path: "/passwordless/start", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: await this.addClientAuthentication({ - client_id: this.clientId, - connection: "email", - ...bodyParameters, - }), - }, - initOverrides, - ); - - return VoidApiResponse.fromResponse(response); - } - - /** - * Start passwordless flow sending an SMS. - * - * Given the user `phone_number`, it will send a SMS message with a - * verification code. You can then authenticate with this user using the - * `/oauth/token` endpoint specifying `phone_number` as `username` and `code` as - * `password`: - * - * See: https://auth0.com/docs/api/authentication#get-code-or-link - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId', - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.passwordless.sendSMS({ - * phone_number: '{PHONE}' - * }); - * ``` - */ - async sendSMS(bodyParameters: SendSmsRequest, initOverrides?: InitOverride): Promise { - validateRequiredRequestParams(bodyParameters, ["phone_number"]); - - const response = await this.request( - { - path: "/passwordless/start", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: await this.addClientAuthentication({ - client_id: this.clientId, - connection: "sms", - ...bodyParameters, - }), - }, - initOverrides, - ); - - return VoidApiResponse.fromResponse(response); - } - - /** - * Once you have a verification code, use this endpoint to login the user with their email and verification code. - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId', - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.passwordless.loginWithEmail({ - * email: 'foo@example.com', - * code: 'ABC123' - * }); - * ``` - */ - async loginWithEmail( - bodyParameters: LoginWithEmailRequest, - options: GrantOptions = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["email", "code"]); - - const { email: username, code: otp, ...otherParams } = bodyParameters; - - return grant( - "http://auth0.com/oauth/grant-type/passwordless/otp", - await this.addClientAuthentication({ - username, - otp, - realm: "email", - ...otherParams, - }), - options, - this.clientId, - this.idTokenValidator, - this.request.bind(this), - ); - } - - /** - * Once you have a verification code, use this endpoint to login the user with their phone number and verification code. - * - * @example - * ```js - * const auth0 = new AuthenticationApi({ - * domain: 'my-domain.auth0.com', - * clientId: 'myClientId', - * clientSecret: 'myClientSecret' - * }); - * - * await auth0.passwordless.loginWithSMS({ - * phone_number: '0777777777', - * code: 'ABC123' - * }); - * ``` - */ - async loginWithSMS( - bodyParameters: LoginWithSMSRequest, - options: GrantOptions = {}, - ): Promise> { - validateRequiredRequestParams(bodyParameters, ["phone_number", "code"]); - - const { phone_number: username, code: otp, ...otherParams } = bodyParameters; - - return grant( - "http://auth0.com/oauth/grant-type/passwordless/otp", - await this.addClientAuthentication({ - username, - otp, - realm: "sms", - ...otherParams, - }), - options, - this.clientId, - this.idTokenValidator, - this.request.bind(this), - ); - } -} diff --git a/src/auth/tokenExchange.ts b/src/auth/tokenExchange.ts deleted file mode 100644 index eb6a6320a8..0000000000 --- a/src/auth/tokenExchange.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { JSONApiResponse } from "../lib/models.js"; -import { BaseAuthAPI } from "./base-auth-api.js"; - -/** - * Represents the configuration options required for initiating a Custom Token Exchange request - * following RFC 8693 specifications. - * - * @see {@link https://www.rfc-editor.org/rfc/rfc8693 | RFC 8693: OAuth 2.0 Token Exchange} - */ -export type CustomTokenExchangeOptions = { - /** - * The type identifier for the subject token being exchanged - * - * @pattern - * - Must be a namespaced URI under your organization's control - * - Forbidden patterns: - * - `^urn:ietf:params:oauth:*` (IETF reserved) - * - `^https:\/\/auth0\.com/*` (Auth0 reserved) - * - `^urn:auth0:*` (Auth0 reserved) - * - * @example - * "urn:acme:legacy-system-token" - * "https://api.yourcompany.com/token-type/v1" - */ - subject_token_type: string; - - /** - * The opaque token value being exchanged for Auth0 tokens - * - * @security - * - Must be validated in Auth0 Actions using strong cryptographic verification - * - Implement replay attack protection - * - Recommended validation libraries: `jose`, `jsonwebtoken` - * - * @example - * "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" - */ - subject_token: string; - - /** - * The target audience for the requested Auth0 token - * - * @remarks - * Must match exactly with an API identifier configured in your Auth0 tenant - * - * @example - * "https://api.your-service.com/v1" - */ - audience: string; - - /** - * Space-separated list of OAuth 2.0 scopes being requested - * - * @remarks - * Subject to API authorization policies configured in Auth0 - * - * @example - * "openid profile email read:data write:data" - */ - scope?: string; - - /** - * Additional custom parameters for Auth0 Action processing - * - * @remarks - * Accessible in Action code via `event.request.body` - * - * @example - * ```typescript - * { - * custom_parameter: "session_context", - * device_fingerprint: "a3d8f7...", - * } - * ``` - */ - [key: string]: unknown; -}; - -/** - * Internal request body structure for token exchange endpoint - * - * @privateRemarks - * Combines user parameters with OAuth 2.0 required values and - * client authentication managed by BaseAuthAPI - */ -type CustomTokenExchangeRequestBody = CustomTokenExchangeOptions & { - /** @default "urn:ietf:params:oauth:grant-type:token-exchange" */ - grant_type: "urn:ietf:params:oauth:grant-type:token-exchange"; - - /** Injected from BaseAuthAPI configuration */ - client_id: string; -}; - -/** - * Interface defining Custom Token Exchange operations - * - * @see {@link https://auth0.com/docs/authenticate/protocols/custom-token-exchange | Auth0 Custom Token Exchange Docs} - */ -export interface ICustomTokenExchange { - /** - * Executes RFC 8693-compliant token exchange flow - * - * @throws {Auth0Error} For structured error responses - * @throws {Error} For generic errors with these codes: - * - `invalid_request`: Invalid parameters - * - `consent_required`: Enable "Allow Skipping User Consent" in API settings - * - `too_many_attempts`: Suspicious IP throttling triggered - * - * @example - * ```typescript - * // External IdP migration scenario - * const tokens = await auth0.customTokenExchange.exchangeToken({ - * subject_token_type: 'urn:external-idp:legacy', - * subject_token: externalIdPToken, - * audience: 'https://api.your-service.com', - * scope: 'openid profile' - * }); - * ``` - */ - exchangeToken(options: CustomTokenExchangeOptions): Promise; -} - -/** RFC 8693-defined grant type for token exchange */ -const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; -/** Auth0 token endpoint path */ -const TOKEN_URL = "/oauth/token"; - -/** - * Implements Auth0's Custom Token Exchange functionality with security best practices - * - * @security - * - **HTTPS Enforcement**: All requests require TLS encryption - * - **Credential Protection**: Client secrets never exposed in browser contexts - * - **Input Validation**: Strict namespace enforcement for token types - * - * @example - * ```typescript - * // Secure token validation in Auth0 Action - * exports.onExecuteCustomTokenExchange = async (event, api) => { - * const { jws } = require('jose'); - * const { createRemoteJWKSet } = require('jose/jwks'); - * - * const JWKS = createRemoteJWKSet(new URL('https://external-idp.com/.well-known/jwks.json')); - * - * try { - * const { payload } = await jws.verify(event.transaction.subject_token, JWKS); - * api.authentication.setUserById(payload.sub); - * } catch (error) { - * api.access.rejectInvalidSubjectToken('Invalid token signature'); - * } - * }; - * ``` - */ -export class CustomTokenExchange extends BaseAuthAPI implements ICustomTokenExchange { - /** - * Executes token exchange flow with security validations - * - * @param options - Exchange configuration parameters - * @returns Auth0-issued tokens with requested claims - * - * @throws {Error} When: - * - `subject_token_type` uses prohibited namespace - * - Network failures occur - * - Auth0 returns error responses (4xx/5xx) - */ - async exchangeToken(options: CustomTokenExchangeOptions): Promise { - const body: CustomTokenExchangeRequestBody = { - ...options, - grant_type: TOKEN_EXCHANGE_GRANT_TYPE, - client_id: this.clientId, - }; - - await this.addClientAuthentication(body); - - const response = await this.request( - { - path: TOKEN_URL, - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams(body as Record), - }, - {}, - ); - - const r: JSONApiResponse = await JSONApiResponse.fromResponse(response); - return r.data; - } -} - -/** - * Standardized token response structure for Auth0 authentication flows - * - * @remarks - * **Token Lifetime Management**: - * - Cache tokens according to `expires_in` value - * - Rotate refresh tokens using `offline_access` scope - * - Revoke compromised tokens immediately - * - * @security - * - Store tokens in secure, encrypted storage - * - Never expose in client-side code or logs - */ -export type TokenResponse = { - /** Bearer token for API authorization */ - access_token: string; - - /** Refresh token (requires `offline_access` scope) */ - refresh_token?: string; - - /** JWT containing user identity claims */ - id_token: string; - - /** Typically "Bearer" */ - token_type?: string; - - /** Token validity in seconds (default: 86400) */ - expires_in: number; - - /** Granted permissions space */ - scope: string; -}; diff --git a/src/index.ts b/src/index.ts index efa2266294..26ec5efcbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ export * from "./management/index.js"; -export * from "./auth/index.js"; -export * from "./userinfo/index.js"; export * from "./lib/errors.js"; export * from "./lib/models.js"; export * from "./lib/httpResponseHeadersUtils.js"; diff --git a/src/lib/middleware/auth0-client-telemetry.ts b/src/lib/middleware/auth0-client-telemetry.ts index e3fca78b75..75a6856812 100644 --- a/src/lib/middleware/auth0-client-telemetry.ts +++ b/src/lib/middleware/auth0-client-telemetry.ts @@ -1,5 +1,5 @@ import { generateClientInfo } from "../../utils.js"; -import { Middleware, ClientOptions, FetchParams, RequestContext } from "../runtime.js"; +import { Middleware, ClientOptions, FetchParams, RequestContext } from "../models.js"; import { base64url } from "jose"; /** diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts deleted file mode 100644 index 52b79bb79b..0000000000 --- a/src/lib/runtime.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { retry } from "./retry.js"; -import { FetchError, RequiredError, TimeoutError } from "./errors.js"; -import { RequestOpts, InitOverrideFunction, HTTPQuery, Configuration, Middleware, FetchAPI } from "./models.js"; - -export * from "./models.js"; - -/** - * @private - * This is the base class for all generated API classes. - */ -export class BaseAPI { - private middleware: Middleware[]; - private fetchApi: FetchAPI; - private parseError: (response: Response) => Promise | Error; - private timeoutDuration: number; - - constructor(protected configuration: Configuration) { - if (configuration.baseUrl === null || configuration.baseUrl === undefined) { - throw new Error("Must provide a base URL for the API"); - } - - if ("string" !== typeof configuration.baseUrl || configuration.baseUrl.length === 0) { - throw new Error("The provided base URL is invalid"); - } - - this.middleware = configuration.middleware || []; - this.fetchApi = configuration.fetch || globalThis.fetch.bind(globalThis); - this.parseError = configuration.parseError; - this.timeoutDuration = - typeof configuration.timeoutDuration === "number" ? configuration.timeoutDuration : 10000; - } - - protected async request( - context: RequestOpts, - initOverrides?: RequestInit | InitOverrideFunction, - ): Promise { - const { url, init } = await this.createFetchParams(context, initOverrides); - const response = await this.fetch(url, init); - if (response && response.status >= 200 && response.status < 300) { - return response; - } - - const error = await this.parseError(response); - throw error; - } - - private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) { - let url = this.configuration.baseUrl + context.path; - if (context.query !== undefined && Object.keys(context.query).length !== 0) { - // only add the querystring to the URL if there are query parameters. - // this is done to avoid urls ending with a "?" character which buggy webservers - // do not handle correctly sometimes. - url += `?${querystring(context.query)}`; - } - - const headers = Object.assign({}, this.configuration.headers, context.headers); - Object.keys(headers).forEach((key) => (headers[key] === undefined ? delete headers[key] : {})); - - const initOverrideFn = typeof initOverrides === "function" ? initOverrides : async () => initOverrides; - - const initParams = { - method: context.method, - headers, - body: context.body, - dispatcher: this.configuration.agent, - }; - - const overriddenInit: RequestInit = { - ...initParams, - ...(await initOverrideFn({ - init: initParams, - context, - })), - }; - - const init: RequestInit = { - ...overriddenInit, - body: - overriddenInit.body instanceof FormData || - overriddenInit.body instanceof URLSearchParams || - overriddenInit.body instanceof Blob - ? overriddenInit.body - : JSON.stringify(overriddenInit.body), - }; - return { url, init }; - } - - private fetchWithTimeout: FetchAPI = async (url, init) => { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, this.timeoutDuration); - try { - return await this.fetchApi(url, { signal: controller.signal as AbortSignal, ...init }); - } catch (e: any) { - if (e.name === "AbortError") { - throw new TimeoutError(); - } - throw e; - } finally { - clearTimeout(timeout); - } - }; - - private fetch = async (url: string | URL | Request, init: RequestInit) => { - let fetchParams = { url, init }; - for (const middleware of this.middleware) { - if (middleware.pre) { - fetchParams = - (await middleware.pre({ - fetch: this.fetchWithTimeout, - ...fetchParams, - })) || fetchParams; - } - } - let response: Response | undefined = undefined; - let error: Error | undefined = undefined; - try { - response = - this.configuration.retry?.enabled !== false - ? await retry(() => this.fetchWithTimeout(fetchParams.url, fetchParams.init), { - ...this.configuration.retry, - }) - : await this.fetchWithTimeout(fetchParams.url, fetchParams.init); - } catch (e: any) { - error = e; - } - if (error || !(response as Response).ok) { - for (const middleware of this.middleware) { - if (middleware.onError) { - response = - (await middleware.onError({ - fetch: this.fetchWithTimeout, - ...fetchParams, - error, - response: response ? response.clone() : undefined, - })) || response; - } - } - if (response === undefined) { - throw new FetchError( - error as Error, - "The request failed and the interceptors did not return an alternative response", - ); - } - } else { - for (const middleware of this.middleware) { - if (middleware.post) { - response = - (await middleware.post({ - fetch: this.fetchApi, - ...fetchParams, - response: (response as Response).clone(), - })) || response; - } - } - } - - return response as Response; - }; -} - -/** - * @private - */ -export const COLLECTION_FORMATS = { - csv: ",", - ssv: " ", - tsv: "\t", - pipes: "|", -}; - -/** - * @private - */ -export type ModelPropertyNaming = "camelCase" | "snake_case" | "PascalCase" | "original"; - -/** - * @private - */ -function querystring(params: HTTPQuery): string { - return Object.keys(params) - .map((key) => querystringSingleKey(key, params[key])) - .filter((part) => part.length > 0) - .join("&"); -} - -function querystringSingleKey( - key: string, - value: string | number | null | undefined | boolean | Array | HTTPQuery, -): string { - if (value instanceof Array) { - const multiValue = value - .map((singleValue) => encodeURIComponent(String(singleValue))) - .join(`&${encodeURIComponent(key)}=`); - return `${encodeURIComponent(key)}=${multiValue}`; - } - return `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`; -} - -/** - * @private - */ -export interface Consume { - contentType: string; -} - -/** - * @private - */ -export function validateRequiredRequestParams( - requestParameters: TRequestParams, - keys: Array>, -) { - keys.forEach((key) => { - if (requestParameters[key] === null || requestParameters[key] === undefined) { - throw new RequiredError(key, `Required parameter requestParameters.${key} was null or undefined.`); - } - }); -} - -type QueryParamConfig = { - isArray?: boolean; - isCollectionFormatMulti?: boolean; - collectionFormat?: keyof typeof COLLECTION_FORMATS; -}; - -/** - * @private - */ -export function applyQueryParams< - TRequestParams extends { [key: string]: any }, - Key extends Extract, ->( - requestParameters: TRequestParams, - keys: Array<{ - key: Key; - config: QueryParamConfig; - }>, -) { - return keys.reduce( - ( - acc: { [key: string]: any }, - { - key, - config, - }: { - key: Key; - config: QueryParamConfig; - }, - ) => { - let value; - - if (config.isArray) { - if (config.isCollectionFormatMulti) { - value = requestParameters[key]; - } else { - value = requestParameters[key].join(COLLECTION_FORMATS[config.collectionFormat!]); - } - } else { - if (requestParameters[key] !== undefined) { - value = requestParameters[key]; - } - } - - return value !== undefined ? { ...acc, [key]: value } : acc; - }, - {}, - ) as Pick[number]>; -} - -/** - * @private - */ -export async function parseFormParam(originalValue: number | boolean | string | Blob): Promise { - let value = originalValue; - value = typeof value == "number" || typeof value == "boolean" ? "" + value : value; - return value as string | Blob; -} diff --git a/src/management/wrapper/ManagementClient.ts b/src/management/wrapper/ManagementClient.ts index 6b91f0a155..450f99af21 100644 --- a/src/management/wrapper/ManagementClient.ts +++ b/src/management/wrapper/ManagementClient.ts @@ -23,7 +23,7 @@ export declare namespace ManagementClient { */ export interface ManagementClientOptions extends Omit< FernClient.Options, - "token" | "environment" | "fetcher" | "baseUrl" | "fetch" + "token" | "environment" | "fetcher" | "baseUrl" > { /** Auth0 domain (e.g., 'your-tenant.auth0.com') */ domain: string; @@ -209,7 +209,6 @@ export class ManagementClient extends FernClient { // Temporarily remove fetcher from options to avoid people passing it for now delete (_options as any).fetcher; - delete (_options as any).fetch; // Prepare the base client options let clientOptions: any = { diff --git a/src/management/wrapper/token-provider.ts b/src/management/wrapper/token-provider.ts index a00cddd24a..cc4473399e 100644 --- a/src/management/wrapper/token-provider.ts +++ b/src/management/wrapper/token-provider.ts @@ -1,39 +1,93 @@ -import { AuthenticationClient } from "../../auth/index.js"; -import { TokenSet } from "../../auth/oauth.js"; -import { JSONApiResponse } from "../../lib/models.js"; -import { ManagementClient } from "./ManagementClient.js"; +import { AuthClient, TokenResponse } from "@auth0/auth0-auth-js"; +import type { ManagementClient } from "./ManagementClient.js"; +import { generateClientInfo } from "../../utils.js"; -const LEEWAY = 10 * 1000; +const LEEWAY = 10 * 1000; // 10s refresh-ahead in ms export class TokenProvider { - private authenticationClient: AuthenticationClient; - private expiresAt = 0; + private authClient: AuthClient; + private expiresAt = 0; // Absolute timestamp in ms (Date.now() scale) private accessToken = ""; - private pending: Promise> | undefined; + private pending: Promise | undefined; constructor(options: ManagementClient.ManagementClientOptionsWithClientSecret & { audience: string }); constructor(options: ManagementClient.ManagementClientOptionsWithClientAssertion & { audience: string }); constructor( private readonly options: ManagementClient.ManagementClientOptionsWithClientCredentials & { audience: string }, ) { - this.authenticationClient = new AuthenticationClient({ ...options, headers: undefined }); + // Map node-auth0 options → AuthClient options + const authClientOptions: any = { + domain: options.domain, + clientId: options.clientId, + }; + + // Client-secret branch + if ("clientSecret" in options) { + authClientOptions.clientSecret = options.clientSecret; + } + + // Client-assertion branch + if ("clientAssertionSigningKey" in options) { + authClientOptions.clientAssertionSigningKey = options.clientAssertionSigningKey; + if (options.clientAssertionSigningAlg) { + authClientOptions.clientAssertionSigningAlg = options.clientAssertionSigningAlg; + } + } + + // mTLS: useMTLS (node-auth0) → useMtls (auth0-auth-js casing) + // auth0-auth-js requires customFetch when useMtls=true + // Forward node-auth0's fetch option (preserved by U7) + if (options.useMTLS) { + authClientOptions.useMtls = true; + // After U7, options.fetch is accessible. Forward to customFetch. + if ((options as any).fetch) { + authClientOptions.customFetch = (options as any).fetch; + } + } + + // Telemetry: preserve node-auth0 identity + if (options.telemetry === false) { + authClientOptions.telemetry = false; + } else if (options.clientInfo) { + // Forward custom clientInfo to auth0-auth-js + authClientOptions.telemetry = { + name: options.clientInfo.name, + version: (options.clientInfo as any).version || "unknown", + env: (options.clientInfo as any).env, + }; + } else { + // Default: node-auth0 identity + const nodeAuth0Info = generateClientInfo(); + authClientOptions.telemetry = { + name: nodeAuth0Info.name, // "node-auth0" + version: nodeAuth0Info.version, // SDK_VERSION + env: nodeAuth0Info.env, // { node: "vXX.Y.Z" } or runtime + }; + } + + this.authClient = new AuthClient(authClientOptions); } - public async getAccessToken() { + public async getAccessToken(): Promise { + // Cache logic preserved: refresh within LEEWAY of expiry if (!this.accessToken || Date.now() > this.expiresAt - LEEWAY) { + // In-flight dedup: share pending promise across concurrent calls this.pending = this.pending || - this.authenticationClient.oauth.clientCredentialsGrant({ + this.authClient.getTokenByClientCredentials({ audience: this.options.audience, }); - const { - data: { access_token: accessToken, expires_in: expiresIn }, - } = await this.pending.finally(() => { + + const tokenResponse = await this.pending.finally(() => { delete this.pending; }); - this.expiresAt = Date.now() + expiresIn * 1000; - this.accessToken = accessToken; + + // CRITICAL: auth0-auth-js returns expiresAt in ABSOLUTE Unix seconds, NOT relative expires_in + // Convert seconds → ms for Date.now() comparison + this.expiresAt = tokenResponse.expiresAt * 1000; + this.accessToken = tokenResponse.accessToken; } + return this.accessToken; } } diff --git a/src/userinfo/index.ts b/src/userinfo/index.ts deleted file mode 100644 index 1a2265c19b..0000000000 --- a/src/userinfo/index.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { ResponseError } from "../lib/errors.js"; -import { Auth0ClientTelemetry } from "../lib/middleware/auth0-client-telemetry.js"; -import { ClientOptions, InitOverride, JSONApiResponse } from "../lib/models.js"; -import { BaseAPI } from "../lib/runtime.js"; - -/** - * Response interface for the UserInfo endpoint - * @group UserInfo API - */ -export interface UserInfoResponse { - sub: string; - name: string; - given_name?: string; - family_name?: string; - middle_name?: string; - nickname: string; - preferred_username?: string; - profile?: string; - picture?: string; - website?: string; - email: string; - email_verified: boolean; - gender?: string; - birthdate?: string; - zoneinfo?: string; - locale?: string; - phone_number?: string; - phone_number_verified?: string; - address?: { - country?: string; - }; - updated_at: string; - [key: string]: unknown; -} - -interface UserInfoErrorResponse { - error_description: string; - error: string; -} - -export class UserInfoError extends Error { - override name = "UserInfoError" as const; - constructor( - public error: string, - public error_description: string, - public statusCode: number, - public body: string, - public headers: Headers, - ) { - super(error_description || error); - } -} - -export async function parseError(response: Response) { - // Errors typically have a specific format: - // { - // error: 'invalid_body', - // error_description: 'Bad Request', - // } - - const body = await response.text(); - let data: UserInfoErrorResponse; - - try { - data = JSON.parse(body) as UserInfoErrorResponse; - return new UserInfoError(data.error, data.error_description, response.status, body, response.headers); - } catch { - return new ResponseError(response.status, body, response.headers, "Response returned an error code"); - } -} - -/** - * Auth0 UserInfo API Client - * - * Provides access to the UserInfo endpoint to retrieve user profile information - * using an access token obtained during authentication. - * - * @group UserInfo API - * - * @example Basic usage - * ```typescript - * import { UserInfoClient } from 'auth0'; - * - * const userInfoClient = new UserInfoClient({ - * domain: 'your-tenant.auth0.com' - * }); - * - * const userInfo = await userInfoClient.getUserInfo(accessToken); - * console.log(userInfo.data.sub, userInfo.data.email); - * ``` - */ -export class UserInfoClient extends BaseAPI { - /** - * Create a new UserInfo API client - * @param options - Configuration options including domain and client settings - */ - constructor(options: { domain: string } & ClientOptions) { - super({ - ...options, - baseUrl: `https://${options.domain}`, - middleware: options.telemetry !== false ? [new Auth0ClientTelemetry(options)] : [], - parseError, - }); - } - - /** - * Given an access token get the user profile linked to it. - * - * @example - * Get the user information based on the Auth0 access token (obtained during - * login). Find more information in the - * API Docs. - * - * - * const userInfoClient = new UserInfoClient({ - * domain: '...' - * }); - - * const userInfo = await userInfoClient.getUserInfo(accessToken); - */ - async getUserInfo(accessToken: string, initOverrides?: InitOverride): Promise> { - const response = await this.request( - { - path: `/userinfo`, - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - initOverrides, - ); - - return JSONApiResponse.fromResponse(response); - } -} diff --git a/src/utils.ts b/src/utils.ts index ab5e562ff9..36d7c57bfd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,28 +14,3 @@ export const generateClientInfo = () => { }, }; }; - -/** - * @private - */ -export const mtlsPrefix = "mtls"; - -type SyncGetter = () => T; -type AsyncGetter = () => Promise; -/** - * Resolves a value that can be a static value, a synchronous function, or an asynchronous function. - * - * @template T - The type of the value to be resolved. - * @param {T | SyncGetter | AsyncGetter} value - The value to be resolved. It can be: - * - A static value of type T. - * - A synchronous function that returns a value of type T. - * - An asynchronous function that returns a Promise of type T. - * @returns {Promise} A promise that resolves to the value of type T. - */ -export const resolveValueToPromise = async (value: T | SyncGetter | AsyncGetter): Promise => { - if (typeof value === "function") { - const result = (value as SyncGetter | AsyncGetter)(); // Call the function - return result instanceof Promise ? result : Promise.resolve(result); // Handle sync/async - } - return Promise.resolve(value); // Static value -}; diff --git a/yarn.lock b/yarn.lock index aa26f2e2e2..9093ac7c80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,14 @@ # yarn lockfile v1 +"@auth0/auth0-auth-js@^1.12.1": + version "1.12.1" + resolved "https://a0us.jfrog.io/artifactory/api/npm/npm/@auth0/auth0-auth-js/-/auth0-auth-js-1.12.1.tgz#cb0e644c31dfdfe1e6707ae9d59a33dbe4a5c4f2" + integrity sha512-YgYOAGmfwO40YOYkYSW7XvE6b+qkdlXdpzBshvOjCioygesUxIw2qRerilW1/8XQV41mnFvQIPnsTdEOHN9BeA== + dependencies: + jose "^6.0.8" + openid-client "^6.8.0" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" @@ -2802,6 +2810,11 @@ jose@^5.0.0: resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== +jose@^6.0.8, jose@^6.2.8: + version "6.2.9" + resolved "https://a0us.jfrog.io/artifactory/api/npm/npm/jose/-/jose-6.2.9.tgz#344fa11af928f8000a4f81972c4d567d587170f0" + integrity sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3198,6 +3211,11 @@ nwsapi@^2.2.2: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.24.tgz#f8927043d4c9b516abdebe804a32c8d1f9484d1f" integrity sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A== +oauth4webapi@^3.8.7: + version "3.8.7" + resolved "https://a0us.jfrog.io/artifactory/api/npm/npm/oauth4webapi/-/oauth4webapi-3.8.7.tgz#e9ba31b5fd21d0f7b531293971ce0f55aabdfb6d" + integrity sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw== + once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -3219,6 +3237,14 @@ onetime@^7.0.0: dependencies: mimic-function "^5.0.0" +openid-client@^6.8.0: + version "6.8.5" + resolved "https://a0us.jfrog.io/artifactory/api/npm/npm/openid-client/-/openid-client-6.8.5.tgz#99ba03960a618a8955dad7d2b60432bf328d95c4" + integrity sha512-jNGC/5wnTYwCcEUe2ss0IRUmVRQcgxM0A1nLb3eX/9llqNbMWOQd2xd+qDAgfVCpA5Qh96Y1cdnkfbva6+bSdA== + dependencies: + jose "^6.2.8" + oauth4webapi "^3.8.7" + optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -3945,7 +3971,7 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" -uuid@11.1.1, uuid@^11.1.1: +uuid@11.1.1: version "11.1.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad" integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ== From 0a5c146e7766a8ba1880fa24ca6b18c24b7f817a Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Mon, 17 Aug 2026 13:20:03 +0530 Subject: [PATCH 2/4] fix(token-provider): correct auth0-auth-js TelemetryConfig shape, drop any cast Match published TelemetryConfig ({enabled:false} | {enabled?:true,name,version}); drop unsupported env field; type options as AuthClientOptions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/management/wrapper/token-provider.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/management/wrapper/token-provider.ts b/src/management/wrapper/token-provider.ts index cc4473399e..e5da3ae790 100644 --- a/src/management/wrapper/token-provider.ts +++ b/src/management/wrapper/token-provider.ts @@ -1,4 +1,4 @@ -import { AuthClient, TokenResponse } from "@auth0/auth0-auth-js"; +import { AuthClient, type AuthClientOptions, TokenResponse } from "@auth0/auth0-auth-js"; import type { ManagementClient } from "./ManagementClient.js"; import { generateClientInfo } from "../../utils.js"; @@ -16,7 +16,7 @@ export class TokenProvider { private readonly options: ManagementClient.ManagementClientOptionsWithClientCredentials & { audience: string }, ) { // Map node-auth0 options → AuthClient options - const authClientOptions: any = { + const authClientOptions: AuthClientOptions = { domain: options.domain, clientId: options.clientId, }; @@ -40,6 +40,7 @@ export class TokenProvider { if (options.useMTLS) { authClientOptions.useMtls = true; // After U7, options.fetch is accessible. Forward to customFetch. + // Cast required: fetch is inherited from FernClient.Options via namespace indirection if ((options as any).fetch) { authClientOptions.customFetch = (options as any).fetch; } @@ -47,21 +48,23 @@ export class TokenProvider { // Telemetry: preserve node-auth0 identity if (options.telemetry === false) { - authClientOptions.telemetry = false; + authClientOptions.telemetry = { enabled: false }; } else if (options.clientInfo) { // Forward custom clientInfo to auth0-auth-js + // Note: clientInfo.version may be unknown-typed, coerce to string + const nodeAuth0Info = generateClientInfo(); authClientOptions.telemetry = { + enabled: true, name: options.clientInfo.name, - version: (options.clientInfo as any).version || "unknown", - env: (options.clientInfo as any).env, + version: String(options.clientInfo.version ?? nodeAuth0Info.version), }; } else { // Default: node-auth0 identity const nodeAuth0Info = generateClientInfo(); authClientOptions.telemetry = { + enabled: true, name: nodeAuth0Info.name, // "node-auth0" version: nodeAuth0Info.version, // SDK_VERSION - env: nodeAuth0Info.env, // { node: "vXX.Y.Z" } or runtime }; } @@ -83,7 +86,7 @@ export class TokenProvider { }); // CRITICAL: auth0-auth-js returns expiresAt in ABSOLUTE Unix seconds, NOT relative expires_in - // Convert seconds → ms for Date.now() comparison + // expiresAt is absolute Unix timestamp in seconds - convert to milliseconds for Date.now() comparison this.expiresAt = tokenResponse.expiresAt * 1000; this.accessToken = tokenResponse.accessToken; } From 69f24fffef744ab3cfecbac3562d0e8208cae27b Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Mon, 17 Aug 2026 16:26:53 +0530 Subject: [PATCH 3/4] test: replace auth-layer tests, mock @auth0/auth0-auth-js for Management token tests - Delete obsolete tests/auth/**, tests/userinfo/**, tests/lib/runtime.test.ts - Rewrite token-provider test to mock @auth0/auth0-auth-js AuthClient (8 cases: both credential modes, cache hit, leeway refresh with expiresAt*1000 boundary, in-flight dedup, error propagation, error-not-cached, expiry) - Add export-surface test asserting AuthenticationClient/UserInfoClient removed - jest: map @auth0/auth0-auth-js to CJS stub for unit/wire (avoids ESM openid-client under Jest CJS runtime); allow openid-client/oauth4webapi transform in root-tests ESM project Co-Authored-By: Claude Opus 4.8 (1M context) --- jest.config.mjs | 14 +- .../tests/__mocks__/auth0-auth-js.cjs | 58 + .../tests/unit/token-provider.test.ts | 267 ++++ tests/auth/backchannel.test.ts | 412 ------ tests/auth/client-authentication.test.ts | 254 ---- tests/auth/database.test.ts | 137 -- tests/auth/fixtures/database.json | 99 -- tests/auth/fixtures/oauth.json | 229 ---- tests/auth/fixtures/passwordless.json | 56 - tests/auth/id-token-validator.test.ts | 388 ------ tests/auth/oauth.test.ts | 466 ------- tests/auth/passwordless.test.ts | 130 -- tests/auth/tokenExchange.test.ts | 133 -- tests/lib/export-surface.test.ts | 21 + tests/lib/runtime.test.ts | 1140 ----------------- tests/management/token-provider.test.ts | 139 -- tests/userinfo/fixtures/userinfo.json | 32 - tests/userinfo/index.test.ts | 48 - 18 files changed, 357 insertions(+), 3666 deletions(-) create mode 100644 src/management/tests/__mocks__/auth0-auth-js.cjs create mode 100644 src/management/tests/unit/token-provider.test.ts delete mode 100644 tests/auth/backchannel.test.ts delete mode 100644 tests/auth/client-authentication.test.ts delete mode 100644 tests/auth/database.test.ts delete mode 100644 tests/auth/fixtures/database.json delete mode 100644 tests/auth/fixtures/oauth.json delete mode 100644 tests/auth/fixtures/passwordless.json delete mode 100644 tests/auth/id-token-validator.test.ts delete mode 100644 tests/auth/oauth.test.ts delete mode 100644 tests/auth/passwordless.test.ts delete mode 100644 tests/auth/tokenExchange.test.ts create mode 100644 tests/lib/export-surface.test.ts delete mode 100644 tests/lib/runtime.test.ts delete mode 100644 tests/management/token-provider.test.ts delete mode 100644 tests/userinfo/fixtures/userinfo.json delete mode 100644 tests/userinfo/index.test.ts diff --git a/jest.config.mjs b/jest.config.mjs index ecdf32cab5..d866124f64 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -27,11 +27,16 @@ export default { displayName: "unit", preset: "ts-jest", testEnvironment: "node", + roots: ["/src/management/tests"], + testPathIgnorePatterns: ["/tests/wire/"], + // Use lightweight CJS stub to avoid ESM openid-client dependency. + // The real @auth0/auth0-auth-js dist/index.cjs requires ESM openid-client, + // which Jest's CJS runtime cannot load. token-provider.test.ts uses its own + // jest.mock() and fully covers token-acquisition behavior. moduleNameMapper: { "^(\.{1,2}/.*)\.js$": "$1", + "^@auth0/auth0-auth-js$": "/src/management/tests/__mocks__/auth0-auth-js.cjs", }, - roots: ["/src/management/tests"], - testPathIgnorePatterns: ["/tests/wire/"], setupFilesAfterEnv: ["/src/management/tests/setup.ts"], transform: { "^.+\\.tsx?$": [ @@ -48,6 +53,7 @@ export default { testEnvironment: "node", moduleNameMapper: { "^(\.{1,2}/.*)\.js$": "$1", + "^@auth0/auth0-auth-js$": "/src/management/tests/__mocks__/auth0-auth-js.cjs", }, roots: ["/src/management/tests/wire"], setupFilesAfterEnv: [ @@ -69,6 +75,8 @@ export default { testEnvironment: "node", moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", + // Use CJS stub to avoid ESM openid-client dependency in export-surface test + "^@auth0/auth0-auth-js$": "/src/management/tests/__mocks__/auth0-auth-js.cjs", }, extensionsToTreatAsEsm: [".ts"], transform: { @@ -88,4 +96,4 @@ export default { ], workerThreads: false, passWithNoTests: true, -}; \ No newline at end of file +}; diff --git a/src/management/tests/__mocks__/auth0-auth-js.cjs b/src/management/tests/__mocks__/auth0-auth-js.cjs new file mode 100644 index 0000000000..23a37e2f9e --- /dev/null +++ b/src/management/tests/__mocks__/auth0-auth-js.cjs @@ -0,0 +1,58 @@ +/** + * Lightweight CJS stub for @auth0/auth0-auth-js + * + * Used by unit/wire Jest projects to avoid ESM openid-client dependency. + * The real token-acquisition behavior is fully tested in token-provider.test.ts + * which uses its own jest.mock() of @auth0/auth0-auth-js. + * + * This stub provides the minimum API surface for ManagementClient construction + * and wire tests that acquire tokens. + */ + +class AuthClient { + constructor(options) { + this.options = options; + } + + async getTokenByClientCredentials({ audience }) { + // Return a fake token for wire tests + return { + accessToken: "mock-access-token-from-stub", + expiresAt: Math.floor(Date.now() / 1000) + 3600, // +1 hour in Unix seconds + tokenType: "Bearer", + }; + } +} + +class TokenByClientCredentialsError extends Error { + constructor(error, errorDescription, statusCode) { + super(errorDescription); + this.name = "TokenByClientCredentialsError"; + this.error = error; + this.errorDescription = errorDescription; + this.statusCode = statusCode; + } +} + +class RateLimitError extends Error { + constructor(message, retryAfter) { + super(message); + this.name = "RateLimitError"; + this.retryAfter = retryAfter; + } +} + +class NetworkError extends Error { + constructor(message, cause) { + super(message); + this.name = "NetworkError"; + this.cause = cause; + } +} + +module.exports = { + AuthClient, + TokenByClientCredentialsError, + RateLimitError, + NetworkError, +}; diff --git a/src/management/tests/unit/token-provider.test.ts b/src/management/tests/unit/token-provider.test.ts new file mode 100644 index 0000000000..1834f3e9ea --- /dev/null +++ b/src/management/tests/unit/token-provider.test.ts @@ -0,0 +1,267 @@ +import { jest } from "@jest/globals"; +import type { TokenResponse } from "@auth0/auth0-auth-js"; + +// Mock @auth0/auth0-auth-js module BEFORE imports +const mockGetTokenByClientCredentials = jest.fn<() => Promise>(); +const MockAuthClient = jest.fn().mockImplementation(() => ({ + getTokenByClientCredentials: mockGetTokenByClientCredentials, +})); + +class MockTokenByClientCredentialsError extends Error { + constructor( + public error: string, + public errorDescription: string, + public statusCode?: number, + ) { + super(errorDescription); + this.name = "TokenByClientCredentialsError"; + } +} + +jest.mock("@auth0/auth0-auth-js", () => ({ + AuthClient: MockAuthClient, + TokenByClientCredentialsError: MockTokenByClientCredentialsError, +})); + +// NOW import TokenProvider (after mock setup) +import { TokenProvider } from "../../wrapper/token-provider.js"; + +describe("TokenProvider (auth0-auth-js)", () => { + const opts = { + domain: "test-domain.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + audience: "https://test-domain.auth0.com/api/v2/", + }; + + beforeEach(() => { + mockGetTokenByClientCredentials.mockReset(); + MockAuthClient.mockClear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + describe("TC-2.1 — Token Acquired (Client-Secret)", () => { + it("should get an access token with client-secret credentials", async () => { + mockGetTokenByClientCredentials.mockResolvedValue({ + accessToken: "mock-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 86400, // Absolute Unix seconds, +1 day + tokenType: "Bearer", + }); + + const tp = new TokenProvider(opts); + const token = await tp.getAccessToken(); + + expect(token).toBe("mock-access-token"); + expect(mockGetTokenByClientCredentials).toHaveBeenCalledTimes(1); + expect(mockGetTokenByClientCredentials).toHaveBeenCalledWith({ + audience: opts.audience, + }); + // Verify AuthClient constructed with correct credentials + expect(MockAuthClient).toHaveBeenCalledWith( + expect.objectContaining({ + domain: opts.domain, + clientId: opts.clientId, + clientSecret: opts.clientSecret, + }), + ); + }); + }); + + describe("TC-2.2 — Token Acquired (Client-Assertion)", () => { + it("should get an access token with client-assertion credentials", async () => { + const optsAssertion = { + domain: "test-domain.auth0.com", + clientId: "test-client-id", + clientAssertionSigningKey: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...", + clientAssertionSigningAlg: "RS256" as const, + audience: "https://test-domain.auth0.com/api/v2/", + }; + + mockGetTokenByClientCredentials.mockResolvedValue({ + accessToken: "mock-assertion-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + tokenType: "Bearer", + }); + + const tp = new TokenProvider(optsAssertion); + const token = await tp.getAccessToken(); + + expect(token).toBe("mock-assertion-token"); + // Verify AuthClient constructed with assertion credentials (no secret) + expect(MockAuthClient).toHaveBeenCalledWith( + expect.objectContaining({ + domain: optsAssertion.domain, + clientId: optsAssertion.clientId, + clientAssertionSigningKey: optsAssertion.clientAssertionSigningKey, + clientAssertionSigningAlg: optsAssertion.clientAssertionSigningAlg, + }), + ); + expect(MockAuthClient).toHaveBeenCalledWith( + expect.not.objectContaining({ + clientSecret: expect.anything(), + }), + ); + }); + }); + + describe("TC-2.3 — Cache Hit", () => { + it("should return cached token on second call within validity", async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 3600; // +1 hour + + mockGetTokenByClientCredentials.mockResolvedValue({ + accessToken: "cached-token", + expiresAt, + tokenType: "Bearer", + }); + + const tp = new TokenProvider(opts); + const token1 = await tp.getAccessToken(); + const token2 = await tp.getAccessToken(); + + expect(token1).toBe("cached-token"); + expect(token2).toBe("cached-token"); + expect(mockGetTokenByClientCredentials).toHaveBeenCalledTimes(1); // single request + }); + }); + + describe("TC-2.4 — Leeway Refresh", () => { + it("should refresh token when within 10s of expiry (leeway)", async () => { + const originalDateNow = Date.now; + let currentTime = 1000000000000; // Fixed start time in ms + Date.now = jest.fn(() => currentTime); + + const expiresAtFirst = Math.floor(currentTime / 1000) + 3600; // +1 hour in Unix seconds + + mockGetTokenByClientCredentials + .mockResolvedValueOnce({ + accessToken: "token-1", + expiresAt: expiresAtFirst, + tokenType: "Bearer", + }) + .mockResolvedValueOnce({ + accessToken: "token-2", + expiresAt: Math.floor((currentTime + 3600 * 1000) / 1000) + 3600, // New expiry + tokenType: "Bearer", + }); + + const tp = new TokenProvider(opts); + + // First call + const token1 = await tp.getAccessToken(); + expect(token1).toBe("token-1"); + + // Advance time to 5s before expiry (within 10s LEEWAY) + currentTime += (3600 - 5) * 1000; // Now: expiresAt - 5s in ms + + // Second call → should refresh (within LEEWAY) + const token2 = await tp.getAccessToken(); + + expect(token2).toBe("token-2"); + expect(mockGetTokenByClientCredentials).toHaveBeenCalledTimes(2); // refresh triggered + + // Verify boundary: expiresAt (Unix seconds) converted to ms correctly + const timeAtSecondCall = currentTime / 1000; // Unix seconds + const leewaySeconds = 10; + expect(timeAtSecondCall).toBeGreaterThan(expiresAtFirst - leewaySeconds); // Within LEEWAY window + + Date.now = originalDateNow; + }); + }); + + describe("TC-2.5 — In-Flight Dedup", () => { + it("should deduplicate concurrent calls to single request", async () => { + mockGetTokenByClientCredentials.mockResolvedValue({ + accessToken: "shared-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + tokenType: "Bearer", + }); + + const tp = new TokenProvider(opts); + + const [token1, token2, token3] = await Promise.all([ + tp.getAccessToken(), + tp.getAccessToken(), + tp.getAccessToken(), + ]); + + expect(token1).toBe("shared-token"); + expect(token2).toBe("shared-token"); + expect(token3).toBe("shared-token"); + expect(mockGetTokenByClientCredentials).toHaveBeenCalledTimes(1); // single request for 3 concurrent calls + }); + }); + + describe("TC-2.6 — Error Path", () => { + it("should propagate TokenByClientCredentialsError", async () => { + mockGetTokenByClientCredentials.mockRejectedValue( + new MockTokenByClientCredentialsError("invalid_client", "Client authentication failed", 401), + ); + + const tp = new TokenProvider(opts); + + await expect(tp.getAccessToken()).rejects.toThrow("Client authentication failed"); + expect(mockGetTokenByClientCredentials).toHaveBeenCalledTimes(1); + }); + }); + + describe("TC-2.7 — Error Not Cached", () => { + it("should retry after failed request (no error caching)", async () => { + mockGetTokenByClientCredentials.mockRejectedValueOnce(new Error("Network timeout")).mockResolvedValueOnce({ + accessToken: "retry-success-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + tokenType: "Bearer", + }); + + const tp = new TokenProvider(opts); + + // First call fails + await expect(tp.getAccessToken()).rejects.toThrow("Network timeout"); + + // Second call succeeds + const token = await tp.getAccessToken(); + + expect(token).toBe("retry-success-token"); + expect(mockGetTokenByClientCredentials).toHaveBeenCalledTimes(2); // retry issued + }); + }); + + describe("TC-2.8 — Token Expired", () => { + it("should refresh token after expiry", async () => { + const originalDateNow = Date.now; + let currentTime = 1000000000000; + Date.now = jest.fn(() => currentTime); + + const expiresAtFirst = Math.floor(currentTime / 1000) + 86400; // +1 day + + mockGetTokenByClientCredentials + .mockResolvedValueOnce({ + accessToken: "token-1", + expiresAt: expiresAtFirst, + tokenType: "Bearer", + }) + .mockResolvedValueOnce({ + accessToken: "token-2", + expiresAt: Math.floor((currentTime + 86400 * 1000 + 20 * 1000) / 1000) + 86400, + tokenType: "Bearer", + }); + + const tp = new TokenProvider(opts); + const token1 = await tp.getAccessToken(); + + // Advance time by 1 day + 20s (beyond expiry + LEEWAY) + currentTime += (86400 + 20) * 1000; + + const token2 = await tp.getAccessToken(); + + expect(token1).toBe("token-1"); + expect(token2).toBe("token-2"); + expect(mockGetTokenByClientCredentials).toHaveBeenCalledTimes(2); + + Date.now = originalDateNow; + }); + }); +}); diff --git a/tests/auth/backchannel.test.ts b/tests/auth/backchannel.test.ts deleted file mode 100644 index fd57e0c4b1..0000000000 --- a/tests/auth/backchannel.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import nock from "nock"; -import querystring from "querystring"; - -import { AuthorizeOptions, Backchannel } from "../../src/auth/backchannel.js"; - -const opts = { - domain: "test-domain.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", -}; - -const jwtOpts = { - ...opts, - clientAssertion: "test-client-assertion", - clientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", -}; - -const mtlsOpts = { - ...opts, - clientCertificate: "test-client-certificate", - clientCertificateCA: "test-client-certificate-ca-verified", -}; - -describe("Backchannel", () => { - let backchannel: Backchannel; - - beforeAll(() => { - backchannel = new Backchannel(opts); - }); - - beforeEach(() => { - nock.cleanAll(); - }); - - describe("#authorize", () => { - it("should require a userId", async () => { - nock(`https://${opts.domain}`).post("/bc-authorize").reply(400, { - error: "invalid_request", - error_description: 'login_hint parameter validation failed: "sub" contains unsupported format', - }); - - await expect(backchannel.authorize({} as AuthorizeOptions)).rejects.toThrow( - 'login_hint parameter validation failed: "sub" contains unsupported format', - ); - }); - - it("should require a binding_message", async () => { - nock(`https://${opts.domain}`).post("/bc-authorize").reply(400, { - error: "invalid_request", - error_description: "binding_message is required", - }); - - await expect(backchannel.authorize({ userId: "auth0|test-user-id" } as AuthorizeOptions)).rejects.toThrow( - "binding_message is required", - ); - }); - - it("should require a valid openid scope", async () => { - nock(`https://${opts.domain}`).post("/bc-authorize").reply(400, { - error: "invalid_scope", - error_description: "openid scope must be requested", - }); - - await expect( - backchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "invalid_scope", - } as AuthorizeOptions), - ).rejects.toThrow("openid scope must be requested"); - }); - - it("should return authorization response", async () => { - nock(`https://${opts.domain}`).post("/bc-authorize").reply(200, { - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - - await expect( - backchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "openid", - }), - ).resolves.toMatchObject({ - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - }); - - it("should pass requested_expiry to /bc-authorize", async () => { - let receivedRequestedExpiry = 0; - nock(`https://${opts.domain}`) - .post("/bc-authorize") - .reply(201, (uri, requestBody, cb) => { - receivedRequestedExpiry = JSON.parse( - querystring.parse(requestBody as any)["requested_expiry"] as string, - ); - cb(null, { - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - }); - - await backchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "openid", - requested_expiry: "999", - }); - - expect(receivedRequestedExpiry).toBe(999); - }); - - it("should pass request_expiry as requested_expiry and retain the request_expiry param for backwards compatibility", async () => { - let receivedRequestedExpiry = 0; - let receivedRequestExpiry = 0; - nock(`https://${opts.domain}`) - .post("/bc-authorize") - .reply(201, (uri, requestBody, cb) => { - receivedRequestedExpiry = JSON.parse( - querystring.parse(requestBody as any)["requested_expiry"] as string, - ); - receivedRequestExpiry = JSON.parse( - querystring.parse(requestBody as any)["request_expiry"] as string, - ); - cb(null, { - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - }); - - await backchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "openid", - request_expiry: "999", - }); - - expect(receivedRequestedExpiry).toBe(999); - expect(receivedRequestExpiry).toBe(999); - }); - - it("should pass authorization_details to /bc-authorize", async () => { - let receivedAuthorizationDetails: { type: string }[] = []; - nock(`https://${opts.domain}`) - .post("/bc-authorize") - .reply(201, (uri, requestBody, cb) => { - receivedAuthorizationDetails = JSON.parse( - querystring.parse(requestBody as any)["authorization_details"] as string, - ); - cb(null, { - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - }); - - await backchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "openid", - authorization_details: JSON.stringify([{ type: "test-type" }]), - }); - - expect(receivedAuthorizationDetails[0].type).toBe("test-type"); - }); - - it("should pass custom parameters to /bc-authorize", async () => { - let receivedCustomParam = ""; - nock(`https://${opts.domain}`) - .post("/bc-authorize") - .reply(201, (uri, requestBody, cb) => { - receivedCustomParam = querystring.parse(requestBody as any)["custom_param"] as string; - cb(null, { - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - }); - - await backchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "openid", - custom_param: "", - }); - - expect(receivedCustomParam).toBe(""); - }); - - it("should throw for invalid request", async () => { - nock(`https://${opts.domain}`).post("/bc-authorize").reply(400, { - error: "invalid_request", - error_description: "Invalid request parameters", - }); - - await expect( - backchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "openid", - }), - ).rejects.toThrowError( - expect.objectContaining({ - body: expect.anything(), - }), - ); - }); - - it("should support Private Key JWT authentication", async () => { - const jwtBackchannel = new Backchannel(jwtOpts); - - nock(`https://${opts.domain}`).post("/bc-authorize").reply(200, { - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - - await expect( - jwtBackchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "openid", - }), - ).resolves.toMatchObject({ - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - }); - - it("should support mTLS authentication", async () => { - const mtlsBackchannel = new Backchannel(mtlsOpts); - - nock(`https://${opts.domain}`).post("/bc-authorize").reply(200, { - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - - await expect( - mtlsBackchannel.authorize({ - userId: "auth0|test-user-id", - binding_message: "Test binding message", - scope: "openid", - }), - ).resolves.toMatchObject({ - auth_req_id: "test-auth-req-id", - expires_in: 300, - interval: 5, - }); - }); - }); - - describe("#backchannelGrant", () => { - it("should throw for invalid or expired auth_req_id", async () => { - nock(`https://${opts.domain}`).post("/oauth/token").reply(401, { - error: "invalid_grant", - error_description: "Invalid or expired auth_req_id", - }); - - await expect( - backchannel.backchannelGrant({ - auth_req_id: "invalid-auth-req-id", - }), - ).rejects.toThrow("Invalid or expired auth_req_id"); - }); - - it("should return token response", async () => { - nock(`https://${opts.domain}`).post("/oauth/token").reply(200, { - access_token: "test-access-token", - id_token: "test-id-token", - expires_in: 86400, - scope: "openid", - }); - - await expect( - backchannel.backchannelGrant({ - auth_req_id: "test-auth-req-id", - }), - ).resolves.toMatchObject({ - access_token: "test-access-token", - id_token: "test-id-token", - expires_in: 86400, - scope: "openid", - }); - }); - - it("should return token response, including authorization_details when available", async () => { - const authorization_details = JSON.stringify([{ type: "test-type" }]); - nock(`https://${opts.domain}`).post("/oauth/token").reply(200, { - access_token: "test-access-token", - id_token: "test-id-token", - expires_in: 86400, - scope: "openid", - authorization_details, - }); - - await expect( - backchannel.backchannelGrant({ - auth_req_id: "test-auth-req-id", - }), - ).resolves.toMatchObject({ - access_token: "test-access-token", - id_token: "test-id-token", - expires_in: 86400, - scope: "openid", - authorization_details, - }); - }); - - it("should throw for authorization pending", async () => { - nock(`https://${opts.domain}`).post("/oauth/token").reply(400, { - error: "authorization_pending", - error_description: "The end-user authorization is pending", - }); - - await expect( - backchannel.backchannelGrant({ - auth_req_id: "test-auth-req-id", - }), - ).rejects.toThrowError( - expect.objectContaining({ - body: expect.anything(), - }), - ); - }); - - it("should throw for access denied", async () => { - nock(`https://${opts.domain}`).post("/oauth/token").reply(400, { - error: "access_denied", - error_description: "The end-user denied the authorization request or it has been expired", - }); - - await expect( - backchannel.backchannelGrant({ - auth_req_id: "test-auth-req-id", - }), - ).rejects.toThrowError( - expect.objectContaining({ - body: expect.anything(), - }), - ); - }); - - it("should throw for polling too quickly", async () => { - nock(`https://${opts.domain}`).post("/oauth/token").reply(400, { - error: "slow_down", - error_description: "You are polling faster than allowed. Try again in 10 seconds.", - }); - - await expect( - backchannel.backchannelGrant({ - auth_req_id: "test-auth-req-id", - }), - ).rejects.toThrowError( - expect.objectContaining({ - body: expect.anything(), - }), - ); - }); - - it("should support Private Key JWT authentication", async () => { - const jwtBackchannel = new Backchannel(jwtOpts); - - nock(`https://${opts.domain}`).post("/oauth/token").reply(200, { - access_token: "test-access-token", - id_token: "test-id-token", - expires_in: 86400, - scope: "openid", - }); - - await expect( - jwtBackchannel.backchannelGrant({ - auth_req_id: "test-auth-req-id", - }), - ).resolves.toMatchObject({ - access_token: "test-access-token", - id_token: "test-id-token", - expires_in: 86400, - scope: "openid", - }); - }); - - it("should support mTLS authentication", async () => { - const mtlsBackchannel = new Backchannel(mtlsOpts); - - nock(`https://${opts.domain}`).post("/oauth/token").reply(200, { - access_token: "test-access-token", - id_token: "test-id-token", - expires_in: 86400, - scope: "openid", - }); - - await expect( - mtlsBackchannel.backchannelGrant({ - auth_req_id: "test-auth-req-id", - }), - ).resolves.toMatchObject({ - access_token: "test-access-token", - id_token: "test-id-token", - expires_in: 86400, - scope: "openid", - }); - }); - }); -}); diff --git a/tests/auth/client-authentication.test.ts b/tests/auth/client-authentication.test.ts deleted file mode 100644 index 9408d55682..0000000000 --- a/tests/auth/client-authentication.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import nock from "nock"; -import { jest } from "@jest/globals"; -import * as jose from "jose"; -import { AuthenticationClient } from "../../src/index.js"; -import { TEST_PUBLIC_KEY, TEST_PRIVATE_KEY } from "../constants.js"; -import { Agent } from "undici"; -import { Dispatcher } from "undici-types"; -const URL = "https://tenant.auth0.com/"; -const clientId = "test-client-id"; -const verifyOpts = { - algorithms: ["RS256"], - audience: URL, - issuer: clientId, - subject: clientId, - maxAge: 180, -}; - -const verify = async (jwt: string, key: string, opts: typeof verifyOpts) => { - const publicKey = await jose.importSPKI(key, "RS256"); - const { payload } = await jose.jwtVerify(jwt, publicKey, opts); - return payload; -}; -const sign = async (payload: jose.JWTPayload, key: string, { algorithm: alg }: { algorithm: string }) => { - const privateKey = await jose.importPKCS8(key, "RS256"); - return new jose.SignJWT(payload).setProtectedHeader({ alg }).sign(privateKey); -}; - -describe("client-authentication", () => { - const path = jest.fn(); - const body = jest.fn(); - const headers = jest.fn(); - const clientAssertion = jest.fn(); - - beforeEach(() => { - async function handler(this: any, pathIn: unknown, bodyIn: string) { - const bodyParsed = Object.fromEntries(new URLSearchParams(bodyIn)); - path(pathIn); - body(bodyParsed); - headers(this.req.headers); - if ((bodyParsed as any).client_assertion) { - clientAssertion(await verify(bodyParsed.client_assertion, TEST_PUBLIC_KEY, verifyOpts)); - } - return { - access_token: "test-access-token", - }; - } - - nock(URL, { encodedQueryParams: true }).post("/oauth/token").reply(200, handler).persist(); - }); - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - it("should do client credentials grant with a client secret", async () => { - const auth0 = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId, - clientSecret: "foo", - }); - await auth0.oauth.clientCredentialsGrant({ - audience: "my-api", - }); - expect(path).toHaveBeenCalledWith("/oauth/token"); - expect(body).toHaveBeenCalledWith({ - grant_type: "client_credentials", - client_id: clientId, - audience: "my-api", - client_secret: "foo", - }); - }); - - it("should do client credentials grant with a client assertion", async () => { - const auth0 = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId, - clientAssertionSigningKey: TEST_PRIVATE_KEY, - }); - await auth0.oauth.clientCredentialsGrant({ - audience: "my-api", - }); - expect(path).toHaveBeenCalledWith("/oauth/token"); - expect(body).toHaveBeenCalledWith( - expect.objectContaining({ - grant_type: "client_credentials", - client_id: clientId, - audience: "my-api", - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - }), - ); - expect(clientAssertion).toHaveBeenCalledWith({ - iss: clientId, - sub: clientId, - aud: URL, - iat: expect.any(Number), - exp: expect.any(Number), - jti: expect.any(String), - }); - }); - - it("should require a client secret or client assertion with client credentials grant", async () => { - const auth0 = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId, - }); - await expect(() => - auth0.oauth.clientCredentialsGrant({ - audience: "my-api", - }), - ).rejects.toThrow("The client_secret or client_assertion field is required, or it should be mTLS request."); - }); - - it("should allow you to pass your own client assertion", async () => { - const auth0 = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId, - }); - const now = Math.floor(Date.now() / 1000); - const payload = { - iss: clientId, - sub: clientId, - aud: URL, - iat: now, - exp: now + 180, - jti: "foo", - }; - await auth0.oauth.clientCredentialsGrant({ - audience: "my-api", - client_assertion: await sign(payload, TEST_PRIVATE_KEY, { algorithm: "RS256" }), - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - }); - expect(path).toHaveBeenCalledWith("/oauth/token"); - expect(body).toHaveBeenCalledWith( - expect.objectContaining({ - grant_type: "client_credentials", - client_id: clientId, - audience: "my-api", - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - }), - ); - expect(clientAssertion).toHaveBeenCalledWith({ - iss: clientId, - sub: clientId, - aud: URL, - iat: expect.any(Number), - exp: expect.any(Number), - jti: "foo", - }); - }); -}); - -describe("client-authentication for par endpoint", () => { - const path = jest.fn(); - const body = jest.fn(); - const headers = jest.fn(); - const clientAssertion = jest.fn(); - - beforeEach(() => { - async function handler(this: any, pathIn: unknown, bodyIn: string) { - const bodyParsed = Object.fromEntries(new URLSearchParams(bodyIn)); - path(pathIn); - body(bodyParsed); - headers(this.req.headers); - if ((bodyParsed as any).client_assertion) { - clientAssertion(await verify(bodyParsed.client_assertion, TEST_PUBLIC_KEY, verifyOpts)); - } - return { - data: { - request_uri: "https://www.request.uri", - expires_in: 86400, - }, - }; - } - - nock(URL, { encodedQueryParams: true }).post("/oauth/par").reply(200, handler).persist(); - }); - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - it("should allow you to call with cliendId & clientSecret combination", async () => { - const auth0 = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId, - clientSecret: "foo", - }); - await auth0.oauth.pushedAuthorization({ - client_id: "test-client-id", - response_type: "code", - redirect_uri: "https://example.com", - }); - expect(path).toHaveBeenCalledWith("/oauth/par"); - - expect(body).toHaveBeenCalledWith({ - client_id: "test-client-id", - client_secret: "foo", - redirect_uri: "https://example.com", - response_type: "code", - }); - }); -}); - -describe("mTLS-authentication", () => { - const path = jest.fn(); - const body = jest.fn(); - const headers = jest.fn(); - const clientAssertion = jest.fn(); - const URL = "https://mtls.tenant.auth0.com/"; - - beforeEach(() => { - async function handler(this: any, pathIn: unknown, bodyIn: string) { - const bodyParsed = Object.fromEntries(new URLSearchParams(bodyIn)); - path(pathIn); - body(bodyParsed); - headers(this.req.headers); - if ((bodyParsed as any).client_assertion) { - clientAssertion(await verify(bodyParsed.client_assertion, TEST_PUBLIC_KEY, verifyOpts)); - } - return { - access_token: "test-access-token", - }; - } - - nock(URL, { encodedQueryParams: true }).post("/oauth/token").reply(200, handler).persist(); - }); - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - it("should do client credentials grant without client secret or assertion & only with agent", async () => { - const auth0 = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId, - agent: new Agent({ - connect: { cert: "my-cert", key: "my-key" }, - }) as unknown as Dispatcher, - useMTLS: true, - }); - await auth0.oauth.clientCredentialsGrant({ - audience: "my-api", - }); - expect(path).toHaveBeenCalledWith("/oauth/token"); - expect(body).toHaveBeenCalledWith({ - grant_type: "client_credentials", - client_id: clientId, - audience: "my-api", - }); - }); -}); diff --git a/tests/auth/database.test.ts b/tests/auth/database.test.ts deleted file mode 100644 index 7b2373afc0..0000000000 --- a/tests/auth/database.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import nock from "nock"; -import { beforeAll, afterAll } from "@jest/globals"; -import { Database, AuthApiError } from "../../src/index.js"; - -const { back: nockBack } = nock; - -const EMAIL = "test-email@example.com"; -const DUPLICATE_EMAIL = "test-email-duplicate@example.com"; -const PASSWORD = "test-password"; - -const opts = { - domain: "test-domain.auth0.com", - clientId: "test-client-id", -}; - -describe("Database", () => { - let nockDone: () => void; - - beforeAll(async () => { - ({ nockDone } = await nockBack("auth/fixtures/database.json")); - }); - - afterAll(() => { - nockDone(); - }); - - describe("#signUp", () => { - it("should signup a user", async () => { - const database = new Database(opts); - const email = EMAIL; - const { data } = await database.signUp({ - email, - password: PASSWORD, - connection: "Username-Password-Authentication", - }); - expect(data).toEqual({ - _id: "test-id", - id: "test-id", - email_verified: false, - email, - }); - }); - - it("should signup a user when response param for id is 'user_id'", async () => { - const database = new Database(opts); - const email = "test-email-1@example.com"; - const { data } = await database.signUp({ - email, - password: PASSWORD, - connection: "Username-Password-Authentication", - }); - expect(data).toEqual({ - user_id: "test-id", - id: "test-id", - email_verified: false, - email, - }); - }); - - it("should signup a user when response param for id is 'id'", async () => { - const database = new Database(opts); - const email = "test-email-2@example.com"; - const { data } = await database.signUp({ - email, - password: PASSWORD, - connection: "Username-Password-Authentication", - }); - expect(data).toEqual({ - id: "test-id", - email_verified: false, - email, - }); - }); - - it("should require connection", async () => { - const database = new Database(opts); - await expect( - database.signUp({ - email: EMAIL, - password: PASSWORD, - } as any), - ).rejects.toThrow("Required parameter requestParameters.connection was null or undefined."); - }); - - it("should handle duplicate user error", async () => { - const database = new Database(opts); - const email = DUPLICATE_EMAIL; - let error: AuthApiError | null = null; - - try { - await database.signUp({ - email, - password: PASSWORD, - connection: "Username-Password-Authentication", - }); - } catch (e) { - error = e as AuthApiError; - } - - expect(error).toBeDefined(); - expect(error).toEqual( - expect.objectContaining({ - error: "invalid_signup", - error_description: "Invalid sign up", - }), - ); - }); - }); - - describe("#changePassword", () => { - it("should send a change password email", async () => { - const database = new Database(opts); - - const email = EMAIL; - await database.signUp({ - email, - password: PASSWORD, - connection: "Username-Password-Authentication", - }); - const { data: txt } = await database.changePassword({ - email, - connection: "Username-Password-Authentication", - }); - expect(txt).toBe("We've just sent you an email to reset your password."); - }); - - it("should require email", async () => { - const database = new Database(opts); - - await expect( - database.changePassword({ - connection: "Username-Password-Authentication", - } as any), - ).rejects.toThrow("Required parameter requestParameters.email was null or undefined."); - }); - }); -}); diff --git a/tests/auth/fixtures/database.json b/tests/auth/fixtures/database.json deleted file mode 100644 index a00a4b7ab5..0000000000 --- a/tests/auth/fixtures/database.json +++ /dev/null @@ -1,99 +0,0 @@ -[ - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/dbconnections/signup", - "body": { - "client_id": "test-client-id", - "email": "test-email@example.com", - "password": "test-password", - "connection": "Username-Password-Authentication" - }, - "status": 200, - "response": { - "_id": "test-id", - "email_verified": false, - "email": "test-email@example.com" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/dbconnections/signup", - "body": { - "client_id": "test-client-id", - "email": "test-email@example.com", - "password": "test-password", - "connection": "Username-Password-Authentication" - }, - "status": 200, - "response": { - "_id": "test-id", - "email_verified": false, - "email": "test-email@example.com" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/dbconnections/signup", - "body": { - "client_id": "test-client-id", - "email": "test-email-1@example.com", - "password": "test-password", - "connection": "Username-Password-Authentication" - }, - "status": 200, - "response": { - "user_id": "test-id", - "email_verified": false, - "email": "test-email-1@example.com" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/dbconnections/signup", - "body": { - "client_id": "test-client-id", - "email": "test-email-2@example.com", - "password": "test-password", - "connection": "Username-Password-Authentication" - }, - "status": 200, - "response": { - "id": "test-id", - "email_verified": false, - "email": "test-email-2@example.com" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/dbconnections/signup", - "body": { - "client_id": "test-client-id", - "email": "test-email-duplicate@example.com", - "password": "test-password", - "connection": "Username-Password-Authentication" - }, - "status": 400, - "response": { - "name": "BadRequestError", - "code": "invalid_signup", - "description": "Invalid sign up" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/dbconnections/change_password", - "body": { - "client_id": "test-client-id", - "email": "test-email@example.com", - "connection": "Username-Password-Authentication" - }, - "status": 200, - "response": "We've just sent you an email to reset your password." - } -] diff --git a/tests/auth/fixtures/oauth.json b/tests/auth/fixtures/oauth.json deleted file mode 100644 index 68db2792be..0000000000 --- a/tests/auth/fixtures/oauth.json +++ /dev/null @@ -1,229 +0,0 @@ -[ - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&code=test-valid-code&redirect_uri=https%3A%2F%2Fexample.com&client_secret=test-client-secret&grant_type=authorization_code", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&code=test-valid-code&redirect_uri=https%3A%2F%2Fexample.com&my_param=test&client_secret=test-client-secret&grant_type=authorization_code", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&code=test-invalid-code&redirect_uri=https%3A%2F%2Fexample.com&client_secret=test-client-secret&grant_type=authorization_code", - "status": 403, - "response": { - "error": "invalid_grant", - "error_description": "Invalid authorization code" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&code=test-code&code_verifier=test-valid-code-verifier&redirect_uri=https%3A%2F%2Fexample.com&client_secret=test-client-secret&grant_type=authorization_code", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&code=test-code&code_verifier=test-valid-code-verifier&redirect_uri=https%3A%2F%2Fexample.com&my_param=test&client_secret=test-client-secret&grant_type=authorization_code", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&code=test-code&code_verifier=test-invalid-code-verifier&redirect_uri=https%3A%2F%2Fexample.com&client_secret=test-client-secret&grant_type=authorization_code", - "status": 403, - "response": { - "error": "invalid_grant", - "error_description": "Failed to verify code verifier" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&audience=my-api&client_secret=test-client-secret&grant_type=client_credentials", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&username=test-username&password=test-password&client_secret=test-client-secret&grant_type=password", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&username=test-username&password=test-password&realm=Username-Password-Authentication&client_secret=test-client-secret&grant_type=http%3A%2F%2Fauth0.com%2Foauth%2Fgrant-type%2Fpassword-realm", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&refresh_token=test-refresh-token&client_secret=test-client-secret&grant_type=refresh_token", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone offline_access" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&refresh_token=test-refresh-token&my_param=test&client_secret=test-client-secret&grant_type=refresh_token", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone offline_access" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/revoke", - "body": { - "client_id": "test-client-id", - "token": "test-refresh-token", - "client_secret": "test-client-secret" - }, - "status": 200, - "response": "" - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/par", - "body": "client_id=test-client-id&response_type=code&redirect_uri=https%3A%2F%2Fexample.com&client_secret=test-client-secret", - "status": 200, - "response": { - "request_uri": "https://www.request.uri", - "expires_in": 86400 - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/par", - "body": "client_id=test-client-id&response_type=code&redirect_uri=https%3A%2F%2Fexample.com&authorization_details=%5B%7B%22type%22%3A%22payment_initiation%22%2C%22actions%22%3A%5B%22write%22%5D%7D%5D&client_secret=test-client-secret", - "status": 200, - "response": { - "request_uri": "https://www.request.uri", - "expires_in": 86400 - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/par", - "body": "client_id=test-client-id&response_type=code&redirect_uri=https%3A%2F%2Fexample.com&request=my-jwt-request&client_secret=test-client-secret", - "status": 200, - "response": { - "request_uri": "https://www.request.uri", - "expires_in": 86400 - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Arefresh_token&connection=google-oauth2&subject_token=test-refresh-token&grant_type=urn%3Aauth0%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange%3Afederated-connection-access-token&requested_token_type=http%3A%2F%2Fauth0.com%2Foauth%2Ftoken-type%2Ffederated-connection-access-token&client_secret=test-client-secret", - "status": 200, - "response": { - "access_token": "connection-access-token", - "expires_in": 86400, - "token_type": "Bearer" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Arefresh_token&connection=google-oauth2&subject_token=test-refresh-token&login_hint=user%40example.com&grant_type=urn%3Aauth0%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange%3Afederated-connection-access-token&requested_token_type=http%3A%2F%2Fauth0.com%2Foauth%2Ftoken-type%2Ffederated-connection-access-token&client_secret=test-client-secret", - "status": 200, - "response": { - "access_token": "connection-access-token", - "expires_in": 86400, - "token_type": "Bearer" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&connection=google-oauth2&subject_token=test-id-token&grant_type=urn%3Aauth0%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange%3Afederated-connection-access-token&requested_token_type=http%3A%2F%2Fauth0.com%2Foauth%2Ftoken-type%2Ffederated-connection-access-token&client_secret=test-client-secret", - "status": 200, - "response": { - "access_token": "connection-access-token", - "expires_in": 86400, - "token_type": "Bearer" - } - } -] diff --git a/tests/auth/fixtures/passwordless.json b/tests/auth/fixtures/passwordless.json deleted file mode 100644 index 6459dda3ab..0000000000 --- a/tests/auth/fixtures/passwordless.json +++ /dev/null @@ -1,56 +0,0 @@ -[ - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/passwordless/start", - "body": { - "client_id": "test-client-id", - "client_secret": "test-client-secret", - "email": "test-email@example.com", - "connection": "email" - }, - "status": 200, - "response": "" - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/passwordless/start", - "body": { - "client_id": "test-client-id", - "client_secret": "test-client-secret", - "phone_number": "01234", - "connection": "sms" - }, - "status": 200, - "response": "" - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&username=test-email%40example.com&otp=test-code&realm=email&client_secret=test-client-secret&grant_type=http%3A%2F%2Fauth0.com%2Foauth%2Fgrant-type%2Fpasswordless%2Fotp", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone" - } - }, - { - "scope": "https://test-domain.auth0.com", - "method": "POST", - "path": "/oauth/token", - "body": "client_id=test-client-id&username=test-phone-number&otp=test-code&realm=sms&client_secret=test-client-secret&grant_type=http%3A%2F%2Fauth0.com%2Foauth%2Fgrant-type%2Fpasswordless%2Fotp", - "status": 200, - "response": { - "access_token": "my-access-token", - "expires_in": 86400, - "token_type": "Bearer", - "id_token": "my-id-token", - "scope": "openid profile email address phone" - } - } -] diff --git a/tests/auth/id-token-validator.test.ts b/tests/auth/id-token-validator.test.ts deleted file mode 100644 index f93d25b6eb..0000000000 --- a/tests/auth/id-token-validator.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import nock from "nock"; -import { jest } from "@jest/globals"; -import * as jose from "jose"; -import { TEST_PUBLIC_KEY, TEST_PRIVATE_KEY } from "../constants.js"; -import { IDTokenValidator } from "../../src/auth/id-token-validator.js"; - -const DOMAIN = "tenant.auth0.com"; -const URL = `https://${DOMAIN}/`; -const CLIENT_ID = "test-client-id"; -const CLIENT_SECRET = "test-client-secret"; - -const now = () => Math.floor(Date.now() / 1000); - -const sign = async ({ - payload = {}, - clientSecret = undefined, - privateKey = undefined, - issuer = URL, - sub = "me", - exp = now() + 3600, - iat = now(), - aud = CLIENT_ID, -}: any) => { - let alg = "RS256"; - let secretOrKey = privateKey; - - if (clientSecret) { - alg = "HS256"; - secretOrKey = new TextEncoder().encode(clientSecret); - } else { - secretOrKey = privateKey || (await jose.importPKCS8(TEST_PRIVATE_KEY, alg)); - } - - return new jose.SignJWT({ - iss: issuer, - sub, - aud, - exp, - iat, - ...payload, - }) - .setProtectedHeader({ alg, kid: "1" }) - .sign(secretOrKey); -}; - -describe("id-token-validator", () => { - beforeEach(async () => { - // Set up the JWKS endpoint mock for all tests - const jwk = await jose.exportJWK(await jose.importSPKI(TEST_PUBLIC_KEY, "RS256")); - nock(URL) - .persist() - .get("/.well-known/jwks.json") - .reply(200, { keys: [{ kid: "1", ...jwk }] }); - }); - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - it("validates the id token and fulfills with input value (when signed by secret)", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - idTokenSigningAlg: "HS256", - }); - const jwt = await sign({ clientSecret: CLIENT_SECRET }); - await expect(idTokenValidator.validate(jwt)).resolves.not.toThrowError(); - }); - - it("validates the id token and fulfills with input value (when signed by private key)", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({}); - await expect(idTokenValidator.validate(jwt)).resolves.not.toThrowError(); - }); - - it("validates the idTokenSigningAlg is the one used", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ clientSecret: CLIENT_SECRET }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError(/Unsupported "alg" value/); - }); - - it("rejects when azp is not present when more audiences are provided", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ aud: [CLIENT_ID, "bar"] }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError(/\(azp\) claim must be a string/); - }); - - it("rejects unknown azp values", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ aud: [CLIENT_ID, "bar"], payload: { azp: "not client id" } }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError(/\(azp\) claim mismatch/); - }); - - it("verifies the audience when azp is there", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ aud: [CLIENT_ID, "bar"], payload: { azp: CLIENT_ID } }); - await expect(idTokenValidator.validate(jwt)).resolves.not.toThrowError(); - }); - - it("verifies the audience when string", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ aud: "not client id" }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError(/\(aud\) claim mismatch/); - }); - - it("verifies the audience when array", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ aud: ["not client id"] }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError(/\(aud\) claim mismatch/); - }); - - it("verifies the audience when invalid", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ aud: 42 }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(aud\) claim must be a string or array of strings present in the ID token/, - ); - }); - - it("passes with nonce check", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ payload: { nonce: "foo" } }); - await expect(idTokenValidator.validate(jwt, { nonce: "foo" })).resolves.not.toThrowError(); - }); - - it("validates nonce when provided to check for", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({}); - await expect(idTokenValidator.validate(jwt, { nonce: "foo" })).rejects.toThrowError( - /\(nonce\) claim must be a string present in the ID token/, - ); - }); - - it("validates nonce when in token", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ payload: { nonce: "foo" } }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError(/\(nonce\) claim mismatch in the ID token/); - }); - - it("verifies iss is present", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ issuer: null }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(iss\) claim must be a string present in the ID token/, - ); - }); - - it("verifies iss matches the provided issuer", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ issuer: "foo" }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(iss\) claim mismatch in the ID token; expected "https:\/\/tenant.auth0.com\/", found "foo"/, - ); - }); - - it("verifies sub is present", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ sub: null }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(sub\) claim must be a string present in the ID token/, - ); - }); - - it("verifies aud is present", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ aud: null }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(aud\) claim must be a string or array of strings present in the ID token/, - ); - }); - - it("verifies exp is present", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ exp: null }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(exp\) claim must be a number present in the ID token/, - ); - }); - - it("verifies iat is present", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ iat: null }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(iat\) claim must be a number present in the ID token/, - ); - }); - - it("verifies iat is a number", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ iat: "foo" }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(iat\) claim must be a number present in the ID token/, - ); - }); - - it("allows iat skew", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ iat: now() + 1000 }); - await expect(idTokenValidator.validate(jwt)).resolves.not.toThrowError(); - }); - - it("verifies exp is a number", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ exp: "foo" }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(exp\) claim must be a number present in the ID token/, - ); - }); - - it("verifies exp is in the future", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - clockTolerance: 10, - }); - const jwt = await sign({ exp: now() - 15 }); - await expect(idTokenValidator.validate(jwt)).rejects.toThrowError( - /\(exp\) claim error in the ID token; current time \(.*?\) is after expiration time \(.*?\)/, - ); - }); - - it("allows exp skew", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - clockTolerance: 10, - }); - const jwt = await sign({ exp: now() - 5 }); - await expect(idTokenValidator.validate(jwt)).resolves.not.toThrowError(); - }); - - it("passes when auth_time is within max_age", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ payload: { auth_time: now() - 100 } }); - await expect(idTokenValidator.validate(jwt, { maxAge: 200 })).resolves.not.toThrowError(); - }); - - it("verifies auth_time did not exceed max_age", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ payload: { auth_time: now() - 400 } }); - await expect(idTokenValidator.validate(jwt, { maxAge: 200 })).rejects.toThrowError( - /\(auth_time\) claim in the ID token indicates that too much time has passed/, - ); - }); - - it("allows auth_time skew", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - clockTolerance: 100, - }); - const jwt = await sign({ payload: { auth_time: now() - 250 } }); - await expect(idTokenValidator.validate(jwt, { maxAge: 200 })).resolves.not.toThrowError(); - }); - - it("verifies auth_time is a number when maxAge is passed", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - const jwt = await sign({ payload: { auth_time: "foo" } }); - await expect(idTokenValidator.validate(jwt, { maxAge: 200 })).rejects.toThrowError( - /\(auth_time\) claim must be a number present in the ID token/, - ); - }); - - it("should throw when organization id is in options, but org_id missing from claim", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - - const jwt = await sign({ payload: { org_id: undefined } }); - - await expect(idTokenValidator.validate(jwt, { organization: "org_123" })).rejects.toThrow( - "Organization Id (org_id) claim must be a string present in the ID token", - ); - }); - - it("should throw when organization name is in options, but org_name missing from claim", async () => { - const idTokenValidator = new IDTokenValidator({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }); - - const jwt = await sign({ payload: { org_name: undefined } }); - - await expect(idTokenValidator.validate(jwt, { organization: "testorg" })).rejects.toThrow( - "Organization Name (org_name) claim must be a string present in the ID token", - ); - }); -}); diff --git a/tests/auth/oauth.test.ts b/tests/auth/oauth.test.ts deleted file mode 100644 index c4e522615b..0000000000 --- a/tests/auth/oauth.test.ts +++ /dev/null @@ -1,466 +0,0 @@ -import nock from "nock"; -import { - OAuth, - AuthorizationCodeGrantRequest, - AuthorizationCodeGrantWithPKCERequest, - ClientCredentialsGrantRequest, - PasswordGrantRequest, - RefreshTokenGrantRequest, - RevokeRefreshTokenRequest, - PushedAuthorizationRequest, - TokenForConnectionRequest, - SUBJECT_TOKEN_TYPES, -} from "../../src/index.js"; -import { withIdToken } from "../utils/index.js"; - -const { back: nockBack } = nock; - -const opts = { - domain: "test-domain.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - idTokenSigningAlg: "HS256", -}; - -describe("OAuth", () => { - let nockDone: () => void; - - beforeAll(async () => { - ({ nockDone } = await nockBack("auth/fixtures/oauth.json", { - before: await withIdToken(opts), - })); - }); - - afterAll(() => { - nockDone(); - }); - - describe("#authorizationCodeGrant", () => { - it("should require a code", async () => { - const oauth = new OAuth(opts); - await expect(oauth.authorizationCodeGrant({} as AuthorizationCodeGrantRequest)).rejects.toThrow( - "Required parameter requestParameters.code was null or undefined.", - ); - }); - - it("should return tokens", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.authorizationCodeGrant({ - code: "test-valid-code", - redirect_uri: "https://example.com", - }), - ).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - }, - }); - }); - - it("should send custom parameters", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.authorizationCodeGrant({ - code: "test-valid-code", - redirect_uri: "https://example.com", - my_param: "test", - }), - ).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - }, - }); - }); - - it("should throw for invalid code", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.authorizationCodeGrant({ - code: "test-invalid-code", - redirect_uri: "https://example.com", - }), - ).rejects.toThrowError( - expect.objectContaining({ - body: expect.anything(), - }), - ); - }); - }); - - describe("#authorizationCodeGrantWithPKCE", () => { - it("should require a code_verifier", () => { - const oauth = new OAuth(opts); - expect( - oauth.authorizationCodeGrantWithPKCE({ - code: "foo", - } as AuthorizationCodeGrantWithPKCERequest), - ).rejects.toThrow("Required parameter requestParameters.code_verifier was null or undefined."); - }); - - it("should return tokens", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.authorizationCodeGrantWithPKCE({ - code: "test-code", - code_verifier: "test-valid-code-verifier", - redirect_uri: "https://example.com", - }), - ).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - }, - }); - }); - - it("should throw for invalid code verifier", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.authorizationCodeGrantWithPKCE({ - code: "test-code", - code_verifier: "test-invalid-code-verifier", - redirect_uri: "https://example.com", - }), - ).rejects.toThrowError( - expect.objectContaining({ - body: expect.anything(), - }), - ); - }); - - it("should send custom parameters", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.authorizationCodeGrantWithPKCE({ - code: "test-code", - code_verifier: "test-valid-code-verifier", - redirect_uri: "https://example.com", - my_param: "test", - }), - ).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - }, - }); - }); - }); - - describe("#clientCredentialsGrant", () => { - it("should require an audience", async () => { - const oauth = new OAuth(opts); - await expect(oauth.clientCredentialsGrant({} as ClientCredentialsGrantRequest)).rejects.toThrow( - "Required parameter requestParameters.audience was null or undefined.", - ); - }); - - it("should return tokens", async () => { - const oauth = new OAuth(opts); - await expect(oauth.clientCredentialsGrant({ audience: "my-api" })).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - }, - }); - }); - }); - - describe("#passwordGrant", () => { - it("should require a password", async () => { - const oauth = new OAuth(opts); - await expect(oauth.passwordGrant({ username: "foo" } as PasswordGrantRequest)).rejects.toThrow( - "Required parameter requestParameters.password was null or undefined.", - ); - }); - - it("should return tokens", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.passwordGrant({ username: "test-username", password: "test-password" }), - ).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - id_token: expect.any(String), - scope: "openid profile email address phone", - }, - }); - }); - - it("should return tokens when passed a realm", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.passwordGrant({ - username: "test-username", - password: "test-password", - realm: "Username-Password-Authentication", - }), - ).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - id_token: expect.any(String), - scope: "openid profile email address phone", - }, - }); - }); - }); - - describe("#refreshTokenGrant", () => { - it("should require a refresh token", async () => { - const oauth = new OAuth(opts); - await expect(oauth.refreshTokenGrant({} as RefreshTokenGrantRequest)).rejects.toThrow( - "Required parameter requestParameters.refresh_token was null or undefined.", - ); - }); - - it("should return tokens", async () => { - const oauth = new OAuth(opts); - await expect(oauth.refreshTokenGrant({ refresh_token: "test-refresh-token" })).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - id_token: expect.any(String), - scope: "openid profile email address phone offline_access", - }, - }); - }); - - it("should send custom parameters", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.refreshTokenGrant({ - refresh_token: "test-refresh-token", - my_param: "test", - }), - ).resolves.toMatchObject({ - data: { - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - id_token: expect.any(String), - scope: "openid profile email address phone offline_access", - }, - }); - }); - }); - - describe("#revokeRefreshToken", () => { - it("should require a refresh token", async () => { - const oauth = new OAuth(opts); - await expect(oauth.revokeRefreshToken({} as RevokeRefreshTokenRequest)).rejects.toThrow( - "Required parameter requestParameters.token was null or undefined.", - ); - }); - - it("should return tokens", async () => { - const oauth = new OAuth(opts); - await expect(oauth.revokeRefreshToken({ token: "test-refresh-token" })).resolves.toMatchObject({ - status: 200, - }); - }); - }); - - describe("#pushedAuthorization", () => { - it("should require a client_id", async () => { - const oauth = new OAuth(opts); - await expect(oauth.pushedAuthorization({} as PushedAuthorizationRequest)).rejects.toThrow( - "Required parameter requestParameters.client_id was null or undefined.", - ); - }); - - it("should require a response_type", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.pushedAuthorization({ client_id: "test-client-id" } as PushedAuthorizationRequest), - ).rejects.toThrow("Required parameter requestParameters.response_type was null or undefined."); - }); - - it("should require a redirect_uri", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.pushedAuthorization({ - client_id: "test-client-id", - response_type: "code", - } as PushedAuthorizationRequest), - ).rejects.toThrow("Required parameter requestParameters.redirect_uri was null or undefined."); - }); - - it("should require a client_secret or client_assertion", async () => { - const oauth = new OAuth({ ...opts, clientSecret: undefined }); - await expect( - oauth.pushedAuthorization({ - client_id: "test-client-id", - response_type: "code", - redirect_uri: "https://example.com", - } as PushedAuthorizationRequest), - ).rejects.toThrow("The client_secret or client_assertion field is required, or it should be mTLS request."); - }); - - it("should return the par response", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.pushedAuthorization({ - client_id: "test-client-id", - response_type: "code", - redirect_uri: "https://example.com", - }), - ).resolves.toMatchObject({ - data: { - request_uri: "https://www.request.uri", - expires_in: 86400, - }, - }); - }); - - it("should send authorization_details when provided", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.pushedAuthorization({ - client_id: "test-client-id", - response_type: "code", - redirect_uri: "https://example.com", - authorization_details: JSON.stringify([{ type: "payment_initiation", actions: ["write"] }]), - }), - ).resolves.toMatchObject({ - data: { - request_uri: "https://www.request.uri", - expires_in: 86400, - }, - }); - }); - - it("should send request param when provided", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.pushedAuthorization({ - client_id: "test-client-id", - response_type: "code", - redirect_uri: "https://example.com", - request: "my-jwt-request", - }), - ).resolves.toMatchObject({ - data: { - request_uri: "https://www.request.uri", - expires_in: 86400, - }, - }); - }); - }); - - describe("#tokenForConnection", () => { - it("should require a connection", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.tokenForConnection({ subject_token: "test-token" } as TokenForConnectionRequest), - ).rejects.toThrow("Required parameter requestParameters.connection was null or undefined."); - }); - - it("should require a subject_token", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.tokenForConnection({ connection: "google-oauth2" } as TokenForConnectionRequest), - ).rejects.toThrow("Required parameter requestParameters.subject_token was null or undefined."); - }); - - it("should return token response", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.tokenForConnection({ - connection: "google-oauth2", - subject_token: "test-refresh-token", - }), - ).resolves.toMatchObject({ - data: { - access_token: "connection-access-token", - expires_in: 86400, - token_type: "Bearer", - }, - }); - }); - - it("should include login_hint when provided", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.tokenForConnection({ - connection: "google-oauth2", - subject_token: "test-refresh-token", - login_hint: "user@example.com", - }), - ).resolves.toMatchObject({ - data: { - access_token: "connection-access-token", - expires_in: 86400, - token_type: "Bearer", - }, - }); - }); - - it("should use subject_token_type when provided", async () => { - const oauth = new OAuth(opts); - await expect( - oauth.tokenForConnection({ - connection: "google-oauth2", - subject_token: "test-id-token", - subject_token_type: SUBJECT_TOKEN_TYPES.ACCESS_TOKEN, - }), - ).resolves.toMatchObject({ - data: { - access_token: "connection-access-token", - expires_in: 86400, - token_type: "Bearer", - }, - }); - }); - }); -}); - -describe("OAuth (with ID Token validation)", () => { - it("should throw for invalid nonce", async () => { - const { nockDone } = await nockBack("auth/fixtures/oauth.json", { - before: await withIdToken({ ...opts, payload: { nonce: "foo" } }), - }); - const oauth = new OAuth(opts); - await expect( - oauth.authorizationCodeGrant( - { - code: "test-valid-code", - redirect_uri: "https://example.com", - }, - { idTokenValidateOptions: { nonce: "bar" } }, - ), - ).rejects.toThrowError(/\(nonce\) claim mismatch in the ID token/); - nockDone(); - }); - - it("should throw for invalid maxAge", async () => { - const { nockDone } = await nockBack("auth/fixtures/oauth.json", { - before: await withIdToken({ - ...opts, - payload: { auth_time: Math.floor(Date.now() / 1000) - 500 }, - }), - }); - const oauth = new OAuth(opts); - await expect( - oauth.authorizationCodeGrantWithPKCE( - { - code: "test-code", - code_verifier: "test-valid-code-verifier", - redirect_uri: "https://example.com", - }, - { idTokenValidateOptions: { maxAge: 100 } }, - ), - ).rejects.toThrowError(/\(auth_time\) claim in the ID token indicates that too much time has passed/); - nockDone(); - }); -}); diff --git a/tests/auth/passwordless.test.ts b/tests/auth/passwordless.test.ts deleted file mode 100644 index 0b5af4b039..0000000000 --- a/tests/auth/passwordless.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import nock from "nock"; -import { beforeAll, afterAll } from "@jest/globals"; -import { Passwordless, LoginWithEmailRequest, LoginWithSMSRequest } from "../../src/index.js"; -import { withIdToken } from "../utils/index.js"; - -const { back: nockBack } = nock; - -const DOMAIN = "test-domain.auth0.com"; -const CLIENT_ID = "test-client-id"; -const EMAIL = "test-email@example.com"; -const PHONE_NUMBER = "01234"; -const CLIENT_SECRET = "test-client-secret"; - -nockBack.setMode("lockdown"); - -const baseUrl = `https://${DOMAIN}/`; - -const opts = { - domain: DOMAIN, - baseUrl, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - idTokenSigningAlg: "HS256", -}; - -describe("Passwordless", () => { - let nockDone: () => void; - - beforeAll(async () => { - ({ nockDone } = await nockBack("auth/fixtures/passwordless.json", { - before: await withIdToken({ - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }), - })); - }); - - afterAll(() => { - nockDone(); - }); - - describe("#sendEmail", () => { - it("should start passwordless using an email", async () => { - const passwordless = new Passwordless(opts); - const response = await passwordless.sendEmail({ - email: EMAIL, - }); - - expect(response.status).toBe(200); - }); - - it("should require email", async () => { - const passwordless = new Passwordless(opts); - await expect(passwordless.sendEmail({} as any)).rejects.toThrow( - "Required parameter requestParameters.email was null or undefined.", - ); - }); - }); - - describe("#sendSMS", () => { - it("should start passwordless using an SMS", async () => { - const passwordless = new Passwordless(opts); - const response = await passwordless.sendSMS({ - phone_number: PHONE_NUMBER, - }); - - expect(response.status).toBe(200); - }); - - it("should require phone_number", async () => { - const passwordless = new Passwordless(opts); - await expect(passwordless.sendEmail({} as any)).rejects.toThrow( - "Required parameter requestParameters.email was null or undefined.", - ); - }); - }); - - describe("#loginWithEmail", () => { - it("should require email", async () => { - const passwordless = new Passwordless(opts); - await expect(passwordless.loginWithEmail({ code: "foo" } as LoginWithEmailRequest)).rejects.toThrow( - "Required parameter requestParameters.email was null or undefined.", - ); - }); - - it("should login with code from email", async () => { - const passwordless = new Passwordless(opts); - const response = await passwordless.loginWithEmail({ - email: "test-email@example.com", - code: "test-code", - }); - - expect(response.status).toBe(200); - expect(response.data).toMatchObject({ - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - id_token: expect.any(String), - scope: "openid profile email address phone", - }); - }); - }); - - describe("#loginWithSMS", () => { - it("should require phone_number", async () => { - const passwordless = new Passwordless(opts); - await expect(passwordless.loginWithSMS({ code: "foo" } as LoginWithSMSRequest)).rejects.toThrow( - "Required parameter requestParameters.phone_number was null or undefined.", - ); - }); - - it("should login with code from SMS", async () => { - const passwordless = new Passwordless({ ...opts, clientSecret: "test-client-secret" }); - const response = await passwordless.loginWithSMS({ - phone_number: "test-phone-number", - code: "test-code", - }); - - expect(response.status).toBe(200); - expect(response.data).toMatchObject({ - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - id_token: expect.any(String), - scope: "openid profile email address phone", - }); - }); - }); -}); diff --git a/tests/auth/tokenExchange.test.ts b/tests/auth/tokenExchange.test.ts deleted file mode 100644 index ac5a77aa2c..0000000000 --- a/tests/auth/tokenExchange.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -// custom-token-exchange.test.ts -import nock from "nock"; -import { CustomTokenExchange, CustomTokenExchangeOptions } from "../../src/auth/tokenExchange.js"; -import { AuthenticationClientOptions } from "../../src/auth/base-auth-api.js"; - -const DOMAIN = "test-tenant.auth0.com"; -const CLIENT_ID = "TEST_CLIENT_ID"; -const CLIENT_SECRET = "TEST_CLIENT_SECRET"; -const AUDIENCE = "https://api.example.com"; - -const mockOptions: AuthenticationClientOptions = { - domain: DOMAIN, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, -}; - -describe("CustomTokenExchange", () => { - let client: CustomTokenExchange; - - beforeAll(() => { - nock.disableNetConnect(); - }); - - afterAll(() => { - nock.enableNetConnect(); - }); - - beforeEach(() => { - client = new CustomTokenExchange(mockOptions); - nock.cleanAll(); - }); - - describe("exchangeToken()", () => { - const baseParams: CustomTokenExchangeOptions = { - subject_token_type: "urn:test:token", - subject_token: "external-token-123", - audience: AUDIENCE, - }; - - test("should successfully exchange valid token", async () => { - // Mock successful token response - nock(`https://${DOMAIN}`) - .post("/oauth/token", (body) => { - return ( - body.grant_type === "urn:ietf:params:oauth:grant-type:token-exchange" && - body.subject_token_type === "urn:test:token" && - body.client_id === CLIENT_ID - ); - }) - .reply(200, { - access_token: "eyJ.ACCESS.TOKEN", - refresh_token: "eyJ.REFRESH.TOKEN", - id_token: "eyJ.ID.TOKEN", - token_type: "Bearer", - expires_in: 86400, - scope: "openid profile", - }); - - const result = await client.exchangeToken(baseParams); - - expect(result).toEqual({ - access_token: "eyJ.ACCESS.TOKEN", - refresh_token: "eyJ.REFRESH.TOKEN", - id_token: "eyJ.ID.TOKEN", - token_type: "Bearer", - expires_in: 86400, - scope: "openid profile", - }); - }); - - test("should include optional scope parameter in request body", async () => { - nock(`https://${DOMAIN}`) - .post("/oauth/token", (body) => { - // Verify body contains URL-encoded scope parameter - return ( - body.scope === "openid profile email" && - body.grant_type === "urn:ietf:params:oauth:grant-type:token-exchange" - ); - }) - .reply(200, { - access_token: "...", - id_token: "...", - expires_in: 3600, - scope: "openid profile email", - }); - - const result = await client.exchangeToken({ - ...baseParams, - scope: "openid profile email", - }); - - expect(result.scope).toBe("openid profile email"); - }); - - test("should handle consent_required error", async () => { - nock(`https://${DOMAIN}`).post("/oauth/token").reply(400, { - error: "invalid_request", - error_description: "Consent required", - }); - - await expect(client.exchangeToken(baseParams)).rejects.toThrow("Consent required"); - }); - - test("should handle rate limiting", async () => { - nock(`https://${DOMAIN}`).post("/oauth/token").reply(429, { - error: "too_many_attempts", - error_description: "Too many requests - try again later", - }); - - await expect(client.exchangeToken(baseParams)).rejects.toThrow("Too many requests"); - }); - - test("should handle invalid credentials", async () => { - nock(`https://${DOMAIN}`).post("/oauth/token").reply(401, { - error: "invalid_client", - error_description: "Invalid client credentials", - }); - - await expect(client.exchangeToken(baseParams)).rejects.toThrow("Invalid client credentials"); - }); - - test("should forward custom parameters", async () => { - nock(`https://${DOMAIN}`) - .post("/oauth/token", /custom_param=value/) - .reply(200, { access_token: "...", id_token: "...", expires_in: 3600 }); - - await client.exchangeToken({ - ...baseParams, - custom_param: "value", - }); - }); - }); -}); diff --git a/tests/lib/export-surface.test.ts b/tests/lib/export-surface.test.ts new file mode 100644 index 0000000000..e0693ac2bd --- /dev/null +++ b/tests/lib/export-surface.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "@jest/globals"; +import * as auth0 from "../../src/index.js"; + +describe("Export Surface (v7.0.0)", () => { + it("should NOT export AuthenticationClient", () => { + expect((auth0 as any).AuthenticationClient).toBeUndefined(); + }); + + it("should NOT export UserInfoClient", () => { + expect((auth0 as any).UserInfoClient).toBeUndefined(); + }); + + it("should export ManagementClient", () => { + expect(auth0.ManagementClient).toBeDefined(); + expect(typeof auth0.ManagementClient).toBe("function"); + }); + + it("should export Management (Fern client)", () => { + expect((auth0 as any).Management).toBeDefined(); + }); +}); diff --git a/tests/lib/runtime.test.ts b/tests/lib/runtime.test.ts deleted file mode 100644 index f6a9685856..0000000000 --- a/tests/lib/runtime.test.ts +++ /dev/null @@ -1,1140 +0,0 @@ -import nock from "nock"; -import { jest } from "@jest/globals"; -import { - AuthenticationClient, - UserInfoClient, - UserInfoError, - AuthApiError, - ResponseError as NewResponseError, -} from "../../src/index.js"; -import { ManagementClient, ManagementApiError, CustomDomainHeader, ResponseError } from "auth0-legacy"; -import { InitOverrideFunction, RequestOpts } from "../../src/lib/models.js"; -import { BaseAPI, applyQueryParams } from "../../src/lib/runtime.js"; - -import * as utils from "../../src/utils.js"; -import { base64url } from "jose"; - -export class TestClient extends BaseAPI { - public async testRequest( - context: RequestOpts, - initOverrides?: RequestInit | InitOverrideFunction, - ): Promise { - return this.request(context, initOverrides); - } -} - -const parseError = async (response: Response) => { - const body = await response.text(); - return new ResponseError(response.status, body, response.headers, "Response returned an error code"); -}; - -describe("Runtime", () => { - const URL = "https://tenant.auth0.com/api/v2"; - let interval: NodeJS.Timeout; - - beforeEach(() => { - interval = setInterval(() => jest.advanceTimersByTime(1000), 10); - jest.useFakeTimers({ - doNotFake: ["nextTick"], - }); - }); - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - jest.useRealTimers(); - clearInterval(interval); - }); - - it("should use globalThis.fetch bound to globalThis when fetch is not provided in configuration", () => { - // Mock globalThis.fetch to verify it's used - const originalFetch = globalThis.fetch; - let calledWithGlobalThis = false; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Ignoring type errors for test purposes - globalThis.fetch = async function () { - //This is important for "workerd" the process used by cloudflare workers. - calledWithGlobalThis = this === globalThis; - return new Response(); - }; - - try { - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - // Call the fetchApi - (client as any).fetchApi("https://example.com"); - - expect(calledWithGlobalThis).toBe(true); - } finally { - // Restore the original fetch - globalThis.fetch = originalFetch; - } - }); - - it("should retry 429 until getting a succesful response", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .times(2) - .reply(429) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - const response = await client.testRequest({ - path: `/clients`, - method: "GET", - }); - - const data = (await response.json()) as Array<{ client_id: string }>; - - expect(data[0].client_id).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should only retry until default configured attempts", async () => { - nock(URL, { encodedQueryParams: true }) - .get("/clients") - .times(4) - .reply(429) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError(expect.objectContaining({ statusCode: 429 })); - }); - - it("should retry 429 the configured amount of times", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .times(6) - .reply(429) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - retry: { - maxRetries: 6, - }, - }); - - const response = await client.testRequest({ - path: `/clients`, - method: "GET", - }); - - const data = (await response.json()) as Array<{ client_id: string }>; - - expect(data[0].client_id).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should not retry if not 429", async () => { - nock(URL, { encodedQueryParams: true }) - .get("/clients") - .reply(428) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError(expect.objectContaining({ statusCode: 428 })); - }); - - it("should retry using a configurable status code", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .times(2) - .reply(428) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - retry: { - retryWhen: [428], - }, - }); - - const response = await client.testRequest({ - path: `/clients`, - method: "GET", - }); - - const data = (await response.json()) as Array<{ client_id: string }>; - - expect(data[0].client_id).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should retry using multiple configurable status code", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .reply(428) - .get("/clients") - .reply(429) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - retry: { - retryWhen: [428, 429], - }, - }); - - const response = await client.testRequest({ - path: `/clients`, - method: "GET", - }); - const data = (await response.json()) as Array<{ client_id: string }>; - - expect(data[0].client_id).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should only retry configured status codes", async () => { - nock(URL, { encodedQueryParams: true }) - .get("/clients") - .reply(428) - .get("/clients") - .reply(429) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - retry: { - retryWhen: [428], - }, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError(expect.objectContaining({ statusCode: 429 })); - }); - - it("should not retry if not enabled", async () => { - nock(URL, { encodedQueryParams: true }) - .get("/clients") - .reply(429) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - retry: { - enabled: false, - }, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError(expect.objectContaining({ statusCode: 429 })); - }); - - it("should retry on ECONNRESET when retry is enabled", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .times(2) - .replyWithError({ code: "ECONNRESET", message: "socket hang up" }) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - const response = await client.testRequest({ - path: `/clients`, - method: "GET", - }); - - const data = (await response.json()) as Array<{ client_id: string }>; - expect(data[0].client_id).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should retry on ECONNRESET wrapped in TypeError (native fetch shape)", async () => { - // Native fetch (undici) does not put code on the top-level error. - // It throws: TypeError: fetch failed { cause: Error: read ECONNRESET { code: "ECONNRESET" } } - // This test ensures isRetryableNetworkError handles that shape. - let callCount = 0; - const mockFetch = async (): Promise => { - callCount++; - if (callCount <= 2) { - const cause = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }); - const err = new TypeError("fetch failed"); - (err as any).cause = cause; - throw err; - } - return new Response(JSON.stringify([{ client_id: "123" }]), { status: 200 }); - }; - - const client = new TestClient({ - baseUrl: URL, - parseError, - fetch: mockFetch, - }); - - const response = await client.testRequest({ - path: `/clients`, - method: "GET", - }); - - const data = (await response.json()) as Array<{ client_id: string }>; - expect(data[0].client_id).toBe("123"); - expect(callCount).toBe(3); - }); - - it("should retry on EPIPE when retry is enabled", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .replyWithError({ code: "EPIPE", message: "write EPIPE" }) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - const response = await client.testRequest({ - path: `/clients`, - method: "GET", - }); - - const data = (await response.json()) as Array<{ client_id: string }>; - expect(data[0].client_id).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should retry on ECONNABORTED when retry is enabled", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .replyWithError({ code: "ECONNABORTED", message: "connection aborted" }) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - const response = await client.testRequest({ - path: `/clients`, - method: "GET", - }); - - const data = (await response.json()) as Array<{ client_id: string }>; - expect(data[0].client_id).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should throw after exhausting retries on repeated ECONNRESET", async () => { - nock(URL, { encodedQueryParams: true }) - .get("/clients") - .times(4) - .replyWithError({ code: "ECONNRESET", message: "socket hang up" }); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError( - expect.objectContaining({ cause: expect.objectContaining({ message: "socket hang up" }) }), - ); - }); - - it("should not retry ECONNRESET when retry is disabled", async () => { - nock(URL, { encodedQueryParams: true }) - .get("/clients") - .replyWithError({ code: "ECONNRESET", message: "socket hang up" }) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - retry: { enabled: false }, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError( - expect.objectContaining({ cause: expect.objectContaining({ message: "socket hang up" }) }), - ); - }); - - it("should not retry non-retryable errors like ECONNREFUSED", async () => { - nock(URL, { encodedQueryParams: true }) - .get("/clients") - .replyWithError({ code: "ECONNREFUSED", message: "connect ECONNREFUSED" }) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError( - expect.objectContaining({ cause: expect.objectContaining({ message: "connect ECONNREFUSED" }) }), - ); - - // Second nock was never consumed — confirms no retry occurred - expect(nock.pendingMocks().length).toBe(1); - nock.cleanAll(); - }); - - it("should not retry on timeout errors", async () => { - nock(URL) - .get("/clients") - .delayConnection(100) - .reply(200, [{ client_id: "123" }]) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const client = new TestClient({ - baseUrl: URL, - parseError, - timeoutDuration: 50, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError(expect.objectContaining({ cause: expect.objectContaining({ name: "TimeoutError" }) })); - - // Second nock was never consumed — confirms no retry occurred - expect(nock.pendingMocks().length).toBe(1); - nock.abortPendingRequests(); - }); - - it("should timeout after default time", async () => { - nock(URL).get("/clients").delayConnection(10000).reply(200, []); - - const client = new TestClient({ - baseUrl: URL, - parseError, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError(expect.objectContaining({ cause: expect.objectContaining({ name: "TimeoutError" }) })); - nock.abortPendingRequests(); - }); - - it("should timeout after configured time", async () => { - nock(URL).get("/clients").delayConnection(100).reply(200, []); - - const client = new TestClient({ - baseUrl: URL, - parseError, - timeoutDuration: 50, - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError(expect.objectContaining({ cause: expect.objectContaining({ name: "TimeoutError" }) })); - nock.abortPendingRequests(); - }); - - it("should execute onError middleware", async () => { - nock(URL).get("/clients").reply(500, {}); - - const client = new TestClient({ - baseUrl: URL, - parseError, - middleware: [ - { - onError() { - return new Response(undefined, { status: 418 }) as Response; - }, - }, - ], - }); - - await expect( - client.testRequest({ - path: `/clients`, - method: "GET", - }), - ).rejects.toThrowError(expect.objectContaining({ statusCode: 418 })); - }); - - it("should execute post middleware", async () => { - nock(URL).get("/clients").reply(200, { foo: "bar" }); - - const client = new TestClient({ - baseUrl: URL, - parseError, - middleware: [ - { - post() { - return new Response(JSON.stringify({ bar: "foo" }), { - status: 200, - }) as Response; - }, - }, - ], - }); - - const resonse = client.testRequest({ - path: `/clients`, - method: "GET", - }); - await expect((await resonse).json()).resolves.toMatchObject({ bar: "foo" }); - }); - - it("should apply query params", () => { - const params = applyQueryParams({ foo: "bar" }, [{ key: "foo", config: {} }]); - expect(params).toEqual({ foo: "bar" }); - }); - - it("should ignore unknown query params", () => { - const params = applyQueryParams({ foo: "bar" }, []); - expect(params).not.toHaveProperty("foo"); - }); - - it("should ignore undefined query params", () => { - const params = applyQueryParams({ foo: undefined }, [{ key: "foo", config: {} }]); - expect(params).not.toHaveProperty("foo"); - }); - - it("should apply array of query params with multiple params", () => { - const params = applyQueryParams({ foo: ["bar", "baz"] }, [{ key: "foo", config: { isArray: true } }]); - expect(params).not.toHaveProperty("bar,baz"); - }); - - it("should apply array of query params with single param", () => { - const params = applyQueryParams({ foo: ["bar", "baz"] }, [{ key: "foo", config: { isArray: true } }]); - expect(params).toEqual({ foo: "bar,baz" }); - }); - - it("should apply array of query params with multiple params", () => { - const params = applyQueryParams({ foo: ["bar", "baz"] }, [ - { key: "foo", config: { isArray: true, isCollectionFormatMulti: true } }, - ]); - expect(params).toEqual({ foo: ["bar", "baz"] }); - }); -}); - -describe("Runtime for ManagementClient", () => { - const URL = "https://tenant.auth0.com/api/v2"; - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - it("should retry if enabled", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .times(2) - .reply(429) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const token = "TOKEN"; - const client = new ManagementClient({ - domain: "tenant.auth0.com", - token: token, - }); - const response = await client.clients.getAll(); - - expect(response.data[0].client_id).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should not retry if not enabled", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .get("/clients") - .reply(429) - .get("/clients") - .reply(200, [{ client_id: "123" }]); - - const token = "TOKEN"; - const client = new ManagementClient({ - domain: "tenant.auth0.com", - token: token, - retry: { - enabled: false, - }, - }); - - try { - await client.clients.getAll(); - - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof ResponseError) { - expect(e.statusCode).toBe(429); - expect(request.isDone()).toBe(false); - } else { - expect(e).toBeInstanceOf(ResponseError); - } - } - }); - - it("should throw a ResponseError when response does not provide payload", async () => { - nock(URL, { encodedQueryParams: true }).get("/clients").reply(428); - - const token = "TOKEN"; - const client = new ManagementClient({ - domain: "tenant.auth0.com", - token: token, - }); - - try { - await client.clients.getAll(); - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof ResponseError) { - expect(e.statusCode).toBe(428); - } else { - expect(e).toBeInstanceOf(ResponseError); - } - } - }); - - it("should throw an ManagementError when backend provides known error details", async () => { - nock(URL, { encodedQueryParams: true }).get("/clients").reply(428, { - error: "test error", - errorCode: "test error code", - message: "test message", - statusCode: 401, - }); - - const token = "TOKEN"; - const client = new ManagementClient({ - domain: "tenant.auth0.com", - token: token, - }); - - try { - await client.clients.getAll(); - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof ManagementApiError) { - expect(e.error).toBe("test error"); - expect(e.errorCode).toBe("test error code"); - expect(e.message).toBe("test message"); - expect(e.statusCode).toBe(401); - } else { - expect(e).toBeInstanceOf(ManagementApiError); - } - } - }); - - it("should throw an ManagementError and fallback to the response status code when statusCode omitted from response", async () => { - nock(URL, { encodedQueryParams: true }).get("/clients").reply(428, { - error: "test error", - errorCode: "test error code", - message: "test message", - }); - - const token = "TOKEN"; - const client = new ManagementClient({ - domain: "tenant.auth0.com", - token: token, - }); - - try { - await client.clients.getAll(); - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof ManagementApiError) { - expect(e.error).toBe("test error"); - expect(e.errorCode).toBe("test error code"); - expect(e.message).toBe("test message"); - expect(e.statusCode).toBe(428); - } else { - expect(e).toBeInstanceOf(ManagementApiError); - } - } - }); - - it("should add the telemetry by default", async () => { - const request = nock(URL) - .get("/clients") - .matchHeader("Auth0-Client", (val) => !!val) // Just check that Auth0-Client header exists - .reply(200, []); - - const token = "TOKEN"; - const client = new ManagementClient({ - domain: "tenant.auth0.com", - token: token, - }); - await client.clients.getAll(); - - expect(request.isDone()).toBe(true); - }); - - it("should add the telemetry in workerd contexts", async () => { - // Mock the RUNTIME module to simulate workerd environment - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const originalRUNTIME = (utils as any).RUNTIME; - - // Mock the utils module's generateClientInfo function - const mockGenerateClientInfo = jest.spyOn(utils, "generateClientInfo"); - mockGenerateClientInfo.mockReturnValue({ - name: "node-auth0", - version: "1.0.0", // Mock version - env: { - "cloudflare-workers": "unknown", - }, - }); - - try { - const clientInfo = utils.generateClientInfo(); - - expect(clientInfo).toEqual({ - name: "node-auth0", - version: expect.any(String), - env: { - "cloudflare-workers": "unknown", - }, - }); - - expect(clientInfo.version).toMatch(/^\d+\.\d+\.\d+(?:-[\w.]+)?$/); - } finally { - // Restore the mock - mockGenerateClientInfo.mockRestore(); - } - }); - - it("should add custom telemetry when provided", async () => { - const mockClientInfo = { name: "test", version: "12", env: { node: "16" } }; - - const request = nock(URL) - .get("/clients") - .matchHeader("Auth0-Client", base64url.encode(JSON.stringify(mockClientInfo))) - .reply(200, []); - - const token = "TOKEN"; - const client = new ManagementClient({ - domain: "tenant.auth0.com", - token: token, - clientInfo: mockClientInfo, - }); - await client.clients.getAll(); - - expect(request.isDone()).toBe(true); - }); - - it("should not add the telemetry when disabled", async () => { - const request = nock(URL, { badheaders: ["Auth0-Client"] }) - .get("/clients") - .reply(200, []); - - const token = "TOKEN"; - const client = new ManagementClient({ - domain: "tenant.auth0.com", - token: token, - telemetry: false, - }); - await client.clients.getAll(); - - expect(request.isDone()).toBe(true); - }); -}); - -describe("Runtime for AuthenticationClient", () => { - const URL = "https://tenant.auth0.com"; - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - it("should retry if enabled", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .post("/oauth/token") - .times(2) - .reply(429) - .post("/oauth/token") - .reply(200, { access_token: "123" }); - - const client = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId: "123", - clientSecret: "123", - retry: { - enabled: true, - }, - }); - const response = await client.oauth.clientCredentialsGrant({ - audience: "123", - }); - - expect(response.data.access_token).toBe("123"); - expect(request.isDone()).toBe(true); - }); - - it("should not retry if not enabled", async () => { - const request = nock(URL, { encodedQueryParams: true }) - .post("/oauth/token") - .reply(429) - .post("/oauth/token") - .reply(200, { access_token: "123" }); - - const client = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId: "123", - clientSecret: "123", - }); - - try { - await client.oauth.clientCredentialsGrant({ - audience: "123", - }); - - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof NewResponseError) { - expect(e.statusCode).toBe(429); - expect(request.isDone()).toBe(false); - } else { - expect(e).toBeInstanceOf(NewResponseError); - } - } - }); - - it("should throw a ResponseError when response does not provide payload", async () => { - nock(URL, { encodedQueryParams: true }).post("/oauth/token").reply(428); - - const client = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId: "123", - clientSecret: "123", - }); - - try { - await client.oauth.clientCredentialsGrant({ - audience: "123", - }); - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof NewResponseError) { - expect(e.statusCode).toBe(428); - } else { - expect(e).toBeInstanceOf(NewResponseError); - } - } - }); - - it("should throw an AuthApiError when backend provides known error details", async () => { - nock(URL, { encodedQueryParams: true }) - .post("/oauth/token") - .reply(428, { error: "test error", error_description: "test error description" }); - - const client = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId: "123", - clientSecret: "123", - }); - - try { - await client.oauth.clientCredentialsGrant({ - audience: "123", - }); - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof AuthApiError) { - expect(e.error).toBe("test error"); - expect(e.error_description).toBe("test error description"); - expect(e.message).toBe("test error description"); - } else { - expect(e).toBeInstanceOf(AuthApiError); - } - } - }); - - it("should add the telemetry by default", async () => { - const request = nock(URL) - .post("/oauth/token") - .matchHeader("Auth0-Client", base64url.encode(JSON.stringify(utils.generateClientInfo()))) - .reply(200, {}); - - const client = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId: "123", - clientSecret: "123", - }); - - await client.oauth.clientCredentialsGrant({ - audience: "123", - }); - - expect(request.isDone()).toBe(true); - }); - - it("should add custom telemetry when provided", async () => { - const mockClientInfo = { name: "test", version: "12", env: { node: "16" } }; - - const request = nock(URL) - .post("/oauth/token") - .matchHeader("Auth0-Client", base64url.encode(JSON.stringify(mockClientInfo))) - .reply(200, []); - - const client = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId: "123", - clientSecret: "123", - clientInfo: mockClientInfo, - }); - - await client.oauth.clientCredentialsGrant({ - audience: "123", - }); - - expect(request.isDone()).toBe(true); - }); - - it("should not add the telemetry when disabled", async () => { - const request = nock(URL, { badheaders: ["Auth0-Client"] }) - .post("/oauth/token") - .reply(200, []); - - const client = new AuthenticationClient({ - domain: "tenant.auth0.com", - clientId: "123", - clientSecret: "123", - telemetry: false, - }); - - await client.oauth.clientCredentialsGrant({ - audience: "123", - }); - - expect(request.isDone()).toBe(true); - }); -}); - -describe("Runtime for UserInfoClient", () => { - const URL = "https://tenant.auth0.com"; - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - it("should throw a ResponseError when response does not provide payload", async () => { - nock(URL, { encodedQueryParams: true }).get("/userinfo").reply(428); - - const client = new UserInfoClient({ - domain: "tenant.auth0.com", - }); - - try { - await client.getUserInfo("token"); - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof NewResponseError) { - expect(e.statusCode).toBe(428); - } else { - expect(e).toBeInstanceOf(NewResponseError); - } - } - }); - - it("should throw a UserInfoApiError when backend provides known error details", async () => { - nock(URL, { encodedQueryParams: true }) - .get("/userinfo") - .reply(428, { error: "test error", error_description: "test error description" }); - - const client = new UserInfoClient({ - domain: "tenant.auth0.com", - }); - - try { - await client.getUserInfo("token"); - // Should not reach this - expect(true).toBeFalsy(); - } catch (e: any) { - if (e instanceof UserInfoError) { - expect(e.error).toBe("test error"); - expect(e.error_description).toBe("test error description"); - expect(e.message).toBe("test error description"); - } else { - expect(e).toBeInstanceOf(UserInfoError); - } - } - }); - - it("should add the telemetry by default", async () => { - const request = nock(URL) - .get("/userinfo") - .matchHeader("Auth0-Client", base64url.encode(JSON.stringify(utils.generateClientInfo()))) - .reply(200, {}); - - const client = new UserInfoClient({ - domain: "tenant.auth0.com", - }); - - await client.getUserInfo("token"); - - expect(request.isDone()).toBe(true); - }); - - it("should add custom telemetry when provided", async () => { - const mockClientInfo = { name: "test", version: "12", env: { node: "16" } }; - - const request = nock(URL) - .get("/userinfo") - .matchHeader("Auth0-Client", base64url.encode(JSON.stringify(mockClientInfo))) - .reply(200, {}); - - const client = new UserInfoClient({ - domain: "tenant.auth0.com", - clientInfo: mockClientInfo, - }); - - await client.getUserInfo("token"); - - expect(request.isDone()).toBe(true); - }); - - it("should not add the telemetry when disabled", async () => { - const request = nock(URL, { badheaders: ["Auth0-Client"] }) - .get("/userinfo") - .reply(200, {}); - - const client = new UserInfoClient({ - domain: "tenant.auth0.com", - telemetry: false, - }); - - await client.getUserInfo("token"); - - expect(request.isDone()).toBe(true); - }); -}); - -describe("CustomDomainHeader", () => { - const domain = "custom.domain.com"; - - const whitelistedPaths = [ - "/api/v2/jobs/verification-email", - "/api/v2/tickets/email-verification", - "/api/v2/tickets/password-change", - "/api/v2/organizations/org123/invitations", - "/api/v2/users", - "/api/v2/users/user123", - "/api/v2/guardian/enrollments/ticket", - ]; - - const nonWhitelistedPath = "/api/v2/not-whitelisted"; - - const method = "GET"; - - it("adds the custom domain header for whitelisted paths", async () => { - for (const path of whitelistedPaths) { - const fn = CustomDomainHeader(domain); - const result = await fn({ - init: { method, headers: {} }, - context: { method, path }, - }); - const headers = result.headers as Record; - expect(headers["auth0-custom-domain"]).toBe(domain); - } - }); - - it("does not add the custom domain header for non-whitelisted paths", async () => { - const fn = CustomDomainHeader(domain); - const result = await fn({ - init: { method, headers: {} }, - context: { method, path: nonWhitelistedPath }, - }); - const headers = result.headers as Record; - expect(headers["auth0-custom-domain"]).toBeUndefined(); - }); - - it("prepends /api/v2 to non-prefixed paths", async () => { - const fn = CustomDomainHeader(domain); - const result = await fn({ - init: { method, headers: {} }, - context: { method, path: "/users" }, - }); - const headers = result.headers as Record; - expect(headers["auth0-custom-domain"]).toBe(domain); - }); - - it("preserves existing headers", async () => { - const fn = CustomDomainHeader(domain); - const result = await fn({ - init: { method, headers: { "existing-header": "value" } }, - context: { method, path: whitelistedPaths[0] }, - }); - const headers = result.headers as Record; - expect(headers["existing-header"]).toBe("value"); - expect(headers["auth0-custom-domain"]).toBe(domain); - }); -}); diff --git a/tests/management/token-provider.test.ts b/tests/management/token-provider.test.ts deleted file mode 100644 index 0e2cebd50d..0000000000 --- a/tests/management/token-provider.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import nock from "nock"; -import { jest } from "@jest/globals"; -import { TokenProvider } from "../../src/management/wrapper/token-provider.js"; -import { FetchAPI } from "../../src/lib/models.js"; - -const opts = { - domain: "test-domain.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - audience: "my-api", -}; - -const url = `https://${opts.domain}`; - -describe("TokenProvider", () => { - const spy = jest.fn().mockReturnValue({ - access_token: "my-access-token", - expires_in: 86400, - token_type: "Bearer", - }); - - beforeEach(() => { - nock(url) - .persist() - .post( - "/oauth/token", - `client_id=${opts.clientId}&audience=${opts.audience}&client_secret=${opts.clientSecret}&grant_type=client_credentials`, - ) - .reply(200, spy); - }); - - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - jest.useRealTimers(); - }); - - it("should get an access token", async () => { - const tp = new TokenProvider(opts); - expect(await tp.getAccessToken()).toBe("my-access-token"); - expect(spy).toHaveBeenCalled(); - }); - - it("should get a cached access token", async () => { - const tp = new TokenProvider(opts); - expect(await tp.getAccessToken()).toBe("my-access-token"); - expect(await tp.getAccessToken()).toBe("my-access-token"); - expect(spy).toHaveBeenCalledTimes(1); - }); - - it("should get a new access token if token expires", async () => { - // Mock Date.now to control time - const originalDateNow = Date.now; - let currentTime = 1000000000000; // Starting timestamp - Date.now = jest.fn(() => currentTime); - - try { - const tp = new TokenProvider(opts); - - // First call should fetch a new token - expect(await tp.getAccessToken()).toBe("my-access-token"); - expect(spy).toHaveBeenCalledTimes(1); - - // Advance time by 2 days (more than the 86400 seconds expiry + 10 second leeway) - currentTime += (86400 + 20) * 1000; - - // Second call should fetch a new token because it's expired - expect(await tp.getAccessToken()).toBe("my-access-token"); - expect(spy).toHaveBeenCalledTimes(2); - } finally { - // Restore original Date.now - Date.now = originalDateNow; - } - }); - - it("should get a new access token if token expires in leeway", async () => { - // Mock Date.now to control time - const originalDateNow = Date.now; - let currentTime = 1000000000000; // Starting timestamp - Date.now = jest.fn(() => currentTime); - - try { - const tp = new TokenProvider(opts); - - // First call should fetch a new token - expect(await tp.getAccessToken()).toBe("my-access-token"); - expect(spy).toHaveBeenCalledTimes(1); - - // Advance time to within the leeway window (86400 - 5 seconds from expiry) - // This should trigger a refresh because it's within the 10 second LEEWAY - currentTime += (86400 - 5) * 1000; - - // Second call should fetch a new token because we're in the leeway window - expect(await tp.getAccessToken()).toBe("my-access-token"); - expect(spy).toHaveBeenCalledTimes(2); - } finally { - // Restore original Date.now - Date.now = originalDateNow; - } - }); - - it("should not cache failed requests", async () => { - const domain = "fail.auth0.com"; - const url = `https://${domain}`; - const tp = new TokenProvider({ ...opts, domain }); - - nock(url).post("/oauth/token").reply(500, {}); - nock(url).post("/oauth/token").reply(200, spy); - - await expect(tp.getAccessToken()).rejects.toThrowError(); - expect(await tp.getAccessToken()).toBe("my-access-token"); - }); - - it("should cache concurrent requests", async () => { - const tp = new TokenProvider(opts); - expect(await Promise.all([tp.getAccessToken(), tp.getAccessToken(), tp.getAccessToken()])).toEqual([ - "my-access-token", - "my-access-token", - "my-access-token", - ]); - - expect(spy).toHaveBeenCalledTimes(1); - }); - - it.skip("should use a custom fetch", async () => { - const customFetch = jest - .fn() - .mockImplementation((url: URL | RequestInfo, init?: RequestInit) => fetch(url, init)); - - // TODO: Casting to any to bypass type checks for testing purposes. - // This is done only because the fetcher type is being hidden for now. - const tp = new TokenProvider({ - ...opts, - fetcher: customFetch as any, - } as any); - expect(await tp.getAccessToken()).toBe("my-access-token"); - expect(customFetch).toHaveBeenCalled(); - }); -}); diff --git a/tests/userinfo/fixtures/userinfo.json b/tests/userinfo/fixtures/userinfo.json deleted file mode 100644 index f5c246168d..0000000000 --- a/tests/userinfo/fixtures/userinfo.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "scope": "https://test-domain.auth0.com", - "method": "GET", - "path": "/userinfo", - "status": 200, - "response": { - "sub": "248289761001", - "name": "Jane Josephine Doe", - "given_name": "Jane", - "family_name": "Doe", - "middle_name": "Josephine", - "nickname": "JJ", - "preferred_username": "j.doe", - "profile": "http://exampleco.com/janedoe", - "picture": "http://exampleco.com/janedoe/me.jpg", - "website": "http://exampleco.com", - "email": "janedoe@exampleco.com", - "email_verified": true, - "gender": "female", - "birthdate": "1972-03-31", - "zoneinfo": "America/Los_Angeles", - "locale": "en-US", - "phone_number": "+1 (111) 222-3434", - "phone_number_verified": false, - "address": { - "country": "us" - }, - "updated_at": "1556845729" - } - } -] diff --git a/tests/userinfo/index.test.ts b/tests/userinfo/index.test.ts deleted file mode 100644 index 3a6fcad461..0000000000 --- a/tests/userinfo/index.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import nock from "nock"; -import { beforeAll, afterAll } from "@jest/globals"; -import { UserInfoClient } from "../../src/index.js"; - -const { back: nockBack } = nock; - -const opts = { - domain: "test-domain.auth0.com", -}; - -describe("Users", () => { - let nockDone: () => void; - - beforeAll(async () => { - ({ nockDone } = await nockBack("userinfo/fixtures/userinfo.json")); - }); - - afterAll(() => { - nockDone(); - }); - - describe("#getUserInfo", () => { - it("should get the user info", async () => { - const users = new UserInfoClient(opts); - const accessToken = "MY_TOKEN"; - const { data } = await users.getUserInfo(accessToken); - - expect(data).toEqual( - expect.objectContaining({ - sub: "248289761001", - }), - ); - }); - - it("should use the provided access token", async () => { - const scope = nock("https://test-domain.auth0.com") - .get("/userinfo") - .matchHeader("Authorization", `Bearer MY_TOKEN`) - .reply(200, {}); - - const users = new UserInfoClient(opts); - const accessToken = "MY_TOKEN"; - await users.getUserInfo(accessToken); - - expect(scope.isDone()).toBeTruthy(); - }); - }); -}); From 5b5be26644d6f9c905bda9ef7d72cc41f1078614 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Mon, 17 Aug 2026 16:35:57 +0530 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20update=20for=20v7=20auth=20removal,?= =?UTF-8?q?=20add=20v6=E2=86=92v7=20migration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: replace AuthenticationClient/UserInfoClient sections with pointers to @auth0/auth0-auth-js; add 'Migrating from v6 to v7' with method-mapping table and mTLS note; preserve auth0/legacy docs - CHANGELOG: v7.0.0 breaking-change entry - token-provider: doc comment on @auth0/auth0-auth-js delegation + expiresAt seconds-to-ms conversion Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 +++++ README.md | 88 ++++++++++++++++++++---- src/management/wrapper/token-provider.ts | 7 ++ 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ea57f5d0..32d87b00b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Change Log +## [v7.0.0](https://github.com/auth0/node-auth0/tree/v7.0.0) (2026-08-17) + +[Full Changelog](https://github.com/auth0/node-auth0/compare/v6.2.0...v7.0.0) + +**⚠️ BREAKING CHANGES** + +- **Removed `AuthenticationClient` and `UserInfoClient` from main entrypoint**: The authentication layer has been separated into [`@auth0/auth0-auth-js`](https://github.com/auth0/node-auth0/tree/main/packages/auth0-auth-js). Use `AuthClient` from `@auth0/auth0-auth-js` for authentication operations, OAuth flows, token management, and user profile retrieval. +- **Management token acquisition now uses `@auth0/auth0-auth-js` internally**: The `TokenProvider` class delegates to `@auth0/auth0-auth-js` for client credentials grant. Token expiration handling now uses absolute Unix timestamps (converted from seconds to milliseconds). +- **mTLS requires explicit `customFetch` option**: When using `useMtls: true` with `@auth0/auth0-auth-js`, you must provide a `customFetch` function that configures the HTTPS agent with client certificates. + +**Migration Guide** + +See the [Migrating from v6 to v7](https://github.com/auth0/node-auth0/blob/master/README.md#migrating-from-v6-to-v7) section in the README for detailed upgrade instructions, including method mappings and mTLS configuration examples. + +The legacy entrypoint (`auth0/legacy`) continues to ship the v4.x API including `AuthenticationClient` for backward compatibility. + ## [v6.2.0](https://github.com/auth0/node-auth0/tree/v6.2.0) (2026-08-05) [Full Changelog](https://github.com/auth0/node-auth0/compare/v6.1.0...v6.2.0) diff --git a/README.md b/README.md index a8281b726d..8dd4eb23b5 100644 --- a/README.md +++ b/README.md @@ -34,20 +34,22 @@ npm install auth0 ### Configure the SDK -#### Authentication API Client +#### Authentication -This client can be used to access Auth0's [Authentication API](https://auth0.com/docs/api/authentication). +For authentication operations (OAuth flows, token management, user sign-up), use [`@auth0/auth0-auth-js`](https://github.com/auth0/node-auth0/tree/main/packages/auth0-auth-js). As of v7, node-auth0 no longer ships `AuthenticationClient` in its main entrypoint. The authentication layer has been separated into a dedicated package. ```js -import { AuthenticationClient } from "auth0"; +import { AuthClient } from "@auth0/auth0-auth-js"; -const auth0 = new AuthenticationClient({ +const auth = new AuthClient({ domain: "{YOUR_TENANT_AND REGION}.auth0.com", clientId: "{YOUR_CLIENT_ID}", clientSecret: "{OPTIONAL_CLIENT_SECRET}", }); ``` +See the [auth0-auth-js documentation](https://github.com/auth0/node-auth0/tree/main/packages/auth0-auth-js) for full API reference. + #### Management API Client The Auth0 Management API is meant to be used by back-end servers or trusted parties performing administrative tasks. Generally speaking, anything that can be done through the Auth0 dashboard (and more) can also be done through this API. @@ -169,25 +171,30 @@ types from the root `auth0` entry adds nothing to your bundle and does not pull > through a bundler. A plain CommonJS `require()` cannot tree-shake and loads the full > resource graph. -#### UserInfo API Client +#### User Profile Information -This client can be used to retrieve user profile information. +To retrieve user profile information, use the `getUserInfo` method from `@auth0/auth0-auth-js`: ```js -import { UserInfoClient } from "auth0"; +import { AuthClient } from "@auth0/auth0-auth-js"; -const userInfo = new UserInfoClient({ +const auth = new AuthClient({ domain: "{YOUR_TENANT_AND REGION}.auth0.com", + clientId: "{YOUR_CLIENT_ID}", }); // Get user info with an access token -const userProfile = await userInfo.getUserInfo(accessToken); +const userProfile = await auth.getUserInfo(accessToken); ``` +As of v7, node-auth0 no longer ships `UserInfoClient`. Use `AuthClient.getUserInfo()` from `@auth0/auth0-auth-js` instead. + ## Legacy Usage If you are migrating from the legacy `node-auth0` package (v4.x) or need to maintain compatibility with legacy code, you can use the legacy export which provides the `node-auth0` v4.x API interface. +**Note:** The legacy entrypoint still includes `AuthenticationClient` from the v4.x API. This is separate from the v7 main entrypoint, which no longer ships authentication clients. + ### Installing Legacy Version The legacy version (`node-auth0` v4.x) is available through the `/legacy` export path: @@ -202,7 +209,7 @@ const { ManagementClient, AuthenticationClient } = require("auth0/legacy"); ### Legacy Configuration -The legacy API uses the `node-auth0` v4.x configuration format and method signatures, which are different from the current v6 API: +The legacy API uses the `node-auth0` v4.x configuration format and method signatures, which are different from the current API: #### Legacy Management Client @@ -345,6 +352,65 @@ try { } ``` +## Migrating from v6 to v7 + +Version 7.0.0 removes authentication clients from the main entrypoint. The authentication layer has been separated into [`@auth0/auth0-auth-js`](https://github.com/auth0/node-auth0/tree/main/packages/auth0-auth-js). + +### Install the authentication package + +```bash +npm install @auth0/auth0-auth-js +``` + +### Update imports + +```js +// v6 +import { AuthenticationClient, UserInfoClient } from "auth0"; + +// v7 +import { AuthClient } from "@auth0/auth0-auth-js"; +``` + +### Method mapping + +| v6 (node-auth0) | v7 (@auth0/auth0-auth-js) | +| --------------------------------------------------- | --------------------------------------------- | +| `authenticationClient.authorizationCodeGrant(...)` | `authClient.getTokenByCode(...)` | +| `authenticationClient.clientCredentialsGrant(...)` | `authClient.getTokenByClientCredentials(...)` | +| `authenticationClient.refreshTokenGrant(...)` | `authClient.getTokenByRefreshToken(...)` | +| `authenticationClient.passwordGrant(...)` | `authClient.getTokenByPassword(...)` | +| `authenticationClient.revokeRefreshToken(...)` | `authClient.revokeToken(...)` | +| `authenticationClient.database.signUp(...)` | `authClient.signUp(...)` | +| `authenticationClient.database.changePassword(...)` | `authClient.changePassword(...)` | +| `authenticationClient.passwordless.*` | `authClient.passwordless.*` (sub-client) | +| `userInfoClient.getUserInfo(accessToken)` | `authClient.getUserInfo(accessToken)` | + +### mTLS configuration + +If you use mTLS, you must now provide an explicit `customFetch` option: + +```js +import { AuthClient } from "@auth0/auth0-auth-js"; +import https from "https"; +import fetch from "node-fetch"; + +const agent = new https.Agent({ + cert: fs.readFileSync("client-cert.pem"), + key: fs.readFileSync("client-key.pem"), +}); + +const auth = new AuthClient({ + domain: "your-tenant.auth0.com", + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET", + useMtls: true, + customFetch: (url, init) => fetch(url, { ...init, agent }), +}); +``` + +See the [auth0-auth-js documentation](https://github.com/auth0/node-auth0/tree/main/packages/auth0-auth-js) for complete API details. + ## Request and Response Types The SDK exports all request and response types as TypeScript interfaces. You can import them directly: @@ -375,8 +441,6 @@ const actions = await client.actions.list(listParams); ### Key Classes - **ManagementClient** - for Auth0 Management API operations -- **AuthenticationClient** - for Auth0 Authentication API operations -- **UserInfoClient** - for retrieving user profile information ## Exception Handling diff --git a/src/management/wrapper/token-provider.ts b/src/management/wrapper/token-provider.ts index e5da3ae790..62192953bf 100644 --- a/src/management/wrapper/token-provider.ts +++ b/src/management/wrapper/token-provider.ts @@ -4,6 +4,13 @@ import { generateClientInfo } from "../../utils.js"; const LEEWAY = 10 * 1000; // 10s refresh-ahead in ms +/** + * TokenProvider handles Management API token acquisition by delegating to @auth0/auth0-auth-js. + * It performs client credentials grant and caches the token until shortly before expiry. + * + * CRITICAL: @auth0/auth0-auth-js returns expiresAt as an absolute Unix timestamp in seconds. + * This class converts it to milliseconds for comparison with Date.now(). + */ export class TokenProvider { private authClient: AuthClient; private expiresAt = 0; // Absolute timestamp in ms (Date.now() scale)