@gang-js/core
Version:
a state sharing algorithm
271 lines (270 loc) • 10.5 kB
JavaScript
import { GangContext } from '../../context';
import { GangCredentialRegistration, GangAuthentication, GangAuthenticationCredential } from '../../models';
import { GangUrlBuilder, base64UrlToBytes, bytesToBase64Url, bytesToString, CBOR, getRandomBytes, stringToBytes, viewToBuffer } from '../utils';
const USER_ID_KEY = 'AUTH.USER_ID';
const USER_TOKEN_KEY = 'AUTH.TOKEN';
export class GangAuthenticationService {
/**
* Create a service
* @param settings
* @param http
* @param location
* @param credentials
* @param vault
*/
constructor(settings, http, location, credentials, vault) {
this.settings = settings;
this.http = http;
this.location = location;
this.credentials = credentials;
this.vault = vault;
this._platform = {
hasAuthenticator: true
};
if (window.PublicKeyCredential)
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then((value) => {
this._platform = Object.assign(Object.assign({}, this.platform), { hasAuthenticator: value });
});
}
get platform() {
return this._platform;
}
/**
* Request a link code
*
* @param email email address
*/
async requestLink(email) {
const result = await this.http.fetch(`/request-link`, {
method: 'POST',
headers: {
'Content-type': 'application/json'
},
body: `"${email}"`
});
return result.ok;
}
/**
* gets the code from the current url and removes if found
*
* @param {string} [parameterName=link-code] - name of the url parameter
*/
tryGetLinkCodeFromUrl(parameterName = 'link-code') {
const urlBuilder = new GangUrlBuilder(this.location.href);
const code = urlBuilder.getString(parameterName);
if (code) {
// code found, remove from the url and link
delete urlBuilder.parameters[parameterName];
this.location.pushState(urlBuilder.build());
return code;
}
return undefined;
}
/**
* Attempts to get a session token, given a code
*
* @param {string} [code]
*/
async validateLink(email, code) {
const result = await this.http.fetch(`/validate-link`, {
method: 'POST',
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify({
email,
code
})
});
if (!result.ok)
return undefined;
return await result.text();
}
/**
* Try and get a challenge for the user from the server
*
* @param token valid session token
*/
async tryGetChallenge(token) {
if (!token)
return null;
const headers = { 'Content-type': 'application/json' };
if (token)
headers['Authorization'] = token;
const result = await this.http.fetch(`/request-challenge`, {
method: 'POST',
headers
});
if (!result.ok)
return null;
return stringToBytes(await result.text());
}
/**
* Register a credential from the device on the server
* shows the authenticator UI e.g. fingerprint, face or pin
*
* @param token required, valid session token
* @param challenge required, challenge from the server
*
* @returns credential, which can be stored and passed to authenticateCredential
*/
async registerCredential(token, challenge) {
var _a;
const tokenData = this.decodeToken(token);
if (!tokenData)
throw new Error('token is required');
if (!challenge)
throw new Error('challenge is required');
const userId = crypto.getRandomValues(new Uint8Array(16));
const options = {
challenge,
rp: {
name: this.settings.app.name
},
user: {
id: userId,
name: tokenData.email,
displayName: tokenData.name || tokenData.email
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ios, Android
{ alg: -257, type: 'public-key' } // windows hello
],
authenticatorSelection: {
authenticatorAttachment: this.platform.hasAuthenticator ? 'platform' : 'cross-platform',
userVerification: 'discouraged',
requireResidentKey: true,
residentKey: 'preferred'
},
extensions: {
prf: { eval: { first: userId } }
},
attestation: 'none'
};
try {
const credential = (await this.credentials.create({ publicKey: options }));
const response = credential.response;
this.validate(response.clientDataJSON, challenge);
const attestationObject = CBOR.decode(response.attestationObject);
const transports = response.getTransports();
const credentialRegistration = GangCredentialRegistration.from(attestationObject.authData, transports, challenge);
const result = await this.http.fetch(`/register-credential`, {
method: 'POST',
headers: {
'Content-type': 'application/json',
Authorization: token
},
body: JSON.stringify(credentialRegistration)
});
if (!result.ok)
throw new Error('Credential was not registered');
this.vault.set(USER_ID_KEY, userId);
const extensionResults = credential.getClientExtensionResults();
const seed = ((_a = extensionResults === null || extensionResults === void 0 ? void 0 : extensionResults.prf) === null || _a === void 0 ? void 0 : _a.results)
? viewToBuffer(extensionResults.prf.results.first)
: viewToBuffer(userId);
this.vault.setSeed(seed);
await this.vault.setEncrypted(USER_TOKEN_KEY, token);
GangContext.logger.debug('GangAuthenticationService.registerCredential success', {
id: tokenData.id,
prf: !!(extensionResults === null || extensionResults === void 0 ? void 0 : extensionResults.prf)
});
return new GangAuthenticationCredential(credential.id, transports);
}
catch (error) {
GangContext.logger.error('GangAuthenticationService.registerCredential', { error });
return null;
}
}
/**
* Validate the credential passed
* shows the authenticator UI e.g. fingerprint, face or pin
*
* throws on failure
*
* offline will only do basic validation
* online will pass back to the server for detailed auth
*
* @param credential Stored registered credential
*
* @returns when offline returns null, online will return a new session token
*/
async validateCredential(credential) {
var _a, _b;
GangContext.logger.debug('GangAuthenticationService.validateCredential', { credential });
const challenge = getRandomBytes();
const options = {
challenge,
userVerification: 'required'
};
const userId = await this.vault.get(USER_ID_KEY);
if (userId)
options.extensions = {
prf: { eval: { first: userId } }
};
if (credential)
options.allowCredentials = [
{
id: base64UrlToBytes(credential.id),
type: 'public-key',
transports: credential.transports
}
];
let publicKey;
try {
publicKey = (await this.credentials.get({ publicKey: options }));
}
catch (error) {
GangContext.logger.error('GangAuthenticationService.validateCredential', { error });
return null;
}
const response = publicKey.response;
this.validate(response.clientDataJSON, challenge);
const extensionResults = publicKey.getClientExtensionResults();
const seed = ((_a = extensionResults === null || extensionResults === void 0 ? void 0 : extensionResults.prf) === null || _a === void 0 ? void 0 : _a.results)
? viewToBuffer((_b = extensionResults.prf.results) === null || _b === void 0 ? void 0 : _b.first)
: viewToBuffer(userId);
this.vault.setSeed(seed);
GangContext.logger.debug('GangAuthenticationService.validateCredential success', {
prf: !!(extensionResults === null || extensionResults === void 0 ? void 0 : extensionResults.prf)
});
if (navigator.onLine) {
const validation = GangAuthentication.from(credential.id, response.clientDataJSON, response.authenticatorData, response.signature);
const result = await this.http.fetch(`/validate-credential`, {
method: 'POST',
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify(validation)
});
if (!result.ok)
throw new Error('Credential Invalid');
const token = await result.text();
await this.vault.setEncrypted(USER_TOKEN_KEY, token);
return token;
}
return await this.vault.getEncrypted(USER_TOKEN_KEY); // get stored token when offline
}
validate(clientDataJSON, challenge) {
const clientData = JSON.parse(bytesToString(clientDataJSON));
const challengeString = bytesToBase64Url(challenge);
if (clientData.challenge !== challengeString)
throw new Error('Invalid authenticator response challenge');
if (clientData.origin !== this.location.origin)
throw new Error('Invalid authenticator response origin');
}
/**
* decode a token to data
*
* @param token valid token
*/
decodeToken(token) {
if (!token)
return undefined;
const tokenParts = token.split('.');
if (tokenParts.length != 2)
return undefined;
const data = tokenParts[0];
return JSON.parse(atob(data));
}
}