diff --git a/package-lock.json b/package-lock.json index c89aacaf8..2a1a3ee9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20172,6 +20172,14 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.3.tgz", + "integrity": "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "dev": true, @@ -32415,7 +32423,10 @@ "packages/javascript-sdk": { "name": "@forgerock/javascript-sdk", "version": "4.4.1", - "license": "MIT" + "license": "MIT", + "dependencies": { + "jose": "^5.2.3" + } }, "packages/ping-protect": { "name": "@forgerock/ping-protect", @@ -36868,7 +36879,10 @@ "dev": true }, "@forgerock/javascript-sdk": { - "version": "file:packages/javascript-sdk" + "version": "file:packages/javascript-sdk", + "requires": { + "jose": "^5.2.3" + } }, "@forgerock/ping-protect": { "version": "file:packages/ping-protect", @@ -48299,6 +48313,11 @@ "version": "1.4.0", "dev": true }, + "jose": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.3.tgz", + "integrity": "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==" + }, "joycon": { "version": "3.1.1", "dev": true diff --git a/packages/javascript-sdk/package.json b/packages/javascript-sdk/package.json index 0b7ffdd03..7092ceda0 100644 --- a/packages/javascript-sdk/package.json +++ b/packages/javascript-sdk/package.json @@ -26,5 +26,8 @@ }, "main": "./src/index.cjs", "module": "./src/index.js", - "types": "./src/index.d.ts" + "types": "./src/index.d.ts", + "dependencies": { + "jose": "^5.2.3" + } } diff --git a/packages/javascript-sdk/src/auth/enums.ts b/packages/javascript-sdk/src/auth/enums.ts index 3874922a6..a850942a5 100644 --- a/packages/javascript-sdk/src/auth/enums.ts +++ b/packages/javascript-sdk/src/auth/enums.ts @@ -25,7 +25,9 @@ enum CallbackType { BooleanAttributeInputCallback = 'BooleanAttributeInputCallback', ChoiceCallback = 'ChoiceCallback', ConfirmationCallback = 'ConfirmationCallback', + DeviceBindingCallback = 'DeviceBindingCallback', DeviceProfileCallback = 'DeviceProfileCallback', + DeviceSigningVerifierCallback = 'DeviceSigningVerifierCallback', HiddenValueCallback = 'HiddenValueCallback', KbaCreateCallback = 'KbaCreateCallback', MetadataCallback = 'MetadataCallback', diff --git a/packages/javascript-sdk/src/fr-device-binding-emulation/DeviceBindingEmulator.ts b/packages/javascript-sdk/src/fr-device-binding-emulation/DeviceBindingEmulator.ts new file mode 100644 index 000000000..b069d6ffe --- /dev/null +++ b/packages/javascript-sdk/src/fr-device-binding-emulation/DeviceBindingEmulator.ts @@ -0,0 +1,136 @@ +import { v4 } from 'uuid'; +import FRCallback from '../fr-auth/callbacks'; +import { CallbackType } from '../auth/enums'; +import * as jose from 'jose'; + +const ALGORITHM = 'ES256'; + +/** + * An emulator for a device binding client. Not for production use. + * Can help with testing scenarios. + */ +export default class DeviceBindingEmulator { + /** + * Factory method to create a virtual device + * @param issuer the issuer to use + * @returns Promise that will resolve to a new device emulator object + */ + public static async createDevice( + issuer = 'com.forgerock.unsummit', + ): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true, + }); + + const privateJwk = await jose.exportJWK(privateKey); + const publicJwk = await jose.exportJWK(publicKey); + const kid = v4(); + + return new DeviceBindingEmulator(publicJwk, privateJwk, kid, issuer); + } + + private publicJwk: jose.JWK; + private privateJwk: jose.JWK; + private kid: string; + private deviceId: string = v4(); + private _userId = ''; + private issuer: string; + + private constructor(publicJwk: jose.JWK, privateJwk: jose.JWK, kid: string, issuer: string) { + this.publicJwk = publicJwk; + this.privateJwk = privateJwk; + this.kid = kid; + this.issuer = issuer; + } + + /** + * Execute device binding for a user and store the user + * in this object. Will satisfy the device binding challenge + * from the server, posing as an iOS client. + * @param callback device binding callback received from a journey + */ + public async bind(callback: FRCallback): Promise { + if (callback.getType() !== CallbackType.DeviceBindingCallback) { + throw new Error( + `Expecting a callback of type DeviceBindingCallback but got ${callback.getType()}`, + ); + } + + const userId = callback.getOutputValue(0) as string; + this._userId = userId; + + const challenge = callback.getOutputValue(3) as string; + + const jwt = await this.createDeviceBindingJwt(challenge); + + callback.setInputValue(jwt, 0); + callback.setInputValue('iPhone', 1); + callback.setInputValue(this.deviceId, 2); + } + + /** + * Perform the device signing operation, satisfying a device + * signing verifier callback. This emulates login with face id or + * thumbprint, etc. It also acts as though the iOS SDK. + * @param callback the device signing callback + */ + public async signIn(callback: FRCallback): Promise { + if (callback.getType() !== CallbackType.DeviceSigningVerifierCallback) { + throw new Error( + `Expecting a callback of type DeviceSigningVerifierCallback but got ${callback.getType()}`, + ); + } + + const challenge = callback.getOutputValue(1) as string; + + const jwt = await this.createDeviceLoginJwt(challenge); + + callback.setInputValue(jwt, 0); + } + + public get userId() { + return this._userId; + } + + public set userId(userId: string) { + this._userId = userId; + } + + private async createDeviceBindingJwt(challenge: string): Promise { + const { privateJwk, kid, publicJwk, _userId: sub } = this; + + return new jose.SignJWT({ + platform: 'ios', + iss: this.issuer, + sub, + challenge, + }) + .setProtectedHeader({ + typ: 'JWS', + jwk: { kid, ...publicJwk }, + kid, + alg: ALGORITHM, + }) + .setIssuedAt() + .setExpirationTime('1m') + .sign(await jose.importJWK(privateJwk, ALGORITHM)); + } + + private async createDeviceLoginJwt(challenge: string): Promise { + const { privateJwk, kid, _userId: sub } = this; + + return new jose.SignJWT({ + iss: this.issuer, + sub, + challenge, + }) + .setProtectedHeader({ + typ: 'JWS', + kid, + alg: ALGORITHM, + }) + .setIssuedAt() + .setExpirationTime('1m') + .sign(await jose.importJWK(privateJwk, ALGORITHM)); + } +} diff --git a/packages/javascript-sdk/src/index.ts b/packages/javascript-sdk/src/index.ts index 895456fc1..c74dab854 100644 --- a/packages/javascript-sdk/src/index.ts +++ b/packages/javascript-sdk/src/index.ts @@ -77,6 +77,7 @@ import Deferred from './util/deferred'; import PKCE from './util/pkce'; import LocalStorage from './util/storage'; import type { LoggerFunctions, StepOptions } from './config/interfaces'; +import DeviceBindingEmulator from './fr-device-binding-emulation/DeviceBindingEmulator'; export type { AuthResponse, @@ -113,6 +114,7 @@ export { Config, ConfirmationCallback, Deferred, + DeviceBindingEmulator, DeviceProfileCallback, ErrorCode, FRAuth,