diff --git a/src/auth/backchannel.ts b/src/auth/backchannel.ts new file mode 100644 index 000000000..55586bcc2 --- /dev/null +++ b/src/auth/backchannel.ts @@ -0,0 +1,254 @@ +// 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. + */ + request_expiry?: string; + /** + * The user ID. + */ + userId: string; + /** + * Optional parameter for subject issuer context. + */ + subjectIssuerContext?: string; +}; + +type AuthorizeRequest = Omit & + AuthorizeCredentialsPartial & { + login_hint: string; + }; + +/** + * 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; +}; + +/** + * 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, + }; + + 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/index.ts b/src/auth/index.ts index a5f8faed4..023849cef 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,3 +1,4 @@ +import { Backchannel } from './backchannel.js'; import { AuthenticationClientOptions } from './base-auth-api.js'; import { Database } from './database.js'; import { OAuth } from './oauth.js'; @@ -13,10 +14,12 @@ export class AuthenticationClient { database: Database; oauth: OAuth; passwordless: Passwordless; + backchannel: Backchannel; constructor(options: AuthenticationClientOptions) { this.database = new Database(options); this.oauth = new OAuth(options); this.passwordless = new Passwordless(options); + this.backchannel = new Backchannel(options); } } diff --git a/test/auth/backchannel.test.ts b/test/auth/backchannel.test.ts new file mode 100644 index 000000000..fed050548 --- /dev/null +++ b/test/auth/backchannel.test.ts @@ -0,0 +1,286 @@ +import nock from 'nock'; + +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 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 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', + }); + }); + }); +});