UNPKG

@gang-js/core

Version:

a state sharing algorithm

271 lines (270 loc) 10.5 kB
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)); } }