UNPKG

passkeys-prf-client

Version:

A client npm package for passkeys authentication with PRF functionality

615 lines (614 loc) 28 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; // @ts-ignore import { TextEncoder } from 'text-encoding'; export const ErrorCodes = { Unknown: 'Unknown', HTTP: 'HTTP', AbortError: 'AbortError', NotAllowedError: 'NotAllowedError', FailedCreateCredential: 'FailedCreateCredential', PassKeySignInVerificationFailed: 'PassKeySignInVerificationFailed', PRFNotSupported: 'PRFNotSupported', InvalidStateError: 'InvalidStateError', }; export const ErrorFrom = { BrowserWebAuthn: 'BrowserWebAuthn', Backend: 'Backend', PasswordlessDev: 'PasswordlessDev', Unknown: 'Unknown', }; export const prf_salt = 'passwordless-login'; export class PasskeysAuthError extends Error { constructor(from, errorCode, message) { super(message); this.from = from; this.errorCode = errorCode; } } export class PasswordlessService { constructor(publicApiKey, backendApiRootUrl) { this.abortController = new AbortController(); this.config = { apiUrl: 'https://v4.passwordless.dev', apiKey: publicApiKey, origin: window.location.origin, rpid: window.location.hostname, backendApiRootUrl: backendApiRootUrl, }; } createHeaders() { return { ApiKey: this.config.apiKey, 'Content-Type': 'application/json', 'Client-Version': 'js-1.1.0', }; } isBrowserSupported() { if (!(window.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function')) { throw new PasskeysAuthError(ErrorFrom.BrowserWebAuthn, ErrorCodes.Unknown, 'WebAuthn and PublicKeyCredentials are not supported on this browser/device'); } } registerBegin(token) { return __awaiter(this, void 0, void 0, function* () { const response = yield fetch(`${this.config.apiUrl}/register/begin`, { method: 'POST', headers: this.createHeaders(), body: JSON.stringify({ token, RPID: this.config.rpid, Origin: this.config.origin, }), }); const res = yield response.json(); if (response.ok) { return res; } throw new PasskeysAuthError(ErrorFrom.PasswordlessDev, ErrorCodes.HTTP, `${response.status} ${res.message}`); }); } registerComplete(credential, session, isPRFSupportRequired, nickname) { return __awaiter(this, void 0, void 0, function* () { var _a; const attestationResponse = credential.response; const clientExtensionResults = credential.getClientExtensionResults(); //@ts-ignore delete clientExtensionResults.prf; const extensionResults = credential.getClientExtensionResults(); //@ts-ignore const prfKey = ((_a = extensionResults.prf) === null || _a === void 0 ? void 0 : _a.enabled) ? btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array( //@ts-ignore extensionResults.prf.results.first)))) : null; if (!prfKey && isPRFSupportRequired) { throw new PasskeysAuthError(ErrorFrom.BrowserWebAuthn, ErrorCodes.PRFNotSupported, 'Browser or Authenticator does not support PRF!'); } const response = yield fetch(`${this.config.apiUrl}/register/complete`, { method: 'POST', headers: this.createHeaders(), body: JSON.stringify({ session: session, response: { id: credential.id, rawId: this.arrayBufferToBase64Url(credential.rawId), type: credential.type, clientExtensionResults, response: { AttestationObject: this.arrayBufferToBase64Url(attestationResponse.attestationObject), clientDataJson: this.arrayBufferToBase64Url(attestationResponse.clientDataJSON), }, }, RPID: this.config.rpid, Origin: this.config.origin, nickname, }), }); const res = yield response.json(); if (!response.ok) { throw new PasskeysAuthError(ErrorFrom.PasswordlessDev, ErrorCodes.HTTP, `${response.status} ${res.message}`); } return Object.assign(Object.assign({}, res), { prfKey }); }); } arrayBufferToBase64Url(buffer) { const uint8Array = (() => { if (Array.isArray(buffer)) return Uint8Array.from(buffer); if (buffer instanceof ArrayBuffer) return new Uint8Array(buffer); if (buffer instanceof Uint8Array) return buffer; const msg = 'Cannot convert from ArrayBuffer to Base64Url. Input was not of type ArrayBuffer, Uint8Array or Array'; console.error(msg, buffer); throw new Error(msg); })(); let string = ''; for (let i = 0; i < uint8Array.byteLength; i++) { string += String.fromCharCode(uint8Array[i]); } const base64String = window.btoa(string); return this.base64ToBase64Url(base64String); } base64ToBase64Url(base64) { return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, ''); } register(token, prf_salt, isPRFSupportRequired, nickname) { return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c; try { this.isBrowserSupported(); this.handleAbort(); const registration = yield this.registerBegin(token); if (!registration.data) return; registration.data.challenge = this.base64UrlToArrayBuffer(registration.data.challenge); registration.data.user.id = this.base64UrlToArrayBuffer(registration.data.user.id); (_a = registration.data.excludeCredentials) === null || _a === void 0 ? void 0 : _a.forEach((cred) => { cred.id = this.base64UrlToArrayBuffer(cred.id); }); //@ts-ignore registration.data.extensions.prf = { eval: {} }; //@ts-ignore registration.data.extensions.prf.eval.first = new TextEncoder().encode(prf_salt); const credential = (yield navigator.credentials.create({ publicKey: registration.data, signal: this.abortController.signal, })); if (!credential) { throw new PasskeysAuthError(ErrorFrom.BrowserWebAuthn, ErrorCodes.FailedCreateCredential, 'Failed to create credential (navigator.credentials.create returned null)'); } const result = yield this.registerComplete(credential, registration.session, isPRFSupportRequired, nickname); return { token: (_b = result.token) !== null && _b !== void 0 ? _b : '', prfKey: (_c = result.prfKey) !== null && _c !== void 0 ? _c : '', passkeyCredentialId: credential.id, error: undefined, }; // next steps // return a token from the API // Add a type to the token (method/action) } catch (caughtError) { if (caughtError instanceof PasskeysAuthError) { throw caughtError; } const errorMessage = this.getErrorMessage(caughtError); let errorCode; if (!caughtError) { errorCode = ErrorCodes.Unknown; } else if (caughtError.name === 'AbortError') { errorCode = ErrorCodes.AbortError; } else if (caughtError.name === 'NotAllowedError') { errorCode = ErrorCodes.NotAllowedError; } else if (caughtError.name === 'InvalidStateError') { errorCode = ErrorCodes.InvalidStateError; } else { errorCode = ErrorCodes.Unknown; } throw new PasskeysAuthError(ErrorFrom.BrowserWebAuthn, errorCode, errorMessage); } }); } base64UrlToArrayBuffer(base64UrlString) { // improvement: Remove BufferSource-type and add proper types upstream if (typeof base64UrlString !== 'string') { const msg = 'Cannot convert from Base64Url to ArrayBuffer: Input was not of type string'; console.error(msg, base64UrlString); throw new TypeError(msg); } const base64Unpadded = this.base64UrlToBase64(base64UrlString); const paddingNeeded = (4 - (base64Unpadded.length % 4)) % 4; const base64Padded = base64Unpadded.padEnd(base64Unpadded.length + paddingNeeded, '='); const binary = window.atob(base64Padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } base64UrlToBase64(base64Url) { return base64Url.replace(/-/g, '+').replace(/_/g, '/'); } getErrorMessage(error) { return this.toErrorWithMessage(error).message; } toErrorWithMessage(maybeError) { if (this.isErrorWithMessage(maybeError)) return maybeError; try { return new Error(JSON.stringify(maybeError)); } catch (_a) { // fallback in case there's an error stringifying the maybeError // like with circular references for example. return new Error(String(maybeError)); } } isErrorWithMessage(error) { return (typeof error === 'object' && error !== null && 'message' in error && typeof error['message'] === 'string'); } reAuthWithAlias(alias_1) { return __awaiter(this, arguments, void 0, function* (alias, isPRFSupportRequired = false, authToken, backendData) { try { const { token, prfKey, error, passkeyCredentialId } = yield this.signin({ alias }, isPRFSupportRequired); if (!token) throw error; const result = yield this.verifyReAuth(token, authToken, backendData); if (!result) { throw new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.PassKeySignInVerificationFailed, 'Passkey signin verification failed!'); } return { isPrfSupported: Boolean(prfKey), prfKey, verifyToken: result.verifyToken, passkeyCredentialId, error: undefined, }; } catch (err) { if (err instanceof PasskeysAuthError) { return { error: err }; } else { const error = new PasskeysAuthError(ErrorFrom.Unknown, ErrorCodes.Unknown, err === null || err === void 0 ? void 0 : err.message); return { error }; } } }); } signinWithAlias(alias_1) { return __awaiter(this, arguments, void 0, function* (alias, isPRFSupportRequired = false) { try { const { token, prfKey, error, passkeyCredentialId } = yield this.signin({ alias }, isPRFSupportRequired); if (!token) throw error; const result = yield this.verifySignin(token); if (!result) { throw new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.PassKeySignInVerificationFailed, 'Passkey signin verification failed!'); } return { isPrfSupported: Boolean(prfKey), prfKey, passkeyCredentialId, authToken: result.authToken, error: undefined, username: result.username, }; } catch (err) { if (err instanceof PasskeysAuthError) { return { error: err }; } else { const error = new PasskeysAuthError(ErrorFrom.Unknown, ErrorCodes.Unknown, err === null || err === void 0 ? void 0 : err.message); return { error }; } } }); } signinWithAutofill() { return __awaiter(this, arguments, void 0, function* (isPRFSupportRequired = false) { try { if (!(yield this.isAutofillSupported())) { throw new PasskeysAuthError(ErrorFrom.BrowserWebAuthn, ErrorCodes.Unknown, 'Autofill authentication (conditional meditation) is not supported in this browser'); } const { token, prfKey, error, passkeyCredentialId } = yield this.signin({ autofill: true }, isPRFSupportRequired); if (!token) throw error; const result = yield this.verifySignin(token); if (!result) { throw new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.PassKeySignInVerificationFailed, 'Passkey signin verification failed!'); } return { isPrfSupported: Boolean(prfKey), passkeyCredentialId, prfKey, authToken: result.authToken, error: undefined, username: result.username, }; } catch (err) { if (err instanceof PasskeysAuthError) { return { error: err }; } else { const error = new PasskeysAuthError(ErrorFrom.Unknown, ErrorCodes.Unknown, err === null || err === void 0 ? void 0 : err.message); return { error }; } } }); } isAutofillSupported() { return __awaiter(this, void 0, void 0, function* () { const PublicKeyCredential = window.PublicKeyCredential; // Typescript lacks support for this if (!PublicKeyCredential.isConditionalMediationAvailable) return false; return PublicKeyCredential.isConditionalMediationAvailable(); }); } handleAbort() { this.abort(); this.abortController = new AbortController(); } abort() { if (this.abortController) { this.abortController.abort(); } } signinBegin(signinMethod) { return __awaiter(this, void 0, void 0, function* () { const response = yield fetch(`${this.config.apiUrl}/signin/begin`, { method: 'POST', headers: this.createHeaders(), body: JSON.stringify({ userId: 'userId' in signinMethod ? signinMethod.userId : undefined, alias: 'alias' in signinMethod ? signinMethod.alias : undefined, RPID: this.config.rpid, Origin: this.config.origin, }), }); const res = yield response.json(); if (response.ok) { return res; } throw new PasskeysAuthError(ErrorFrom.PasswordlessDev, ErrorCodes.HTTP, `${response.status} ${res.message}`); }); } signinComplete(credential, session, isPRFSupportRequired) { return __awaiter(this, void 0, void 0, function* () { var _a, _b; const assertionResponse = credential.response; const clientExtensionResults = credential.getClientExtensionResults(); //@ts-ignore delete clientExtensionResults.prf; const extensionResults = credential.getClientExtensionResults(); //@ts-ignore const prfKey = ((_b = (_a = extensionResults.prf) === null || _a === void 0 ? void 0 : _a.results) === null || _b === void 0 ? void 0 : _b.first) ? btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array( //@ts-ignore extensionResults.prf.results.first)))) : null; if (!prfKey && isPRFSupportRequired) { throw new PasskeysAuthError(ErrorFrom.BrowserWebAuthn, ErrorCodes.PRFNotSupported, 'Browser or Authenticator does not support PRF!'); } const response = yield fetch(`${this.config.apiUrl}/signin/complete`, { method: 'POST', headers: this.createHeaders(), body: JSON.stringify({ session: session, response: { id: credential.id, rawId: this.arrayBufferToBase64Url(new Uint8Array(credential.rawId)), type: credential.type, clientExtensionResults, response: { authenticatorData: this.arrayBufferToBase64Url(assertionResponse.authenticatorData), clientDataJson: this.arrayBufferToBase64Url(assertionResponse.clientDataJSON), signature: this.arrayBufferToBase64Url(assertionResponse.signature), }, }, RPID: this.config.rpid, Origin: this.config.origin, }), }); const res = yield response.json(); if (!response.ok) { throw new PasskeysAuthError(ErrorFrom.PasswordlessDev, ErrorCodes.HTTP, `${response.status} ${res.message}`); } return Object.assign(Object.assign({}, res), { //@ts-ignore prfKey }); }); } signin(signinMethod, isPRFSupportRequired) { return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c; try { this.isBrowserSupported(); this.handleAbort(); // if signinMethod is undefined, set it to an empty object // this will cause a login using discoverable credentials if (!signinMethod) { signinMethod = { discoverable: true }; } const signin = yield this.signinBegin(signinMethod); if (!signin.data) throw signin.error; signin.data.challenge = this.base64UrlToArrayBuffer(signin.data.challenge); (_a = signin.data.allowCredentials) === null || _a === void 0 ? void 0 : _a.forEach((cred) => { cred.id = this.base64UrlToArrayBuffer(cred.id); }); signin.data.userVerification = 'required'; //@ts-ignore signin.data.extensions = { prf: { eval: {} } }; //@ts-ignore signin.data.extensions.prf.eval.first = new TextEncoder().encode(prf_salt); const credential = (yield navigator.credentials.get({ publicKey: signin.data, mediation: 'autofill' in signinMethod ? 'conditional' : undefined, // Typescript doesn't know about 'conditational' yet signal: this.abortController.signal, })); const response = yield this.signinComplete(credential, signin.session, isPRFSupportRequired); return { token: (_b = response.token) !== null && _b !== void 0 ? _b : '', prfKey: (_c = response.prfKey) !== null && _c !== void 0 ? _c : '', passkeyCredentialId: credential.id, error: undefined, }; } catch (caughtError) { if (caughtError instanceof PasskeysAuthError) { throw caughtError; } const errorMessage = this.getErrorMessage(caughtError); let errorCode; if (!caughtError) { errorCode = ErrorCodes.Unknown; } else if (caughtError.name === 'AbortError') { errorCode = ErrorCodes.AbortError; } else if (caughtError.name === 'NotAllowedError') { errorCode = ErrorCodes.NotAllowedError; } else if (caughtError.name === 'InvalidStateError') { errorCode = ErrorCodes.InvalidStateError; } else { errorCode = ErrorCodes.Unknown; } throw new PasskeysAuthError(ErrorFrom.BrowserWebAuthn, errorCode, errorMessage); } }); } signup(name_1, email_1) { return __awaiter(this, arguments, void 0, function* (name, email, isPRFSupportRequired = false, authtoken, nickname, backendData) { const headers = { 'Content-Type': 'application/json' }; if (authtoken) { headers['Authorization'] = `Bearer ${authtoken}`; } try { let response = yield fetch(`${this.config.backendApiRootUrl}/passkeys-auth/signup/begin`, { method: 'POST', headers, body: JSON.stringify(backendData ? Object.assign({ name, email }, backendData) : { name, email }), }); if (!response.ok) { throw new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(yield response.json()).message}`); } const data = yield response.json(); const { token: registrationToken, userId } = data; const result = yield this.register(registrationToken, prf_salt, isPRFSupportRequired, nickname); let prfKey; let passkeyCredentialId; if (result) { prfKey = result.prfKey; passkeyCredentialId = result.passkeyCredentialId; } response = yield fetch(`${this.config.backendApiRootUrl}/passkeys-auth/signup/complete`, { method: 'POST', headers, body: JSON.stringify(backendData ? Object.assign({ userId }, backendData) : { userId }), }); if (!response.ok) { throw new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(yield response.json()).message}`); } return { isPrfSupported: Boolean(prfKey), prfKey, passkeyCredentialId, error: undefined, }; } catch (err) { return { error: err }; } }); } verifySignin(token) { return __awaiter(this, void 0, void 0, function* () { const response = yield fetch(`${this.config.backendApiRootUrl}/passkeys-auth/signin/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ token }), }); if (!response.ok) { throw new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(yield response.json()).message}`); } return (yield response.json()); }); } verifyReAuth(token, authToken, backendData) { return __awaiter(this, void 0, void 0, function* () { const headers = { 'Content-Type': 'application/json' }; if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } let body = { token }; if (backendData) { body = Object.assign({ token }, backendData); } const response = yield fetch(`${this.config.backendApiRootUrl}/passkeys-auth/re-auth/verify`, { method: 'POST', headers, body: JSON.stringify(body), }); if (!response.ok) { throw new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(yield response.json()).message}`); } return (yield response.json()); }); } signupOrSigninAbort() { this.abort(); } //Passkeys Credentials Management getUserPasskeyCredentials(authtoken, userId) { return __awaiter(this, void 0, void 0, function* () { if (!authtoken && !userId) { return { error: new PasskeysAuthError(ErrorFrom.Unknown, ErrorCodes.Unknown, 'Please provide auth token or userId! '), }; } const headers = { 'Content-Type': 'application/json' }; if (authtoken) { headers.Authorization = `Bearer ${authtoken}`; } const response = yield fetch(`${this.config.backendApiRootUrl}/passkeys-auth/credentials/list${userId ? '?userId=' + userId : ''}`, { method: 'GET', headers, }); const res = yield response.json(); if (!response.ok) { return { error: new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${res.message}`), }; } return { credentials: res.credentials, error: undefined }; }); } deleteUserPasskeyCredential(credentialId, backendData, authtoken) { return __awaiter(this, void 0, void 0, function* () { const headers = { 'Content-Type': 'application/json' }; if (authtoken) { headers['Authorization'] = `Bearer ${authtoken}`; } let body = { credentialId }; if (backendData) { body = Object.assign({ credentialId }, backendData); } const response = yield fetch(`${this.config.backendApiRootUrl}/passkeys-auth/credentials/delete`, { method: 'DELETE', headers, body: JSON.stringify(body), }); if (!response.ok) { return { error: new PasskeysAuthError(ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(yield response.json()).message}`), }; } return { isDeletionSuccessful: true, error: undefined }; }); } }