passkeys-prf-client
Version:
A client npm package for passkeys authentication with PRF functionality
615 lines (614 loc) • 28 kB
JavaScript
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 };
});
}
}