-
Notifications
You must be signed in to change notification settings - Fork 46
feat(javascript-sdk): register-device-name #340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /* | ||
| * @forgerock/javascript-sdk | ||
| * | ||
| * autoscript.ts | ||
| * | ||
| * Copyright (c) 2020 ForgeRock. All rights reserved. | ||
| * This software may be modified and distributed under the terms | ||
| * of the MIT license. See the LICENSE file for details. | ||
| */ | ||
| // @ts-nocheck | ||
| import * as forgerock from '@forgerock/javascript-sdk'; | ||
| import { delay as rxDelay, map, mergeMap } from 'rxjs/operators'; | ||
| import { fromEvent } from 'rxjs'; | ||
|
|
||
| function autoscript() { | ||
| const delay = 0; | ||
|
|
||
| const url = new URL(window.location.href); | ||
| const amUrl = url.searchParams.get('amUrl') || 'https://auth.example.com:9443/am'; | ||
| const realmPath = url.searchParams.get('realmPath') || 'root'; | ||
| const un = url.searchParams.get('un') || 'sdkuser'; | ||
| const pw = url.searchParams.get('pw') || 'password'; | ||
| const tree = url.searchParams.get('tree') || 'PasswordlessWebAuthn'; | ||
| console.log('Configure the SDK'); | ||
| forgerock.Config.set({ | ||
| realmPath, | ||
| tree, | ||
| serverConfig: { | ||
| baseUrl: amUrl, | ||
| }, | ||
| }); | ||
|
|
||
| try { | ||
| forgerock.SessionManager.logout(); | ||
| } catch (err) { | ||
| // Do nothing | ||
| } | ||
| /*** | ||
| * Test Device Registration (Not Automated) | ||
| * must create cert because you cannot have a TLS error with webauthn to test manually | ||
| */ | ||
| /*** | ||
| * Test Device Registration (Not Automated) | ||
| * must create cert because you cannot have a TLS error with webauthn to test manually | ||
| */ | ||
| console.log('Click the device registration button!'); | ||
| const deviceRegistration = document.querySelector('.device-registration'); | ||
| fromEvent(deviceRegistration, 'click') | ||
| .pipe( | ||
| mergeMap(() => { | ||
| console.log('Initiate first step with `undefined`'); | ||
| return forgerock.FRAuth.next(); | ||
| }), | ||
| rxDelay(delay), | ||
| mergeMap((step) => { | ||
| console.log('Set username on auth tree callback'); | ||
| console.log(step); | ||
| step.getCallbackOfType('NameCallback').setName(un); | ||
| return forgerock.FRAuth.next(step); | ||
| }), | ||
| rxDelay(delay), | ||
| mergeMap((step) => { | ||
| console.log('Set password on auth tree callback'); | ||
| step.getCallbackOfType('PasswordCallback').setPassword(pw); | ||
| return forgerock.FRAuth.next(step); | ||
| }), | ||
| mergeMap(async (step) => { | ||
| const webAuthnStep = forgerock.FRWebAuthn.getWebAuthnStepType(step); | ||
| if (webAuthnStep === 2) { | ||
| console.log('WebAuthn step is registration'); | ||
| } else { | ||
| throw new Error('WebAuthn step is incorrectly identified'); | ||
| } | ||
| console.log('Handle WebAuthn Registration'); | ||
| try { | ||
| step = await forgerock.FRWebAuthn.register<'mydevice'>(step, 'mydevice'); | ||
| // ensure the step here has the 'mydevice' name at the end of the value. (outcome) | ||
| const deviceValue = step.getCallbacksOfType('HiddenValueCallback')[0].getInputValue(); | ||
| if (!deviceValue.includes('mydevice')) { | ||
| throw new Error('Device name is not correct'); | ||
| } | ||
| } catch (err) { | ||
| console.log(err); | ||
| } | ||
| return forgerock.FRAuth.next(step); | ||
| }), | ||
| map((step) => { | ||
| console.log('step', step); | ||
| if (step.payload.status === 401) { | ||
| throw new Error('Auth_Error'); | ||
| } else if (step.payload.tokenId) { | ||
| console.log('Basic login successful.'); | ||
| document.body.innerHTML = '<p class="Logged_In">Login successful</p>'; | ||
| return step; | ||
| } else { | ||
| throw new Error('Something went wrong.'); | ||
| } | ||
| }), | ||
| ) | ||
| .subscribe({ | ||
| error: (err) => { | ||
| console.log(`Error: ${err.message}`); | ||
| document.body.innerHTML = `<p class="Test_Complete">${err.message}</p>`; | ||
| }, | ||
| complete: () => { | ||
| console.log('Test script complete'); | ||
| document.body.innerHTML = `<p class="Test_Complete">Test script complete</p>`; | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| autoscript(); | ||
| export default autoscript; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <title>E2E Test | ForgeRock JavaScript SDK</title> | ||
|
|
||
| <!-- Needed only for automation with mock server --> | ||
| <meta name="referrer" content="unsafe-url" /> | ||
|
|
||
| <link rel="shortcut icon" href="/fr-ico.png" type="image/png" /> | ||
|
|
||
| <style> | ||
| @media (prefers-color-scheme: dark) { | ||
| html { | ||
| background-color: black; | ||
| color: white; | ||
| } | ||
| a { | ||
| color: lightblue; | ||
| } | ||
| a:visited { | ||
| color: lavender; | ||
| } | ||
| a:hover { | ||
| color: lightskyblue; | ||
| } | ||
| } | ||
| </style> | ||
| </head> | ||
|
|
||
| <body> | ||
| <!-- script src="/_polyfills/fast-text-encoder.js"></script --> | ||
| <button class="device-registration">Device Registration</button> | ||
|
|
||
| <script src="autoscript.js"></script> | ||
| </body> | ||
| </html> |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ import { | |
| parseRelyingPartyId, | ||
| } from './helpers'; | ||
| import { | ||
| AttestationType, | ||
| RelyingParty, | ||
| WebAuthnAuthenticationMetadata, | ||
| WebAuthnCallbacks, | ||
|
|
@@ -29,6 +30,15 @@ import { | |
| import TextOutputCallback from '../fr-auth/callbacks/text-output-callback'; | ||
| import { parseWebAuthnAuthenticateText, parseWebAuthnRegisterText } from './script-parser'; | ||
|
|
||
| // <clientdata>::<attestation>::<publickeyCredential>::<DeviceName> | ||
| type OutcomeWithName< | ||
| ClientId extends string, | ||
| Attestation extends AttestationType, | ||
| PubKeyCred extends PublicKeyCredential, | ||
| Name = '', | ||
| > = Name extends infer P extends string | ||
| ? `${ClientId}::${Attestation}::${PubKeyCred['id']}${P extends '' ? '' : `::${P}`}` | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. realistically, i thought a simpler type could be used, i have no idea why i couldn't use a simpler type, it only worked when I went this route. |
||
| : never; | ||
| // JSON-based WebAuthn | ||
| type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMetadata; | ||
| // Script-based WebAuthn | ||
|
|
@@ -92,7 +102,7 @@ abstract class FRWebAuthn { | |
| public static async authenticate(step: FRStep): Promise<FRStep> { | ||
| const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step); | ||
| if (hiddenCallback && (metadataCallback || textOutputCallback)) { | ||
| let outcome: string; | ||
| let outcome: ReturnType<typeof this.getAuthenticationOutcome>; | ||
|
|
||
| try { | ||
| let publicKey: PublicKeyCredentialRequestOptions; | ||
|
|
@@ -127,17 +137,22 @@ abstract class FRWebAuthn { | |
| throw e; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Populates the step with the necessary registration outcome. | ||
| * | ||
| * @param step The step that contains WebAuthn registration data | ||
| * @return The populated step | ||
| */ | ||
| public static async register(step: FRStep): Promise<FRStep> { | ||
| // Can make this generic const in Typescritp 5.0 > and the name itself will | ||
| // be inferred from the type so `typeof deviceName` will not just return string | ||
| // but the actual name of the deviceName passed in as a generic. | ||
| public static async register<T extends string = undefined>( | ||
| step: FRStep, | ||
| deviceName?: T, | ||
| ): Promise<FRStep> { | ||
| const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step); | ||
| if (hiddenCallback && (metadataCallback || textOutputCallback)) { | ||
| let outcome: string; | ||
| let outcome: OutcomeWithName<string, AttestationType, PublicKeyCredential>; | ||
|
|
||
| try { | ||
| let publicKey: PublicKeyCredentialRequestOptions; | ||
|
|
@@ -147,10 +162,9 @@ abstract class FRWebAuthn { | |
| } else if (textOutputCallback) { | ||
| publicKey = parseWebAuthnRegisterText(textOutputCallback.getMessage()); | ||
| } | ||
| // TypeScript doesn't like `publicKey` being assigned in conditionals above | ||
| // eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
| // @ts-ignore | ||
| const credential = await this.getRegistrationCredential(publicKey); | ||
| const credential = await this.getRegistrationCredential( | ||
| publicKey as PublicKeyCredentialCreationOptions, | ||
| ); | ||
| outcome = this.getRegistrationOutcome(credential); | ||
| } catch (error) { | ||
| if (!(error instanceof Error)) throw error; | ||
|
|
@@ -162,8 +176,7 @@ abstract class FRWebAuthn { | |
| hiddenCallback.setInputValue(`${WebAuthnOutcome.Error}::${error.name}:${error.message}`); | ||
| throw error; | ||
| } | ||
|
|
||
| hiddenCallback.setInputValue(outcome); | ||
| hiddenCallback.setInputValue(deviceName ? `${outcome}::${deviceName}` : outcome); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hypothetically, we could give |
||
| return step; | ||
| } else { | ||
| const e = new Error('Incorrect callbacks for WebAuthn registration'); | ||
|
|
@@ -263,7 +276,11 @@ abstract class FRWebAuthn { | |
| * @param credential The credential to convert | ||
| * @return The outcome string | ||
| */ | ||
| public static getAuthenticationOutcome(credential: PublicKeyCredential | null): string { | ||
| public static getAuthenticationOutcome( | ||
| credential: PublicKeyCredential | null, | ||
| ): | ||
| | OutcomeWithName<string, AttestationType, typeof credential> | ||
| | OutcomeWithName<string, AttestationType, typeof credential, string> { | ||
| if (credential === null) { | ||
| const e = new Error('No credential generated from authentication'); | ||
| e.name = WebAuthnOutcomeType.UnknownError; | ||
|
|
@@ -273,19 +290,33 @@ abstract class FRWebAuthn { | |
| try { | ||
| const clientDataJSON = arrayBufferToString(credential.response.clientDataJSON); | ||
| const assertionResponse = credential.response as AuthenticatorAssertionResponse; | ||
| const authenticatorData = new Int8Array(assertionResponse.authenticatorData).toString(); | ||
| const authenticatorData = new Int8Array( | ||
| assertionResponse.authenticatorData, | ||
| ).toString() as AttestationType; | ||
| const signature = new Int8Array(assertionResponse.signature).toString(); | ||
|
|
||
| // Current native typing for PublicKeyCredential does not include `userHandle` | ||
| // eslint-disable-next-line | ||
| // @ts-ignore | ||
| const userHandle = arrayBufferToString(credential.response.userHandle); | ||
|
|
||
| let stringOutput = `${clientDataJSON}::${authenticatorData}::${signature}::${credential.id}`; | ||
| let stringOutput = | ||
| `${clientDataJSON}::${authenticatorData}::${signature}::${credential.id}` as OutcomeWithName< | ||
| string, | ||
| AttestationType, | ||
| PublicKeyCredential | ||
| >; | ||
| // Check if Username is stored on device | ||
| if (userHandle) { | ||
| stringOutput = `${stringOutput}::${userHandle}`; | ||
| return stringOutput as OutcomeWithName< | ||
| string, | ||
| AttestationType, | ||
| PublicKeyCredential, | ||
| string | ||
| >; | ||
| } | ||
|
|
||
| return stringOutput; | ||
| } catch (error) { | ||
| const e = new Error('Transforming credential object to string failed'); | ||
|
|
@@ -321,7 +352,9 @@ abstract class FRWebAuthn { | |
| * @param credential The credential to convert | ||
| * @return The outcome string | ||
| */ | ||
| public static getRegistrationOutcome(credential: PublicKeyCredential | null): string { | ||
| public static getRegistrationOutcome( | ||
| credential: PublicKeyCredential | null, | ||
| ): OutcomeWithName<string, AttestationType, PublicKeyCredential> { | ||
| if (credential === null) { | ||
| const e = new Error('No credential generated from registration'); | ||
| e.name = WebAuthnOutcomeType.UnknownError; | ||
|
|
@@ -331,7 +364,9 @@ abstract class FRWebAuthn { | |
| try { | ||
| const clientDataJSON = arrayBufferToString(credential.response.clientDataJSON); | ||
| const attestationResponse = credential.response as AuthenticatorAttestationResponse; | ||
| const attestationObject = new Int8Array(attestationResponse.attestationObject).toString(); | ||
| const attestationObject = new Int8Array( | ||
| attestationResponse.attestationObject, | ||
| ).toString() as AttestationType.Direct; | ||
| return `${clientDataJSON}::${attestationObject}::${credential.id}`; | ||
| } catch (error) { | ||
| const e = new Error('Transforming credential object to string failed'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so typechecking happens on build, was an oversight by me.