Files
gitea/web_src/js/features/user-auth-webauthn.ts
silverwind 60de6cefed Fix WebAuthn error checking (#36219)
Fixes: https://github.com/go-gitea/gitea/issues/36216

Now `detectWebAuthnSupport` returns the error type and lets the caller
decide whether they call `webAuthnError` and show the error. It no
longer shows the error during page load when the user has not even
interacted with the feature.

The bug affects all users on HTTP, so I think a quick fix release for
this might be good.
2025-12-21 18:24:41 +00:00

268 lines
8.2 KiB
TypeScript

import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
import {hideElem, showElem} from '../utils/dom.ts';
import {GET, POST} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
/** One of the possible values for the `data-webauthn-error-msg` attribute on the webauthn error message element */
type ErrorType = 'general' | 'insecure' | 'browser' | 'unable-to-process' | 'duplicated' | 'unknown';
export async function initUserAuthWebAuthn() {
const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
const elSignInPasskeyBtn = document.querySelector('.signin-passkey');
if (!elPrompt && !elSignInPasskeyBtn) {
return;
}
const errorType = detectWebAuthnSupport();
if (errorType) {
if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn);
return;
}
if (elSignInPasskeyBtn) {
elSignInPasskeyBtn.addEventListener('click', loginPasskey);
}
if (elPrompt) {
login2FA();
}
}
async function loginPasskey() {
const res = await GET(`${appSubUrl}/user/webauthn/passkey/assertion`);
if (!res.ok) {
webAuthnError('unknown');
return;
}
const options = await res.json();
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
for (const cred of options.publicKey.allowCredentials ?? []) {
cred.id = decodeURLEncodedBase64(cred.id);
}
try {
const credential = await navigator.credentials.get({
publicKey: options.publicKey,
}) as PublicKeyCredential;
const credResp = credential.response as AuthenticatorAssertionResponse;
// Move data into Arrays in case it is super long
const authData = new Uint8Array(credResp.authenticatorData);
const clientDataJSON = new Uint8Array(credResp.clientDataJSON);
const rawId = new Uint8Array(credential.rawId);
const sig = new Uint8Array(credResp.signature);
const userHandle = new Uint8Array(credResp.userHandle ?? []);
const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, {
data: {
id: credential.id,
rawId: encodeURLEncodedBase64(rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
authenticatorData: encodeURLEncodedBase64(authData),
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
signature: encodeURLEncodedBase64(sig),
userHandle: encodeURLEncodedBase64(userHandle),
},
},
});
if (res.status === 500) {
webAuthnError('unknown');
return;
} else if (!res.ok) {
webAuthnError('unable-to-process');
return;
}
const reply = await res.json();
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
} catch (err) {
webAuthnError('general', err.message);
}
}
async function login2FA() {
const res = await GET(`${appSubUrl}/user/webauthn/assertion`);
if (!res.ok) {
webAuthnError('unknown');
return;
}
const options = await res.json();
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
for (const cred of options.publicKey.allowCredentials ?? []) {
cred.id = decodeURLEncodedBase64(cred.id);
}
try {
const credential = await navigator.credentials.get({
publicKey: options.publicKey,
});
await verifyAssertion(credential);
} catch (err) {
if (!options.publicKey.extensions?.appid) {
webAuthnError('general', err.message);
return;
}
delete options.publicKey.extensions.appid;
try {
const credential = await navigator.credentials.get({
publicKey: options.publicKey,
});
await verifyAssertion(credential);
} catch (err) {
webAuthnError('general', err.message);
}
}
}
async function verifyAssertion(assertedCredential: any) { // TODO: Credential type does not work
// Move data into Arrays in case it is super long
const authData = new Uint8Array(assertedCredential.response.authenticatorData);
const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
const rawId = new Uint8Array(assertedCredential.rawId);
const sig = new Uint8Array(assertedCredential.response.signature);
const userHandle = new Uint8Array(assertedCredential.response.userHandle);
const res = await POST(`${appSubUrl}/user/webauthn/assertion`, {
data: {
id: assertedCredential.id,
rawId: encodeURLEncodedBase64(rawId),
type: assertedCredential.type,
clientExtensionResults: assertedCredential.getClientExtensionResults(),
response: {
authenticatorData: encodeURLEncodedBase64(authData),
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
signature: encodeURLEncodedBase64(sig),
userHandle: encodeURLEncodedBase64(userHandle),
},
},
});
if (res.status === 500) {
webAuthnError('unknown');
return;
} else if (!res.ok) {
webAuthnError('unable-to-process');
return;
}
const reply = await res.json();
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
}
async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work
const attestationObject = new Uint8Array(newCredential.response.attestationObject);
const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
const rawId = new Uint8Array(newCredential.rawId);
const res = await POST(`${appSubUrl}/user/settings/security/webauthn/register`, {
data: {
id: newCredential.id,
rawId: encodeURLEncodedBase64(rawId),
type: newCredential.type,
response: {
attestationObject: encodeURLEncodedBase64(attestationObject),
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
},
},
});
if (res.status === 409) {
webAuthnError('duplicated');
return;
} else if (res.status !== 201) {
webAuthnError('unknown');
return;
}
window.location.reload();
}
function webAuthnError(errorType: ErrorType, message:string = '') {
const elErrorMsg = document.querySelector(`#webauthn-error-msg`)!;
if (errorType === 'general') {
elErrorMsg.textContent = message || 'unknown error';
} else {
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`);
if (elTypedError) {
elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`;
} else {
elErrorMsg.textContent = `unknown error type: ${errorType}${message ? ` ${message}` : ''}`;
}
}
showElem('#webauthn-error');
}
/** Returns the error type or `null` when there was no error. */
function detectWebAuthnSupport(): ErrorType | null {
if (!window.isSecureContext) {
return 'insecure';
}
if (typeof window.PublicKeyCredential !== 'function') {
return 'browser';
}
return null;
}
export function initUserAuthWebAuthnRegister() {
const elRegister = document.querySelector<HTMLInputElement>('#register-webauthn');
if (!elRegister) return;
const errorType = detectWebAuthnSupport();
if (errorType) {
webAuthnError(errorType);
elRegister.disabled = true;
return;
}
elRegister.addEventListener('click', async (e) => {
e.preventDefault();
await webAuthnRegisterRequest();
});
}
async function webAuthnRegisterRequest() {
const elNickname = document.querySelector<HTMLInputElement>('#nickname')!;
const formData = new FormData();
formData.append('name', elNickname.value);
const res = await POST(`${appSubUrl}/user/settings/security/webauthn/request_register`, {
data: formData,
});
if (res.status === 409) {
webAuthnError('duplicated');
return;
} else if (!res.ok) {
webAuthnError('unknown');
return;
}
const options = await res.json();
elNickname.closest('div.field')!.classList.remove('error');
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
options.publicKey.user.id = decodeURLEncodedBase64(options.publicKey.user.id);
if (options.publicKey.excludeCredentials) {
for (const cred of options.publicKey.excludeCredentials) {
cred.id = decodeURLEncodedBase64(cred.id);
}
}
try {
const credential = await navigator.credentials.create({
publicKey: options.publicKey,
});
await webauthnRegistered(credential);
} catch (err) {
webAuthnError('unknown', err);
}
}