mirror of https://github.com/bitwarden/web.git
22 changed files with 516 additions and 141 deletions
@ -1 +1 @@
@@ -1 +1 @@
|
||||
Subproject commit f80e89465ffc004705d2941301c0ffb6bfd71d1a |
||||
Subproject commit f20af0cd7c90adc07783950bed197b5d47892d6f |
||||
@ -0,0 +1,62 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -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, ' ')); |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -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> |
||||
@ -0,0 +1,118 @@
@@ -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'); |
||||
} |
||||
@ -0,0 +1,16 @@
@@ -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> |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
@import "../scss/styles.scss"; |
||||
|
||||
body { |
||||
min-width: 0px !important; |
||||
} |
||||
@ -0,0 +1,122 @@
@@ -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); |
||||
} |
||||
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
Loading…
Reference in new issue