mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 00:03:56 +00:00
WebAuthn (#633)
This commit is contained in:
62
src/connectors/common-webauthn.ts
Normal file
62
src/connectors/common-webauthn.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export function buildDataString(assertedCredential: PublicKeyCredential) {
|
||||
const response = assertedCredential.response as AuthenticatorAssertionResponse;
|
||||
|
||||
const authData = new Uint8Array(response.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
||||
const rawId = new Uint8Array(assertedCredential.rawId);
|
||||
const sig = new Uint8Array(response.signature);
|
||||
|
||||
const data = {
|
||||
id: assertedCredential.id,
|
||||
rawId: coerceToBase64Url(rawId),
|
||||
type: assertedCredential.type,
|
||||
extensions: assertedCredential.getClientExtensionResults(),
|
||||
response: {
|
||||
authenticatorData: coerceToBase64Url(authData),
|
||||
clientDataJson: coerceToBase64Url(clientDataJSON),
|
||||
signature: coerceToBase64Url(sig),
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
export function b64Decode(str: string) {
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(str), (c: string) => {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
// From https://github.com/abergs/fido2-net-lib/blob/b487a1d47373ea18cd752b4988f7262035b7b54e/Demo/wwwroot/js/helpers.js#L34
|
||||
// License: https://github.com/abergs/fido2-net-lib/blob/master/LICENSE.txt
|
||||
function coerceToBase64Url(thing: any) {
|
||||
// Array or ArrayBuffer to Uint8Array
|
||||
if (Array.isArray(thing)) {
|
||||
thing = Uint8Array.from(thing);
|
||||
}
|
||||
|
||||
if (thing instanceof ArrayBuffer) {
|
||||
thing = new Uint8Array(thing);
|
||||
}
|
||||
|
||||
// Uint8Array to base64
|
||||
if (thing instanceof Uint8Array) {
|
||||
let str = '';
|
||||
const len = thing.byteLength;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
str += String.fromCharCode(thing[i]);
|
||||
}
|
||||
thing = window.btoa(str);
|
||||
}
|
||||
|
||||
if (typeof thing !== 'string') {
|
||||
throw new Error('could not coerce to string');
|
||||
}
|
||||
|
||||
// base64 to base64url
|
||||
// NOTE: "=" at the end of challenge is optional, strip it off here
|
||||
thing = thing.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, '');
|
||||
|
||||
return thing;
|
||||
}
|
||||
15
src/connectors/common.ts
Normal file
15
src/connectors/common.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function getQsParam(name: string) {
|
||||
const url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, '\\$&');
|
||||
const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)');
|
||||
const results = regex.exec(url);
|
||||
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
if (!results[2]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(results[2].replace(/\+/g, ' '));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as DuoWebSDK from 'duo_web_sdk';
|
||||
import { getQsParam } from './common';
|
||||
|
||||
// tslint:disable-next-line
|
||||
require('./duo.scss');
|
||||
@@ -27,22 +28,6 @@ document.addEventListener('DOMContentLoaded', event => {
|
||||
}
|
||||
});
|
||||
|
||||
function getQsParam(name: string) {
|
||||
const url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, '\\$&');
|
||||
const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)');
|
||||
const results = regex.exec(url);
|
||||
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
if (!results[2]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(results[2].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
function invokeCSCode(data: string) {
|
||||
try {
|
||||
(window as any).invokeCSharpAction(data);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// tslint:disable-next-line
|
||||
import { getQsParam } from './common';
|
||||
|
||||
// tslint:disable-next-line
|
||||
require('./sso.scss');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', event => {
|
||||
@@ -19,22 +21,6 @@ document.addEventListener('DOMContentLoaded', event => {
|
||||
}
|
||||
});
|
||||
|
||||
function getQsParam(name: string) {
|
||||
const url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, '\\$&');
|
||||
const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)');
|
||||
const results = regex.exec(url);
|
||||
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
if (!results[2]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(results[2].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
function initiateBrowserSso(code: string, state: string) {
|
||||
window.postMessage({ command: 'authResult', code: code, state: state }, '*');
|
||||
const handOffMessage = ('; ' + document.cookie).split('; ssoHandOffMessage=').pop().split(';').shift();
|
||||
|
||||
36
src/connectors/webauthn-fallback.html
Normal file
36
src/connectors/webauthn-fallback.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Bitwarden WebAuthn Connector</title>
|
||||
</head>
|
||||
|
||||
<body class="layout_frontend">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-5">
|
||||
<img src="../images/logo-dark@2x.png" class="mb-4 logo" alt="Bitwarden">
|
||||
<div id="spinner">
|
||||
<p class="text-center">
|
||||
<i class="fa fa-spinner fa-spin fa-2x text-muted" title="Loading" aria-hidden="true"></i>
|
||||
</p>
|
||||
</div>
|
||||
<div id="content" class="card mt-4 d-none">
|
||||
<div class="card-body ng-star-inserted">
|
||||
<p id="msg" class="text-center"></p>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember" name="remember">
|
||||
<label class="form-check-label" for="remember" id="remember-label"></label>
|
||||
</div>
|
||||
<hr>
|
||||
<p class="text-center mb-0">
|
||||
<button id="webauthn-button" onClick="javascript:init()" class="btn btn-primary btn-lg"></button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
118
src/connectors/webauthn-fallback.ts
Normal file
118
src/connectors/webauthn-fallback.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { getQsParam } from './common';
|
||||
import { b64Decode, buildDataString } from './common-webauthn';
|
||||
|
||||
// tslint:disable-next-line
|
||||
require('./webauthn.scss');
|
||||
|
||||
let parentUrl: string = null;
|
||||
let parentOrigin: string = null;
|
||||
let sentSuccess = false;
|
||||
|
||||
let locales: any = {};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const locale = getQsParam('locale');
|
||||
|
||||
const filePath = `locales/${locale}/messages.json?cache=${process.env.CACHE_TAG}`;
|
||||
const localesResult = await fetch(filePath);
|
||||
locales = await localesResult.json();
|
||||
|
||||
document.getElementById('msg').innerText = translate('webAuthnFallbackMsg');
|
||||
document.getElementById('remember-label').innerText = translate('rememberMe');
|
||||
document.getElementById('webauthn-button').innerText = translate('webAuthnAuthenticate');
|
||||
|
||||
document.getElementById('spinner').classList.add('d-none');
|
||||
const content = document.getElementById('content');
|
||||
content.classList.add('d-block');
|
||||
content.classList.remove('d-none');
|
||||
});
|
||||
|
||||
function translate(id: string) {
|
||||
return locales[id]?.message || '';
|
||||
}
|
||||
|
||||
(window as any).init = () => {
|
||||
start();
|
||||
};
|
||||
|
||||
function start() {
|
||||
if (sentSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('credentials' in navigator)) {
|
||||
error(translate('webAuthnNotSupported'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = getQsParam('data');
|
||||
if (!data) {
|
||||
error('No data.');
|
||||
return;
|
||||
}
|
||||
|
||||
parentUrl = getQsParam('parent');
|
||||
if (!parentUrl) {
|
||||
error('No parent.');
|
||||
return;
|
||||
} else {
|
||||
parentUrl = decodeURIComponent(parentUrl);
|
||||
parentOrigin = new URL(parentUrl).origin;
|
||||
}
|
||||
|
||||
let json: any;
|
||||
try {
|
||||
const jsonString = b64Decode(data);
|
||||
json = JSON.parse(jsonString);
|
||||
}
|
||||
catch (e) {
|
||||
error('Cannot parse data.');
|
||||
return;
|
||||
}
|
||||
|
||||
initWebAuthn(json);
|
||||
}
|
||||
|
||||
async function initWebAuthn(obj: any) {
|
||||
const challenge = obj.challenge.replace(/-/g, '+').replace(/_/g, '/');
|
||||
obj.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0));
|
||||
|
||||
// fix escaping. Change this to coerce
|
||||
obj.allowCredentials.forEach((listItem: any) => {
|
||||
const fixedId = listItem.id.replace(/\_/g, '/').replace(/\-/g, '+');
|
||||
listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0));
|
||||
});
|
||||
|
||||
try {
|
||||
const assertedCredential = await navigator.credentials.get({ publicKey: obj }) as PublicKeyCredential;
|
||||
|
||||
if (sentSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataString = buildDataString(assertedCredential);
|
||||
const remember = (document.getElementById('remember') as HTMLInputElement).checked;
|
||||
window.postMessage({ command: 'webAuthnResult', data: dataString, remember: remember }, '*');
|
||||
|
||||
sentSuccess = true;
|
||||
success(translate('webAuthnSuccess'));
|
||||
} catch (err) {
|
||||
error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function error(message: string) {
|
||||
const el = document.getElementById('msg');
|
||||
el.innerHTML = message;
|
||||
el.classList.add('alert');
|
||||
el.classList.add('alert-danger');
|
||||
}
|
||||
|
||||
function success(message: string) {
|
||||
(document.getElementById('webauthn-button') as HTMLButtonElement).disabled = true;
|
||||
|
||||
const el = document.getElementById('msg');
|
||||
el.innerHTML = message;
|
||||
el.classList.add('alert');
|
||||
el.classList.add('alert-success');
|
||||
}
|
||||
16
src/connectors/webauthn.html
Normal file
16
src/connectors/webauthn.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Bitwarden WebAuthn Connector</title>
|
||||
</head>
|
||||
|
||||
<body style="background: transparent;">
|
||||
<img src="../images/u2fkey.jpg" class="rounded img-fluid mb-3">
|
||||
<div class="text-center">
|
||||
<button id="webauthn-button" class="btn btn-primary" onclick="javascript:executeWebAuthn()"></button>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
5
src/connectors/webauthn.scss
Normal file
5
src/connectors/webauthn.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "../scss/styles.scss";
|
||||
|
||||
body {
|
||||
min-width: 0px !important;
|
||||
}
|
||||
122
src/connectors/webauthn.ts
Normal file
122
src/connectors/webauthn.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { getQsParam } from './common';
|
||||
import { b64Decode, buildDataString } from './common-webauthn';
|
||||
|
||||
// tslint:disable-next-line
|
||||
require('./webauthn.scss');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
|
||||
const text = getQsParam('btnText');
|
||||
if (text) {
|
||||
document.getElementById('webauthn-button').innerText = decodeURI(text);
|
||||
}
|
||||
});
|
||||
|
||||
let parentUrl: string = null;
|
||||
let parentOrigin: string = null;
|
||||
let stopWebAuthn = false;
|
||||
let sentSuccess = false;
|
||||
let obj: any = null;
|
||||
|
||||
function init() {
|
||||
start();
|
||||
onMessage();
|
||||
info('ready');
|
||||
}
|
||||
|
||||
function start() {
|
||||
sentSuccess = false;
|
||||
|
||||
if (!('credentials' in navigator)) {
|
||||
error('WebAuthn is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = getQsParam('data');
|
||||
if (!data) {
|
||||
error('No data.');
|
||||
return;
|
||||
}
|
||||
|
||||
parentUrl = getQsParam('parent');
|
||||
if (!parentUrl) {
|
||||
error('No parent.');
|
||||
return;
|
||||
} else {
|
||||
parentUrl = decodeURIComponent(parentUrl);
|
||||
parentOrigin = new URL(parentUrl).origin;
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonString = b64Decode(data);
|
||||
obj = JSON.parse(jsonString);
|
||||
}
|
||||
catch (e) {
|
||||
error('Cannot parse data.');
|
||||
return;
|
||||
}
|
||||
|
||||
const challenge = obj.challenge.replace(/-/g, '+').replace(/_/g, '/');
|
||||
obj.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0));
|
||||
|
||||
// fix escaping. Change this to coerce
|
||||
obj.allowCredentials.forEach((listItem: any) => {
|
||||
const fixedId = listItem.id.replace(/\_/g, '/').replace(/\-/g, '+');
|
||||
listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0));
|
||||
});
|
||||
|
||||
stopWebAuthn = false;
|
||||
|
||||
if (navigator.userAgent.indexOf(' Safari/') !== -1 && navigator.userAgent.indexOf('Chrome') === -1) {
|
||||
// TODO: Hide image, show button
|
||||
} else {
|
||||
executeWebAuthn();
|
||||
}
|
||||
}
|
||||
|
||||
function executeWebAuthn() {
|
||||
if (stopWebAuthn) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.credentials.get({ publicKey: obj })
|
||||
.then(success)
|
||||
.catch(err => error('WebAuth Error: ' + err));
|
||||
}
|
||||
|
||||
(window as any).executeWebAuthn = executeWebAuthn;
|
||||
|
||||
function onMessage() {
|
||||
window.addEventListener('message', event => {
|
||||
if (!event.origin || event.origin === '' || event.origin !== parentOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === 'stop') {
|
||||
stopWebAuthn = true;
|
||||
}
|
||||
else if (event.data === 'start' && stopWebAuthn) {
|
||||
start();
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
|
||||
function error(message: string) {
|
||||
parent.postMessage('error|' + message, parentUrl);
|
||||
}
|
||||
|
||||
function success(assertedCredential: PublicKeyCredential) {
|
||||
if (sentSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataString = buildDataString(assertedCredential);
|
||||
parent.postMessage('success|' + dataString, parentUrl);
|
||||
sentSuccess = true;
|
||||
}
|
||||
|
||||
function info(message: string) {
|
||||
parent.postMessage('info|' + message, parentUrl);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user