src/lib/passkey-name.ts
Rama main · réplica de lectura del repositorio original.
// What a browser is called, read from a User-Agent string, for the two places on
// the settings page that have to name one: the label a new passkey is filed
// under, and the device hint on a signed-in session's row.
//
// Only the client names a passkey: the server sees a User-Agent header it has no
// business parsing, and asking the diver to type a name before the biometric
// prompt puts a form in front of a one-tap gesture. So the ceremony names it
// "Chrome on macOS" and the settings card offers a rename afterwards, which is
// where a diver who wants "Work laptop" goes.
//
// A session row is the same question from the other end. There the API *does*
// store the raw header - an audit trail wants the string it actually saw - and
// hands it back on the diver's own rows, but the label is still derived here, so
// that one browser cannot be called two different things on one settings page.
// That is the whole reason both readers share these tables rather than the
// server growing a second, diverging copy of them.
//
// Deliberately a coarse read of the UA string rather than a UA-parsing
// dependency. What this is for is telling *this* passkey, or *this* device,
// apart from the two others on the account, so a wrong-but-plausible answer
// costs a rename and a missing one costs nothing - the fallback is a label, not
// an error.
// The API's own cap (`NAME_MAX_LENGTH` in its `webauthn_credential` schema). No
// suggestion here comes close, but a caller passing this to the rename field
// should have one number to check against.
export const PASSKEY_NAME_MAX_LENGTH = 50;
// The two fallbacks, which is the only thing the two readers do differently.
//
// A passkey falls back to the word for what it is, because that string becomes a
// *name* the diver can rename. A session cannot be renamed and is not a passkey,
// so "Passkey" there would be a plain falsehood on a row describing a curl
// client or a browser that trimmed its UA to nothing - hence a device-shaped
// answer that admits it does not know.
const FALLBACK_PASSKEY_NAME = "Passkey";
const FALLBACK_DEVICE_NAME = "Unknown device";
// Order matters in both tables: every Chromium browser says "Chrome", and Chrome
// on iOS says "Safari" as well, so the specific token has to be looked for
// before the generic one it also carries.
const BROWSERS: ReadonlyArray<[RegExp, string]> = [
[/\bEdg[A-Za-z]*\//, "Edge"],
[/\bOPR\/|\bOpera[\s/]/, "Opera"],
[/\bSamsungBrowser\//, "Samsung Internet"],
[/\bCriOS\//, "Chrome"],
[/\bFxiOS\//, "Firefox"],
[/\bChrome\//, "Chrome"],
[/\bFirefox\//, "Firefox"],
[/\bSafari\//, "Safari"],
];
const PLATFORMS: ReadonlyArray<[RegExp, string]> = [
[/\bCrOS\b/, "ChromeOS"],
[/\bAndroid\b/, "Android"],
[/\biPhone\b/, "iPhone"],
[/\biPad\b/, "iPad"],
[/\bWindows\b/, "Windows"],
// After the two iOS devices: an iPad in desktop mode reports itself as a Mac,
// and there is nothing in the UA string left to tell them apart.
[/\bMac OS X\b|\bMacintosh\b/, "macOS"],
[/\bLinux\b/, "Linux"],
];
function firstMatch(
table: ReadonlyArray<[RegExp, string]>,
userAgent: string,
): string | null {
for (const [pattern, label] of table) {
if (pattern.test(userAgent)) return label;
}
return null;
}
// The whole reading, with only the last resort left to the caller. Everything a
// UA string is actually recognized by is shared: the two exported wrappers
// differ on nothing but what to say when it is recognized by nothing at all.
function nameForUserAgent(userAgent: string, fallback: string): string {
const platform = firstMatch(PLATFORMS, userAgent);
const browser = firstMatch(BROWSERS, userAgent);
// On iOS every browser is WebKit wearing a different badge, so "Chrome on
// iPhone" and "Safari on iPhone" describe the same authenticator - the device
// is the whole answer.
if (platform === "iPhone" || platform === "iPad") return platform;
if (browser && platform) return `${browser} on ${platform}`;
return browser ?? platform ?? fallback;
}
/**
* A default name for a passkey being created, read from a User-Agent string -
* "Chrome on macOS", or just "iPhone" where the browser adds nothing (every
* browser on iOS is the same engine, so naming it would be a distinction without
* a difference).
*
* Takes the string rather than reading `navigator` so it can be tested against
* real UA strings; `suggestPasskeyName()` below is the call sites' version.
*/
export function passkeyNameForUserAgent(userAgent: string): string {
return nameForUserAgent(userAgent, FALLBACK_PASSKEY_NAME);
}
/**
* The device hint on a signed-in session's row, read from the raw User-Agent
* string the API stored when that session was created - the same reading as
* `passkeyNameForUserAgent`, differing only in what an unrecognized string
* becomes.
*
* The API sends the empty string where the client sent no header at all, which
* lands here as the fallback like any other unreadable value - a row that cannot
* be named is still a row that has to be revocable.
*/
export function deviceNameForUserAgent(userAgent: string): string {
return nameForUserAgent(userAgent, FALLBACK_DEVICE_NAME);
}
/**
* The default name for a passkey created in this browser, right now.
*
* Safe to call during a ceremony and nowhere else: it reads `navigator`, so it
* belongs in an event handler rather than in a render.
*/
export function suggestPasskeyName(): string {
return passkeyNameForUserAgent(
typeof navigator === "undefined" ? "" : navigator.userAgent,
);
}