passkeys-prf-client
Version:
A client npm package for passkeys authentication with PRF functionality
938 lines (814 loc) • 25 kB
text/typescript
// @ts-ignore
import {TextEncoder} from 'text-encoding';
import {
Config,
ErrorWithMessage,
PasskeysAuthResult,
SignupBeginResponse,
TokenPrfResponse,
PromiseResult,
RegisterBeginResponse,
Result,
SigninBeginResponse,
SigninMethod,
GetUserPasskeyCredentialsBackendResponse,
GetUserPasskeyCredentialsResult,
DeleteUserPasskeyCredentialResult,
PasskeysRegisterResult,
VerifyReAuthResponse,
PasskeysReAuthResult,
VerifyAuthResponse,
} from './types';
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 {
from: string;
errorCode: string;
constructor(from: string, errorCode: string, message: string) {
super(message);
this.from = from;
this.errorCode = errorCode;
}
}
export class PasswordlessService {
private abortController: AbortController = new AbortController();
private config: Config;
constructor(publicApiKey: string, backendApiRootUrl: string) {
this.config = {
apiUrl: 'https://v4.passwordless.dev',
apiKey: publicApiKey,
origin: window.location.origin,
rpid: window.location.hostname,
backendApiRootUrl: backendApiRootUrl,
};
}
private createHeaders(): Record<string, string> {
return {
ApiKey: this.config.apiKey,
'Content-Type': 'application/json',
'Client-Version': 'js-1.1.0',
};
}
public isBrowserSupported(): void {
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'
);
}
}
private async registerBegin(
token: string
): PromiseResult<RegisterBeginResponse> {
const response = await 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 = await response.json();
if (response.ok) {
return res;
}
throw new PasskeysAuthError(
ErrorFrom.PasswordlessDev,
ErrorCodes.HTTP,
`${response.status} ${res.message}`
);
}
private async registerComplete(
credential: PublicKeyCredential,
session: string,
isPRFSupportRequired: boolean,
nickname?: string
): PromiseResult<TokenPrfResponse> {
const attestationResponse =
credential.response as AuthenticatorAttestationResponse;
const clientExtensionResults = credential.getClientExtensionResults();
//@ts-ignore
delete clientExtensionResults.prf;
const extensionResults = credential.getClientExtensionResults();
//@ts-ignore
const prfKey = extensionResults.prf?.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 = await 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 = await response.json();
if (!response.ok) {
throw new PasskeysAuthError(
ErrorFrom.PasswordlessDev,
ErrorCodes.HTTP,
`${response.status} ${res.message}`
);
}
return {
...res,
prfKey,
};
}
private arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string {
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);
}
private base64ToBase64Url(base64: string): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, '');
}
private async register(
token: string,
prf_salt: string,
isPRFSupportRequired: boolean,
nickname?: string
): Promise<Result<TokenPrfResponse> | void> {
try {
this.isBrowserSupported();
this.handleAbort();
const registration = await 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
);
registration.data.excludeCredentials?.forEach(
(cred: {id: string | BufferSource}) => {
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 = (await navigator.credentials.create({
publicKey: registration.data,
signal: this.abortController.signal,
})) as PublicKeyCredential;
if (!credential) {
throw new PasskeysAuthError(
ErrorFrom.BrowserWebAuthn,
ErrorCodes.FailedCreateCredential,
'Failed to create credential (navigator.credentials.create returned null)'
);
}
const result = await this.registerComplete(
credential,
registration.session,
isPRFSupportRequired,
nickname
);
return {
token: result.token ?? '',
prfKey: result.prfKey ?? '',
passkeyCredentialId: credential.id,
error: undefined,
};
// next steps
// return a token from the API
// Add a type to the token (method/action)
} catch (caughtError: any) {
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
);
}
}
private base64UrlToArrayBuffer(
base64UrlString: string | BufferSource
): ArrayBuffer {
// 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;
}
private base64UrlToBase64(base64Url: string): string {
return base64Url.replace(/-/g, '+').replace(/_/g, '/');
}
private getErrorMessage(error: unknown) {
return this.toErrorWithMessage(error).message;
}
private toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
if (this.isErrorWithMessage(maybeError)) return maybeError;
try {
return new Error(JSON.stringify(maybeError));
} catch {
// fallback in case there's an error stringifying the maybeError
// like with circular references for example.
return new Error(String(maybeError));
}
}
private isErrorWithMessage(error: unknown): error is ErrorWithMessage {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record<string, unknown>)['message'] === 'string'
);
}
public async reAuthWithAlias(
alias: string,
isPRFSupportRequired = false,
authToken?: string,
backendData?: unknown
): PromiseResult<PasskeysReAuthResult> {
try {
const {token, prfKey, error, passkeyCredentialId} = await this.signin(
{alias},
isPRFSupportRequired
);
if (!token) throw error;
const result = await 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: any) {
if (err instanceof PasskeysAuthError) {
return {error: err};
} else {
const error = new PasskeysAuthError(
ErrorFrom.Unknown,
ErrorCodes.Unknown,
err?.message
);
return {error};
}
}
}
public async signinWithAlias(
alias: string,
isPRFSupportRequired = false
): PromiseResult<PasskeysAuthResult> {
try {
const {token, prfKey, error, passkeyCredentialId} = await this.signin(
{alias},
isPRFSupportRequired
);
if (!token) throw error;
const result = await 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: any) {
if (err instanceof PasskeysAuthError) {
return {error: err};
} else {
const error = new PasskeysAuthError(
ErrorFrom.Unknown,
ErrorCodes.Unknown,
err?.message
);
return {error};
}
}
}
public async signinWithAutofill(
isPRFSupportRequired = false
): PromiseResult<PasskeysAuthResult> {
try {
if (!(await this.isAutofillSupported())) {
throw new PasskeysAuthError(
ErrorFrom.BrowserWebAuthn,
ErrorCodes.Unknown,
'Autofill authentication (conditional meditation) is not supported in this browser'
);
}
const {token, prfKey, error, passkeyCredentialId} = await this.signin(
{autofill: true},
isPRFSupportRequired
);
if (!token) throw error;
const result = await 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: any) {
if (err instanceof PasskeysAuthError) {
return {error: err};
} else {
const error = new PasskeysAuthError(
ErrorFrom.Unknown,
ErrorCodes.Unknown,
err?.message
);
return {error};
}
}
}
private async isAutofillSupported(): Promise<boolean> {
const PublicKeyCredential = window.PublicKeyCredential as any; // Typescript lacks support for this
if (!PublicKeyCredential.isConditionalMediationAvailable) return false;
return PublicKeyCredential.isConditionalMediationAvailable() as Promise<boolean>;
}
private handleAbort() {
this.abort();
this.abortController = new AbortController();
}
private abort() {
if (this.abortController) {
this.abortController.abort();
}
}
private async signinBegin(
signinMethod: SigninMethod
): PromiseResult<SigninBeginResponse> {
const response = await 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 = await response.json();
if (response.ok) {
return res;
}
throw new PasskeysAuthError(
ErrorFrom.PasswordlessDev,
ErrorCodes.HTTP,
`${response.status} ${res.message}`
);
}
private async signinComplete(
credential: PublicKeyCredential,
session: string,
isPRFSupportRequired: boolean
): PromiseResult<TokenPrfResponse> {
const assertionResponse =
credential.response as AuthenticatorAssertionResponse;
const clientExtensionResults = credential.getClientExtensionResults();
//@ts-ignore
delete clientExtensionResults.prf;
const extensionResults = credential.getClientExtensionResults();
//@ts-ignore
const prfKey = extensionResults.prf?.results?.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 = await 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 = await response.json();
if (!response.ok) {
throw new PasskeysAuthError(
ErrorFrom.PasswordlessDev,
ErrorCodes.HTTP,
`${response.status} ${res.message}`
);
}
return {
...res,
//@ts-ignore
prfKey,
};
}
private async signin(
signinMethod: SigninMethod,
isPRFSupportRequired: boolean
): PromiseResult<TokenPrfResponse> {
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 = await this.signinBegin(signinMethod);
if (!signin.data) throw signin.error;
signin.data.challenge = this.base64UrlToArrayBuffer(
signin.data.challenge
);
signin.data.allowCredentials?.forEach(
(cred: {id: string | BufferSource}) => {
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 = (await navigator.credentials.get({
publicKey: signin.data,
mediation:
'autofill' in signinMethod
? ('conditional' as CredentialMediationRequirement)
: undefined, // Typescript doesn't know about 'conditational' yet
signal: this.abortController.signal,
})) as PublicKeyCredential;
const response = await this.signinComplete(
credential,
signin.session,
isPRFSupportRequired
);
return {
token: response.token ?? '',
prfKey: response.prfKey ?? '',
passkeyCredentialId: credential.id,
error: undefined,
};
} catch (caughtError: any) {
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
);
}
}
public async signup(
name: string,
email: string,
isPRFSupportRequired = false,
authtoken?: string,
nickname?: string,
backendData?: unknown
): PromiseResult<PasskeysRegisterResult> {
const headers: any = {'Content-Type': 'application/json'};
if (authtoken) {
headers['Authorization'] = `Bearer ${authtoken}`;
}
try {
let response = await fetch(
`${this.config.backendApiRootUrl}/passkeys-auth/signup/begin`,
{
method: 'POST',
headers,
body: JSON.stringify(
backendData ? {name, email, ...backendData} : {name, email}
),
}
);
if (!response.ok) {
throw new PasskeysAuthError(
ErrorFrom.Backend,
ErrorCodes.HTTP,
`${response.status} ${(await response.json()).message}`
);
}
const data: SignupBeginResponse = await response.json();
const {token: registrationToken, userId} = data;
const result = await this.register(
registrationToken,
prf_salt,
isPRFSupportRequired,
nickname
);
let prfKey;
let passkeyCredentialId;
if (result) {
prfKey = result.prfKey;
passkeyCredentialId = result.passkeyCredentialId;
}
response = await fetch(
`${this.config.backendApiRootUrl}/passkeys-auth/signup/complete`,
{
method: 'POST',
headers,
body: JSON.stringify(
backendData ? {userId, ...backendData} : {userId}
),
}
);
if (!response.ok) {
throw new PasskeysAuthError(
ErrorFrom.Backend,
ErrorCodes.HTTP,
`${response.status} ${(await response.json()).message}`
);
}
return {
isPrfSupported: Boolean(prfKey),
prfKey,
passkeyCredentialId,
error: undefined,
};
} catch (err: any) {
return {error: err};
}
}
private async verifySignin(token: string): Promise<VerifyAuthResponse> {
const response = await 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} ${(await response.json()).message}`
);
}
return (await response.json()) as VerifyAuthResponse;
}
private async verifyReAuth(
token: string,
authToken?: string,
backendData?: unknown
): Promise<VerifyReAuthResponse> {
const headers: any = {'Content-Type': 'application/json'};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
let body = {token};
if (backendData) {
body = {token, ...backendData};
}
const response = await 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} ${(await response.json()).message}`
);
}
return (await response.json()) as VerifyReAuthResponse;
}
public signupOrSigninAbort() {
this.abort();
}
//Passkeys Credentials Management
public async getUserPasskeyCredentials(
authtoken?: string,
userId?: string
): PromiseResult<GetUserPasskeyCredentialsResult> {
if (!authtoken && !userId) {
return {
error: new PasskeysAuthError(
ErrorFrom.Unknown,
ErrorCodes.Unknown,
'Please provide auth token or userId! '
),
};
}
const headers: any = {'Content-Type': 'application/json'};
if (authtoken) {
headers.Authorization = `Bearer ${authtoken}`;
}
const response = await fetch(
`${this.config.backendApiRootUrl}/passkeys-auth/credentials/list${userId ? '?userId=' + userId : ''}`,
{
method: 'GET',
headers,
}
);
const res: GetUserPasskeyCredentialsBackendResponse = await response.json();
if (!response.ok) {
return {
error: new PasskeysAuthError(
ErrorFrom.Backend,
ErrorCodes.HTTP,
`${response.status} ${res.message}`
),
};
}
return {credentials: res.credentials, error: undefined};
}
public async deleteUserPasskeyCredential(
credentialId: string,
backendData?: unknown,
authtoken?: string
): PromiseResult<DeleteUserPasskeyCredentialResult> {
const headers: any = {'Content-Type': 'application/json'};
if (authtoken) {
headers['Authorization'] = `Bearer ${authtoken}`;
}
let body = {credentialId};
if (backendData) {
body = {credentialId, ...backendData};
}
const response = await 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} ${(await response.json()).message}`
),
};
}
return {isDeletionSuccessful: true, error: undefined};
}
}