mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 00:03:56 +00:00
Move web to apps/web and bitwarden_license/bit-web
This commit is contained in:
23
apps/web/src/connectors/captcha-mobile.html
Normal file
23
apps/web/src/connectors/captcha-mobile.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="theme_light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"
|
||||
/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="HandheldFriendly" content="true" />
|
||||
<title>Bitwarden Captcha Connector</title>
|
||||
</head>
|
||||
|
||||
<body class="layout_frontend">
|
||||
<div class="row justify-content-md-center mt-5">
|
||||
<div>
|
||||
<img src="..//images/logo-dark@2x.png" class="logo mb-2" alt="Bitwarden" />
|
||||
<p id="captchaRequired" class="lead text-center mx-4 mb-4">Captcha Required</p>
|
||||
<div id="captcha"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
apps/web/src/connectors/captcha-mobile.scss
Normal file
1
apps/web/src/connectors/captcha-mobile.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "../scss/styles.scss";
|
||||
17
apps/web/src/connectors/captcha.html
Normal file
17
apps/web/src/connectors/captcha.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"
|
||||
/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="HandheldFriendly" content="true" />
|
||||
<title>Bitwarden Captcha Connector</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="captcha"></div>
|
||||
</body>
|
||||
</html>
|
||||
6
apps/web/src/connectors/captcha.scss
Normal file
6
apps/web/src/connectors/captcha.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
body {
|
||||
min-width: 0px !important;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
144
apps/web/src/connectors/captcha.ts
Normal file
144
apps/web/src/connectors/captcha.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { b64Decode, getQsParam } from "./common";
|
||||
|
||||
declare let hcaptcha: any;
|
||||
|
||||
if (window.location.pathname.includes("mobile")) {
|
||||
require("./captcha-mobile.scss");
|
||||
} else {
|
||||
require("./captcha.scss");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init();
|
||||
});
|
||||
|
||||
(window as any).captchaSuccess = captchaSuccess;
|
||||
(window as any).captchaError = captchaError;
|
||||
|
||||
let parentUrl: string = null;
|
||||
let parentOrigin: string = null;
|
||||
let mobileResponse: boolean = null;
|
||||
let sentSuccess = false;
|
||||
|
||||
async function init() {
|
||||
await start();
|
||||
onMessage();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
sentSuccess = false;
|
||||
|
||||
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 decodedData: any;
|
||||
try {
|
||||
decodedData = JSON.parse(b64Decode(data, true));
|
||||
} catch (e) {
|
||||
error("Cannot parse data.");
|
||||
return;
|
||||
}
|
||||
mobileResponse = decodedData.callbackUri != null || decodedData.mobile === true;
|
||||
|
||||
let src = "https://hcaptcha.com/1/api.js?render=explicit";
|
||||
|
||||
// Set language code
|
||||
if (decodedData.locale) {
|
||||
src += `&hl=${encodeURIComponent(decodedData.locale) ?? "en"}`;
|
||||
}
|
||||
|
||||
// Set captchaRequired subtitle for mobile
|
||||
const subtitleEl = document.getElementById("captchaRequired");
|
||||
if (decodedData.captchaRequiredText && subtitleEl) {
|
||||
subtitleEl.textContent = decodedData.captchaRequiredText;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.addEventListener("load", () => {
|
||||
hcaptcha.render("captcha", {
|
||||
sitekey: encodeURIComponent(decodedData.siteKey),
|
||||
callback: "captchaSuccess",
|
||||
"error-callback": "captchaError",
|
||||
});
|
||||
watchHeight();
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function captchaSuccess(response: string) {
|
||||
if (mobileResponse) {
|
||||
document.location.replace("bitwarden://captcha-callback?token=" + encodeURIComponent(response));
|
||||
} else {
|
||||
success(response);
|
||||
}
|
||||
}
|
||||
|
||||
function captchaError() {
|
||||
error("An error occurred with the captcha. Try again.");
|
||||
}
|
||||
|
||||
function onMessage() {
|
||||
window.addEventListener(
|
||||
"message",
|
||||
(event) => {
|
||||
if (!event.origin || event.origin === "" || event.origin !== parentOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === "start") {
|
||||
start();
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function error(message: string) {
|
||||
parent.postMessage("error|" + message, parentUrl);
|
||||
}
|
||||
|
||||
function success(data: string) {
|
||||
if (sentSuccess) {
|
||||
return;
|
||||
}
|
||||
parent.postMessage("success|" + data, parentUrl);
|
||||
sentSuccess = true;
|
||||
}
|
||||
|
||||
function info(message: string | object) {
|
||||
parent.postMessage("info|" + JSON.stringify(message), parentUrl);
|
||||
}
|
||||
|
||||
async function watchHeight() {
|
||||
const imagesDiv = document.body.lastChild as HTMLElement;
|
||||
// eslint-disable-next-line
|
||||
while (true) {
|
||||
info({
|
||||
height:
|
||||
imagesDiv.style.visibility === "hidden"
|
||||
? document.documentElement.offsetHeight
|
||||
: document.documentElement.scrollHeight,
|
||||
width: document.documentElement.scrollWidth,
|
||||
});
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
71
apps/web/src/connectors/common-webauthn.ts
Normal file
71
apps/web/src/connectors/common-webauthn.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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 parseWebauthnJson(jsonString: string) {
|
||||
const json = JSON.parse(jsonString);
|
||||
|
||||
const challenge = json.challenge.replace(/-/g, "+").replace(/_/g, "/");
|
||||
json.challenge = Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0));
|
||||
|
||||
json.allowCredentials.forEach((listItem: any) => {
|
||||
// eslint-disable-next-line
|
||||
const fixedId = listItem.id.replace(/\_/g, "/").replace(/\-/g, "+");
|
||||
listItem.id = Uint8Array.from(atob(fixedId), (c) => c.charCodeAt(0));
|
||||
});
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
30
apps/web/src/connectors/common.ts
Normal file
30
apps/web/src/connectors/common.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export function getQsParam(name: string) {
|
||||
const url = window.location.href;
|
||||
// eslint-disable-next-line
|
||||
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, " "));
|
||||
}
|
||||
|
||||
export function b64Decode(str: string, spaceAsPlus = false) {
|
||||
if (spaceAsPlus) {
|
||||
str = str.replace(/ /g, "+");
|
||||
}
|
||||
|
||||
return decodeURIComponent(
|
||||
Array.prototype.map
|
||||
.call(atob(str), (c: string) => {
|
||||
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
13
apps/web/src/connectors/duo.html
Normal file
13
apps/web/src/connectors/duo.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width"
|
||||
/>
|
||||
<title>Bitwarden Duo Connector</title>
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
</html>
|
||||
18
apps/web/src/connectors/duo.scss
Normal file
18
apps/web/src/connectors/duo.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #efeff4 url("../images/loading.svg") 0 0 no-repeat;
|
||||
}
|
||||
|
||||
iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
47
apps/web/src/connectors/duo.ts
Normal file
47
apps/web/src/connectors/duo.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as DuoWebSDK from "duo_web_sdk";
|
||||
|
||||
import { getQsParam } from "./common";
|
||||
|
||||
require("./duo.scss");
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const frameElement = document.createElement("iframe");
|
||||
frameElement.setAttribute("id", "duo_iframe");
|
||||
setFrameHeight();
|
||||
document.body.appendChild(frameElement);
|
||||
|
||||
const hostParam = getQsParam("host");
|
||||
const requestParam = getQsParam("request");
|
||||
|
||||
const hostUrl = new URL("https://" + hostParam);
|
||||
if (
|
||||
!hostUrl.hostname.endsWith(".duosecurity.com") &&
|
||||
!hostUrl.hostname.endsWith(".duofederal.com")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
DuoWebSDK.init({
|
||||
iframe: "duo_iframe",
|
||||
host: hostParam,
|
||||
sig_request: requestParam,
|
||||
submit_callback: (form: any) => {
|
||||
invokeCSCode(form.elements.sig_response.value);
|
||||
},
|
||||
});
|
||||
|
||||
window.onresize = setFrameHeight;
|
||||
|
||||
function setFrameHeight() {
|
||||
frameElement.style.height = window.innerHeight + "px";
|
||||
}
|
||||
});
|
||||
|
||||
function invokeCSCode(data: string) {
|
||||
try {
|
||||
(window as any).invokeCSharpAction(data);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
33
apps/web/src/connectors/sso.html
Normal file
33
apps/web/src/connectors/sso.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="theme_light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1010" />
|
||||
<meta name="theme-color" content="#175DDC" />
|
||||
|
||||
<title>Bitwarden</title>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../images/icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../images/icons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../images/icons/favicon-16x16.png" />
|
||||
<link rel="mask-icon" href="../images/icons/safari-pinned-tab.svg" color="#175DDC" />
|
||||
<link rel="manifest" href="../manifest.json" />
|
||||
</head>
|
||||
|
||||
<body class="layout_frontend">
|
||||
<div class="mt-5 d-flex justify-content-center">
|
||||
<div>
|
||||
<img src="../images/logo-dark@2x.png" class="mb-4 logo" alt="Bitwarden" />
|
||||
<div id="content">
|
||||
<p class="text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x text-muted"
|
||||
title="Loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
apps/web/src/connectors/sso.scss
Normal file
1
apps/web/src/connectors/sso.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "../scss/styles.scss";
|
||||
47
apps/web/src/connectors/sso.ts
Normal file
47
apps/web/src/connectors/sso.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getQsParam } from "./common";
|
||||
|
||||
require("./sso.scss");
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const code = getQsParam("code");
|
||||
const state = getQsParam("state");
|
||||
|
||||
if (state != null && state.includes(":clientId=browser")) {
|
||||
initiateBrowserSso(code, state);
|
||||
} else {
|
||||
window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state;
|
||||
// Match any characters between "_returnUri='" and the next "'"
|
||||
const returnUri = extractFromRegex(state, "(?<=_returnUri=')(.*)(?=')");
|
||||
if (returnUri) {
|
||||
window.location.href = window.location.origin + `/#${returnUri}`;
|
||||
} else {
|
||||
window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function initiateBrowserSso(code: string, state: string) {
|
||||
window.postMessage({ command: "authResult", code: code, state: state }, "*");
|
||||
const handOffMessage = ("; " + document.cookie)
|
||||
.split("; ssoHandOffMessage=")
|
||||
.pop()
|
||||
.split(";")
|
||||
.shift();
|
||||
document.cookie = "ssoHandOffMessage=;SameSite=strict;max-age=0";
|
||||
const content = document.getElementById("content");
|
||||
content.innerHTML = "";
|
||||
const p = document.createElement("p");
|
||||
p.innerText = handOffMessage;
|
||||
content.appendChild(p);
|
||||
}
|
||||
|
||||
function extractFromRegex(s: string, regexString: string) {
|
||||
const regex = new RegExp(regexString);
|
||||
const results = regex.exec(s);
|
||||
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return results[0];
|
||||
}
|
||||
39
apps/web/src/connectors/webauthn-fallback.html
Normal file
39
apps/web/src/connectors/webauthn-fallback.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="theme_light">
|
||||
<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="bwi bwi-spinner bwi-spin bwi-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" class="btn btn-primary btn-lg"></button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
164
apps/web/src/connectors/webauthn-fallback.ts
Normal file
164
apps/web/src/connectors/webauthn-fallback.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { b64Decode, getQsParam } from "./common";
|
||||
import { buildDataString, parseWebauthnJson } from "./common-webauthn";
|
||||
|
||||
require("./webauthn.scss");
|
||||
|
||||
let parsed = false;
|
||||
let webauthnJson: any;
|
||||
let parentUrl: string = null;
|
||||
let sentSuccess = false;
|
||||
let locale = "en";
|
||||
|
||||
let locales: any = {};
|
||||
|
||||
function parseParameters() {
|
||||
if (parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
parentUrl = getQsParam("parent");
|
||||
if (!parentUrl) {
|
||||
error("No parent.");
|
||||
return;
|
||||
} else {
|
||||
parentUrl = decodeURIComponent(parentUrl);
|
||||
}
|
||||
|
||||
locale = getQsParam("locale").replace("-", "_");
|
||||
|
||||
const version = getQsParam("v");
|
||||
|
||||
if (version === "1") {
|
||||
parseParametersV1();
|
||||
} else {
|
||||
parseParametersV2();
|
||||
}
|
||||
parsed = true;
|
||||
}
|
||||
|
||||
function parseParametersV1() {
|
||||
const data = getQsParam("data");
|
||||
if (!data) {
|
||||
error("No data.");
|
||||
return;
|
||||
}
|
||||
|
||||
webauthnJson = b64Decode(data);
|
||||
}
|
||||
|
||||
function parseParametersV2() {
|
||||
let dataObj: { data: any; btnText: string } = null;
|
||||
try {
|
||||
dataObj = JSON.parse(b64Decode(getQsParam("data")));
|
||||
} catch (e) {
|
||||
error("Cannot parse data.");
|
||||
return;
|
||||
}
|
||||
|
||||
webauthnJson = dataObj.data;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
parseParameters();
|
||||
try {
|
||||
locales = await loadLocales(locale);
|
||||
} catch {
|
||||
// eslint-disable-next-line
|
||||
console.error("Failed to load the locale", locale);
|
||||
locales = await loadLocales("en");
|
||||
}
|
||||
|
||||
document.getElementById("msg").innerText = translate("webAuthnFallbackMsg");
|
||||
document.getElementById("remember-label").innerText = translate("rememberMe");
|
||||
|
||||
const button = document.getElementById("webauthn-button");
|
||||
button.innerText = translate("webAuthnAuthenticate");
|
||||
button.onclick = start;
|
||||
|
||||
document.getElementById("spinner").classList.add("d-none");
|
||||
const content = document.getElementById("content");
|
||||
content.classList.add("d-block");
|
||||
content.classList.remove("d-none");
|
||||
});
|
||||
|
||||
async function loadLocales(newLocale: string) {
|
||||
const filePath = `locales/${newLocale}/messages.json?cache=${process.env.CACHE_TAG}`;
|
||||
const localesResult = await fetch(filePath);
|
||||
return await localesResult.json();
|
||||
}
|
||||
|
||||
function translate(id: string) {
|
||||
return locales[id]?.message || "";
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (sentSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("credentials" in navigator)) {
|
||||
error(translate("webAuthnNotSupported"));
|
||||
return;
|
||||
}
|
||||
|
||||
parseParameters();
|
||||
if (!webauthnJson) {
|
||||
error("No data.");
|
||||
return;
|
||||
}
|
||||
|
||||
let json: any;
|
||||
try {
|
||||
json = parseWebauthnJson(webauthnJson);
|
||||
} catch (e) {
|
||||
error("Cannot parse data.");
|
||||
return;
|
||||
}
|
||||
|
||||
initWebAuthn(json);
|
||||
}
|
||||
|
||||
async function initWebAuthn(obj: any) {
|
||||
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");
|
||||
resetMsgBox(el);
|
||||
el.textContent = 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");
|
||||
resetMsgBox(el);
|
||||
el.textContent = message;
|
||||
el.classList.add("alert");
|
||||
el.classList.add("alert-success");
|
||||
}
|
||||
|
||||
function resetMsgBox(el: HTMLElement) {
|
||||
el.classList.remove("alert");
|
||||
el.classList.remove("alert-danger");
|
||||
el.classList.remove("alert-success");
|
||||
}
|
||||
30
apps/web/src/connectors/webauthn-mobile.html
Normal file
30
apps/web/src/connectors/webauthn-mobile.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"
|
||||
/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="HandheldFriendly" content="true" />
|
||||
<title>Bitwarden WebAuthn Connector</title>
|
||||
</head>
|
||||
|
||||
<body style="background: transparent">
|
||||
<div class="row justify-content-md-center mt-5">
|
||||
<div>
|
||||
<img src="../images/logo-dark@2x.png" class="logo mb-2" alt="Bitwarden" />
|
||||
<p id="webauthn-header" class="lead text-center mx-4 mb-4"></p>
|
||||
<picture>
|
||||
<source srcset="../images/u2fkey-mobile.avif" type="image/avif" />
|
||||
<source srcset="../images/u2fkey-mobile.webp" type="image/webp" />
|
||||
<img src="../images/u2fkey-mobile.jpg" class="rounded img-fluid" />
|
||||
</picture>
|
||||
<div class="text-center mt-4">
|
||||
<button id="webauthn-button" class="btn btn-primary btn-lg"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
18
apps/web/src/connectors/webauthn.html
Normal file
18
apps/web/src/connectors/webauthn.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Bitwarden WebAuthn Connector</title>
|
||||
</head>
|
||||
|
||||
<body style="background: transparent">
|
||||
<picture>
|
||||
<source srcset="../images/u2fkey.avif" type="image/avif" />
|
||||
<source srcset="../images/u2fkey.webp" type="image/webp" />
|
||||
<img src="../images/u2fkey.jpg" class="rounded img-fluid mb-3" />
|
||||
</picture>
|
||||
<div class="text-center">
|
||||
<button id="webauthn-button" class="btn btn-primary"></button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
5
apps/web/src/connectors/webauthn.scss
Normal file
5
apps/web/src/connectors/webauthn.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "../scss/styles.scss";
|
||||
|
||||
body {
|
||||
min-width: 0px !important;
|
||||
}
|
||||
200
apps/web/src/connectors/webauthn.ts
Normal file
200
apps/web/src/connectors/webauthn.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { b64Decode, getQsParam } from "./common";
|
||||
import { buildDataString, parseWebauthnJson } from "./common-webauthn";
|
||||
|
||||
require("./webauthn.scss");
|
||||
|
||||
const mobileCallbackUri = "bitwarden://webauthn-callback";
|
||||
|
||||
let parsed = false;
|
||||
let webauthnJson: any;
|
||||
let headerText: string = null;
|
||||
let btnText: string = null;
|
||||
let btnReturnText: string = null;
|
||||
let parentUrl: string = null;
|
||||
let parentOrigin: string = null;
|
||||
let mobileResponse = false;
|
||||
let stopWebAuthn = false;
|
||||
let sentSuccess = false;
|
||||
let obj: any = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init();
|
||||
|
||||
parseParameters();
|
||||
if (headerText) {
|
||||
const header = document.getElementById("webauthn-header");
|
||||
header.innerText = decodeURI(headerText);
|
||||
}
|
||||
if (btnText) {
|
||||
const button = document.getElementById("webauthn-button");
|
||||
button.innerText = decodeURI(btnText);
|
||||
button.onclick = executeWebAuthn;
|
||||
}
|
||||
});
|
||||
|
||||
function init() {
|
||||
start();
|
||||
onMessage();
|
||||
info("ready");
|
||||
}
|
||||
|
||||
function parseParameters() {
|
||||
if (parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
parentUrl = getQsParam("parent");
|
||||
if (!parentUrl) {
|
||||
error("No parent.");
|
||||
return;
|
||||
} else {
|
||||
parentUrl = decodeURIComponent(parentUrl);
|
||||
parentOrigin = new URL(parentUrl).origin;
|
||||
}
|
||||
|
||||
const version = getQsParam("v");
|
||||
|
||||
if (version === "1") {
|
||||
parseParametersV1();
|
||||
} else {
|
||||
parseParametersV2();
|
||||
}
|
||||
parsed = true;
|
||||
}
|
||||
|
||||
function parseParametersV1() {
|
||||
const data = getQsParam("data");
|
||||
if (!data) {
|
||||
error("No data.");
|
||||
return;
|
||||
}
|
||||
|
||||
webauthnJson = b64Decode(data);
|
||||
headerText = getQsParam("headerText");
|
||||
btnText = getQsParam("btnText");
|
||||
btnReturnText = getQsParam("btnReturnText");
|
||||
}
|
||||
|
||||
function parseParametersV2() {
|
||||
let dataObj: {
|
||||
data: any;
|
||||
headerText: string;
|
||||
btnText: string;
|
||||
btnReturnText: string;
|
||||
callbackUri?: string;
|
||||
mobile?: boolean;
|
||||
} = null;
|
||||
try {
|
||||
dataObj = JSON.parse(b64Decode(getQsParam("data")));
|
||||
} catch (e) {
|
||||
error("Cannot parse data.");
|
||||
return;
|
||||
}
|
||||
|
||||
mobileResponse = dataObj.callbackUri != null || dataObj.mobile === true;
|
||||
webauthnJson = dataObj.data;
|
||||
headerText = dataObj.headerText;
|
||||
btnText = dataObj.btnText;
|
||||
btnReturnText = dataObj.btnReturnText;
|
||||
}
|
||||
|
||||
function start() {
|
||||
sentSuccess = false;
|
||||
|
||||
if (!("credentials" in navigator)) {
|
||||
error("WebAuthn is not supported in this browser.");
|
||||
return;
|
||||
}
|
||||
|
||||
parseParameters();
|
||||
if (!webauthnJson) {
|
||||
error("No data.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
obj = parseWebauthnJson(webauthnJson);
|
||||
} catch (e) {
|
||||
error("Cannot parse webauthn data.");
|
||||
return;
|
||||
}
|
||||
|
||||
stopWebAuthn = false;
|
||||
|
||||
if (
|
||||
mobileResponse ||
|
||||
(navigator.userAgent.indexOf(" Safari/") !== -1 && navigator.userAgent.indexOf("Chrome") === -1)
|
||||
) {
|
||||
// Safari and mobile chrome blocks non-user initiated WebAuthn requests.
|
||||
} else {
|
||||
executeWebAuthn();
|
||||
}
|
||||
}
|
||||
|
||||
function executeWebAuthn() {
|
||||
if (stopWebAuthn) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.credentials.get({ publicKey: obj }).then(success).catch(error);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (mobileResponse) {
|
||||
document.location.replace(mobileCallbackUri + "?error=" + encodeURIComponent(message));
|
||||
returnButton(mobileCallbackUri + "?error=" + encodeURIComponent(message));
|
||||
} else {
|
||||
parent.postMessage("error|" + message, parentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function success(assertedCredential: PublicKeyCredential) {
|
||||
if (sentSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataString = buildDataString(assertedCredential);
|
||||
|
||||
if (mobileResponse) {
|
||||
document.location.replace(mobileCallbackUri + "?data=" + encodeURIComponent(dataString));
|
||||
returnButton(mobileCallbackUri + "?data=" + encodeURIComponent(dataString));
|
||||
} else {
|
||||
parent.postMessage("success|" + dataString, parentUrl);
|
||||
sentSuccess = true;
|
||||
}
|
||||
}
|
||||
|
||||
function info(message: string) {
|
||||
if (mobileResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
parent.postMessage("info|" + message, parentUrl);
|
||||
}
|
||||
|
||||
function returnButton(uri: string) {
|
||||
// provides 'return' button in case scripted navigation is blocked
|
||||
const button = document.getElementById("webauthn-button");
|
||||
button.innerText = decodeURI(btnReturnText);
|
||||
button.onclick = () => {
|
||||
document.location.replace(uri);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user