diff --git a/src/main/apiClient.ts b/src/main/apiClient.ts index 3956dbb3..962cea3b 100644 --- a/src/main/apiClient.ts +++ b/src/main/apiClient.ts @@ -14,7 +14,12 @@ import { UrlParser } from './urlParser' import { popupSize } from './providerPopupSize' import { createHttpClient, HttpClient } from './httpClient' import { computePkceParams, PkceParams } from './pkceService' -import { encodePublicKeyCredentialCreationOptions, serializeRegistrationPublicKeyCredential, publicKeyCredentialType, CredentialCreationOptionsSerialized } from './webAuthnService' +import { + encodePublicKeyCredentialCreationOptions, encodePublicKeyCredentialRequestOptions, + serializeRegistrationPublicKeyCredential, serializeAuthenticationPublicKeyCredential, + CredentialCreationOptionsSerialized, CredentialRequestOptionsSerialized, + publicKeyCredentialType +} from './webAuthnService' export type SignupParams = { data: SignupProfile @@ -38,6 +43,10 @@ export type LoginWithCredentialsParams = { auth?: AuthOptions } +type EmailLoginWithWebAuthnParams = { email: string, auth?: AuthOptions } +type PhoneNumberLoginWithWebAuthnParams = { phoneNumber: string, auth?: AuthOptions } +export type LoginWithWebAuthnParams = EmailLoginWithWebAuthnParams | PhoneNumberLoginWithWebAuthnParams + type EmailRequestPasswordResetParams = { email: string redirectUrl?: string @@ -96,6 +105,8 @@ export type TokenRequestParameters = { persistent?: boolean // Whether the remember me is enabled } +type InternalToken = { tkn: string } + /** * Identity Rest API Client */ @@ -382,7 +393,7 @@ export default class ApiClient { private loginWithPasswordByRedirect({ auth = {}, ...rest }: LoginWithPasswordParams): Promise { return this.http - .post<{ tkn: string }>('/password/login', { + .post('/password/login', { body: { clientId: this.config.clientId, scope: this.resolveScope(auth), @@ -454,7 +465,7 @@ export default class ApiClient { }) .then(result => this.eventManager.fireEvent('authenticated', result)) : this.http - .post<{ tkn: string }>('/signup', { + .post('/signup', { body: { clientId: this.config.clientId, redirectUrl, @@ -632,6 +643,41 @@ export default class ApiClient { }) } + loginWithWebAuthn(params: LoginWithWebAuthnParams): Promise { + const body = { + clientId: this.config.clientId, + origin: window.location.origin, + scope: this.resolveScope(params.auth), + email: (params as EmailLoginWithWebAuthnParams).email, + phoneNumber: (params as PhoneNumberLoginWithWebAuthnParams).phoneNumber + } + + return this.http + .post('/webauthn/authentication-options', { body }) + .then(response => { + const options = encodePublicKeyCredentialRequestOptions(response.publicKey) + + return navigator.credentials.get({ publicKey: options }) + }) + .then(credentials => { + if (!credentials || credentials.type !== publicKeyCredentialType) { + throw new Error('Unable to authenticate with invalid public key crendentials.') + } + + const serializedCredentials = serializeAuthenticationPublicKeyCredential(credentials) + + return this.http + .post('/webauthn/authentication', { body: { ...serializedCredentials } }) + .then(response => this.loginWithPasswordCallback(response.tkn, params.auth)) + .catch(error => { throw error }) + }) + .catch(error => { + if (error.error) this.eventManager.fireEvent('login_failed', error) + + throw error + }) + } + getSessionInfo(): Promise { return this.http.get('/sso/data', { query: { clientId: this.config.clientId }, diff --git a/src/main/main.ts b/src/main/main.ts index 39fc9826..6eb47436 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2,6 +2,7 @@ import { Profile, RemoteSettings, SessionInfo } from './models' import ApiClient, { LoginWithPasswordParams, LoginWithCredentialsParams, + LoginWithWebAuthnParams, PasswordlessParams, VerifyPasswordlessParams, RequestPasswordResetParams, @@ -56,6 +57,7 @@ export type Client = { loginWithCustomToken: (params: { token: string; auth: AuthOptions }) => Promise loginWithCredentials: (params: LoginWithCredentialsParams) => Promise addNewWebAuthnDevice: (accessToken: string) => Promise + loginWithWebAuthn: (params: LoginWithWebAuthnParams) => Promise getSessionInfo: (params?: {}) => Promise checkUrlFragment: (url: string) => boolean } @@ -184,6 +186,10 @@ export function createClient(creationConfig: Config): Client { return apiClient.then(api => api.addNewWebAuthnDevice(accessToken)) } + function loginWithWebAuthn(params: LoginWithWebAuthnParams) { + return apiClient.then(api => api.loginWithWebAuthn(params)) + } + function getSessionInfo() { return apiClient.then(api => api.getSessionInfo()) } @@ -237,6 +243,7 @@ export function createClient(creationConfig: Config): Client { loginWithCustomToken, loginWithCredentials, addNewWebAuthnDevice, + loginWithWebAuthn, getSessionInfo, checkUrlFragment } diff --git a/src/main/webAuthnService.ts b/src/main/webAuthnService.ts index 1ba2fbbd..a5ea3517 100644 --- a/src/main/webAuthnService.ts +++ b/src/main/webAuthnService.ts @@ -5,6 +5,7 @@ import { encodeToBase64 } from '../utils/base64' export const publicKeyCredentialType = 'public-key' export type CredentialCreationOptionsSerialized = { publicKey: PublicKeyCredentialCreationOptionsSerialized } +export type CredentialRequestOptionsSerialized = { publicKey: PublicKeyCredentialRequestOptionsSerialized } type PublicKeyCredentialCreationOptionsSerialized = { rp: PublicKeyCredentialRpEntity @@ -22,6 +23,18 @@ type PublicKeyCredentialCreationOptionsSerialized = { extensions?: AuthenticationExtensionsClientInputs } +type PublicKeyCredentialRequestOptionsSerialized = { + challenge: string + timeout?: number + rpId: string + allowCredentials: { + id: string + transports?: AuthenticatorTransport[] + type: PublicKeyCredentialType + }[] + userVerification: 'required' | 'preferred' | 'discouraged' +} + export type RegistrationPublicKeyCredentialSerialized = { id: string rawId: string @@ -32,6 +45,18 @@ export type RegistrationPublicKeyCredentialSerialized = { } } +export type AuthenticationPublicKeyCredentialSerialized = { + id: string + rawId: string + type: 'public-key' + response: { + authenticatorData: string + clientDataJSON: string + signature: string + userHandle: string | null + } +} + export function encodePublicKeyCredentialCreationOptions(serializedOptions: PublicKeyCredentialCreationOptionsSerialized): PublicKeyCredentialCreationOptions { return { ...serializedOptions, @@ -43,6 +68,17 @@ export function encodePublicKeyCredentialCreationOptions(serializedOptions: Publ } } +export function encodePublicKeyCredentialRequestOptions(serializedOptions: PublicKeyCredentialRequestOptionsSerialized): PublicKeyCredentialRequestOptions { + return { + ...serializedOptions, + challenge: Buffer.from(serializedOptions.challenge, 'base64'), + allowCredentials: serializedOptions.allowCredentials.map(allowCrendential => ({ + ...allowCrendential, + id: Buffer.from(allowCrendential.id, 'base64') + })) + } +} + export function serializeRegistrationPublicKeyCredential(encodedPublicKey: PublicKeyCredential): RegistrationPublicKeyCredentialSerialized { const response = encodedPublicKey.response as AuthenticatorAttestationResponse @@ -56,3 +92,19 @@ export function serializeRegistrationPublicKeyCredential(encodedPublicKey: Publi } } } + +export function serializeAuthenticationPublicKeyCredential(encodedPublicKey: PublicKeyCredential): AuthenticationPublicKeyCredentialSerialized { + const response = encodedPublicKey.response as AuthenticatorAssertionResponse + + return { + id: encodedPublicKey.id, + rawId: encodeToBase64(encodedPublicKey.rawId), + type: encodedPublicKey.type, + response: { + authenticatorData: encodeToBase64(response.authenticatorData), + clientDataJSON: encodeToBase64(response.clientDataJSON), + signature: encodeToBase64(response.signature), + userHandle: response.userHandle && encodeToBase64(response.userHandle) + } + } +}