@auth-link/web 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +120 -129
- package/dist/index.js +203 -658
- package/dist/index.js.map +4 -4
- package/dist/react.js +227 -584
- package/dist/react.js.map +4 -4
- package/dist/types/core.d.ts +4 -38
- package/dist/types/index.d.ts +3 -7
- package/dist/types/modal.d.ts +3 -19
- package/dist/types/react.d.ts +4 -15
- package/dist/types/types.d.ts +35 -59
- package/dist/types/view.d.ts +6 -10
- package/dist/types/vue.d.ts +5 -16
- package/dist/vue.js +231 -626
- package/dist/vue.js.map +4 -4
- package/docs/README.en.md +41 -44
- package/docs/README.ja.md +41 -44
- package/docs/README.zh-CN.md +41 -44
- package/examples/react-client.tsx +9 -21
- package/examples/vue-client.vue +9 -26
- package/package.json +2 -2
- package/dist/types/passcode.d.ts +0 -28
package/dist/index.js
CHANGED
|
@@ -1633,333 +1633,36 @@ var qrcode_default = qrcode;
|
|
|
1633
1633
|
var stringToBytes = qrcode.stringToBytes;
|
|
1634
1634
|
|
|
1635
1635
|
// src/core.ts
|
|
1636
|
-
var token = /^[A-Za-z0-9_-]{43}$/;
|
|
1637
1636
|
var AuthLinkError = class extends Error {
|
|
1637
|
+
code;
|
|
1638
1638
|
constructor(code) {
|
|
1639
1639
|
super(code);
|
|
1640
|
-
this.code = code;
|
|
1641
1640
|
this.name = "AuthLinkError";
|
|
1641
|
+
this.code = code;
|
|
1642
1642
|
}
|
|
1643
|
-
code;
|
|
1644
1643
|
};
|
|
1645
|
-
function
|
|
1646
|
-
|
|
1644
|
+
function deadline(value) {
|
|
1645
|
+
const at = typeof value === "string" ? Date.parse(value) : NaN;
|
|
1646
|
+
if (!Number.isFinite(at) || at <= Date.now()) throw new AuthLinkError("INVALID_EXPIRY");
|
|
1647
|
+
return at;
|
|
1647
1648
|
}
|
|
1648
|
-
function
|
|
1649
|
-
if (
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
function validateDisplayCode(value) {
|
|
1653
|
-
const m = object(value);
|
|
1654
|
-
const span = Date.parse(String(m.validUntil)) - Date.parse(String(m.serverTime));
|
|
1655
|
-
if (typeof m.value !== "string" || !/^\d{6}$/.test(m.value) || m.periodSeconds !== 30 || !Number.isFinite(span) || span <= 0 || span > 3e4 || "seed" in m || "secret" in m || "key" in m) fail("INVALID_DISPLAY_CODE");
|
|
1656
|
-
return { value: m.value, serverTime: String(m.serverTime), validUntil: String(m.validUntil), periodSeconds: 30 };
|
|
1657
|
-
}
|
|
1658
|
-
function validateTicket(value, expectedMode, trustedOrigin) {
|
|
1659
|
-
const m = object(value);
|
|
1660
|
-
if ("pairing" in m || "seed" in m || "apiKey" in m) fail("SERVER_SECRET_FORBIDDEN");
|
|
1661
|
-
if (typeof m.requestId !== "string" || !token.test(m.requestId) || !["bind", "confirm"].includes(String(m.purpose)) || expectedMode && m.purpose !== expectedMode || typeof m.qr !== "string" || m.qr.length > 4096 || typeof m.pairingCode !== "string" || !/^\d{6}$/.test(m.pairingCode)) fail("INVALID_TICKET");
|
|
1662
|
-
let qr;
|
|
1663
|
-
try {
|
|
1664
|
-
qr = object(JSON.parse(m.qr));
|
|
1665
|
-
} catch {
|
|
1666
|
-
return fail("INVALID_QR");
|
|
1667
|
-
}
|
|
1668
|
-
let origin;
|
|
1669
|
-
try {
|
|
1670
|
-
origin = new URL(String(qr.origin));
|
|
1671
|
-
} catch {
|
|
1672
|
-
return fail("INVALID_QR");
|
|
1673
|
-
}
|
|
1674
|
-
if (qr.v !== 1 || qr.requestId !== m.requestId || typeof qr.token !== "string" || !token.test(qr.token) || origin.protocol !== "https:" || origin.origin !== qr.origin || origin.origin.length > 512 || origin.username || origin.password || trustedOrigin && origin.origin !== trustedOrigin) fail("UNTRUSTED_QR");
|
|
1675
|
-
const displayCode = m.displayCode === void 0 ? void 0 : validateDisplayCode(m.displayCode);
|
|
1676
|
-
const expiry = Date.parse(String(m.expiresAt));
|
|
1677
|
-
const now = displayCode ? Date.parse(displayCode.serverTime) : Date.now();
|
|
1678
|
-
if (!Number.isFinite(expiry) || expiry > now + 36e4) fail("INVALID_EXPIRY");
|
|
1679
|
-
return {
|
|
1680
|
-
requestId: m.requestId,
|
|
1681
|
-
purpose: m.purpose,
|
|
1682
|
-
qr: m.qr,
|
|
1683
|
-
pairingCode: m.pairingCode,
|
|
1684
|
-
expiresAt: String(m.expiresAt),
|
|
1685
|
-
...displayCode ? { displayCode } : {}
|
|
1686
|
-
};
|
|
1687
|
-
}
|
|
1688
|
-
function qrImage(ticket) {
|
|
1689
|
-
const value = validateTicket(ticket);
|
|
1690
|
-
const payload = JSON.parse(value.qr);
|
|
1691
|
-
const qr = qrcode_default(0, "M");
|
|
1692
|
-
qr.addData(JSON.stringify({ v: 1, origin: payload.origin, requestId: payload.requestId, token: payload.token }), "Byte");
|
|
1693
|
-
qr.make();
|
|
1694
|
-
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.createSvgTag({ cellSize: 4, margin: 16, scalable: true }))}`;
|
|
1695
|
-
}
|
|
1696
|
-
function backendProtocol(url) {
|
|
1697
|
-
return url.protocol === "https:" || url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
1698
|
-
}
|
|
1699
|
-
async function limitedJson(response) {
|
|
1700
|
-
const reader = response.body?.getReader();
|
|
1701
|
-
if (!reader) fail("INVALID_RESPONSE");
|
|
1702
|
-
const chunks = [];
|
|
1703
|
-
let size = 0;
|
|
1704
|
-
try {
|
|
1705
|
-
for (; ; ) {
|
|
1706
|
-
const { done, value } = await reader.read();
|
|
1707
|
-
if (done) break;
|
|
1708
|
-
size += value.length;
|
|
1709
|
-
if (size > 65536) {
|
|
1710
|
-
await reader.cancel();
|
|
1711
|
-
fail("RESPONSE_TOO_LARGE");
|
|
1712
|
-
}
|
|
1713
|
-
chunks.push(value);
|
|
1714
|
-
}
|
|
1715
|
-
} finally {
|
|
1716
|
-
reader.releaseLock();
|
|
1717
|
-
}
|
|
1718
|
-
const bytes = new Uint8Array(size);
|
|
1719
|
-
let at = 0;
|
|
1720
|
-
for (const chunk of chunks) {
|
|
1721
|
-
bytes.set(chunk, at);
|
|
1722
|
-
at += chunk.length;
|
|
1723
|
-
}
|
|
1724
|
-
try {
|
|
1725
|
-
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1726
|
-
} catch {
|
|
1727
|
-
return fail("INVALID_RESPONSE");
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
function createBackendCall(options) {
|
|
1731
|
-
if ("apiKey" in options || "secret" in options) fail("SERVER_SECRET_FORBIDDEN");
|
|
1732
|
-
const base = options.baseUrl ?? "/api/authlink";
|
|
1733
|
-
const allowedOrigins = new Set(options.allowedBackendOrigins ?? []);
|
|
1734
|
-
for (const origin of allowedOrigins) {
|
|
1735
|
-
let url;
|
|
1736
|
-
try {
|
|
1737
|
-
url = new URL(origin);
|
|
1738
|
-
} catch {
|
|
1739
|
-
return fail("INVALID_BACKEND_ORIGIN");
|
|
1740
|
-
}
|
|
1741
|
-
if (url.origin !== origin || url.hostname.includes("*") || !backendProtocol(url)) fail("INVALID_BACKEND_ORIGIN");
|
|
1742
|
-
}
|
|
1743
|
-
const credentials = options.credentials ?? (options.accessToken ? "omit" : "same-origin");
|
|
1744
|
-
if (!["omit", "same-origin", "include"].includes(credentials)) fail("INVALID_CREDENTIALS");
|
|
1745
|
-
const csrfHeaderName = options.csrfHeaderName ?? "X-CSRF-Token";
|
|
1746
|
-
const reserved = /* @__PURE__ */ new Set(["accept", "content-type", "authorization", "idempotency-key"]);
|
|
1649
|
+
function bindingImage(ticket, expectedOrigin) {
|
|
1650
|
+
if (typeof ticket.qr !== "string" || ticket.qr.length > 4096) throw new AuthLinkError("INVALID_QR");
|
|
1651
|
+
if (deadline(ticket.expiresAt) > Date.now() + 36e5) throw new AuthLinkError("INVALID_EXPIRY");
|
|
1652
|
+
let payload, origin;
|
|
1747
1653
|
try {
|
|
1748
|
-
|
|
1654
|
+
payload = JSON.parse(ticket.qr);
|
|
1655
|
+
origin = new URL(String(payload.origin));
|
|
1749
1656
|
} catch {
|
|
1750
|
-
|
|
1751
|
-
}
|
|
1752
|
-
if (reserved.has(csrfHeaderName.toLowerCase())) fail("INVALID_CSRF_HEADER");
|
|
1753
|
-
reserved.add(csrfHeaderName.toLowerCase());
|
|
1754
|
-
const call = async (path, body, signal, key) => {
|
|
1755
|
-
if (typeof window === "undefined") fail("BROWSER_REQUIRED");
|
|
1756
|
-
let url;
|
|
1757
|
-
try {
|
|
1758
|
-
url = new URL(base.replace(/\/+$/, "") + path, window.location.href);
|
|
1759
|
-
} catch {
|
|
1760
|
-
return fail("INVALID_BASE_URL");
|
|
1761
|
-
}
|
|
1762
|
-
if (url.username || url.password || url.search || url.hash || !backendProtocol(url)) fail("INVALID_BASE_URL");
|
|
1763
|
-
if (url.origin !== window.location.origin && !allowedOrigins.has(url.origin)) fail("BACKEND_ORIGIN_NOT_ALLOWED");
|
|
1764
|
-
signal?.throwIfAborted();
|
|
1765
|
-
const controller = new AbortController();
|
|
1766
|
-
const cancel = () => controller.abort(signal?.reason);
|
|
1767
|
-
signal?.addEventListener("abort", cancel, { once: true });
|
|
1768
|
-
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
1769
|
-
try {
|
|
1770
|
-
const headers = new Headers(typeof options.headers === "function" ? await abortable(Promise.resolve().then(options.headers), controller.signal) : options.headers);
|
|
1771
|
-
for (const name of reserved) if (headers.has(name)) fail("RESERVED_HEADER");
|
|
1772
|
-
headers.set("Accept", "application/json");
|
|
1773
|
-
headers.set("Content-Type", "application/json");
|
|
1774
|
-
if (key) headers.set("Idempotency-Key", key);
|
|
1775
|
-
if (options.csrfToken) {
|
|
1776
|
-
const csrf = await abortable(Promise.resolve().then(options.csrfToken), controller.signal);
|
|
1777
|
-
if (typeof csrf !== "string" || !csrf.trim() || csrf.length > 1024 || /[\r\n]/.test(csrf)) fail("CSRF_REQUIRED");
|
|
1778
|
-
headers.set(csrfHeaderName, csrf);
|
|
1779
|
-
} else if (!options.accessToken || credentials !== "omit") fail("CSRF_REQUIRED");
|
|
1780
|
-
if (options.accessToken) {
|
|
1781
|
-
const accessToken = await abortable(Promise.resolve().then(options.accessToken), controller.signal);
|
|
1782
|
-
if (typeof accessToken !== "string" || !accessToken || /\s/.test(accessToken)) fail("ACCESS_TOKEN_REQUIRED");
|
|
1783
|
-
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
1784
|
-
}
|
|
1785
|
-
controller.signal.throwIfAborted();
|
|
1786
|
-
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
1787
|
-
method: "POST",
|
|
1788
|
-
credentials,
|
|
1789
|
-
redirect: "error",
|
|
1790
|
-
cache: "no-store",
|
|
1791
|
-
signal: controller.signal,
|
|
1792
|
-
headers,
|
|
1793
|
-
body: JSON.stringify(body)
|
|
1794
|
-
});
|
|
1795
|
-
if (!response.ok) {
|
|
1796
|
-
let code = "";
|
|
1797
|
-
try {
|
|
1798
|
-
code = String(object(await limitedJson(response)).code ?? "").replace(/^AUTHLINK_/, "");
|
|
1799
|
-
} catch {
|
|
1800
|
-
}
|
|
1801
|
-
if (["CONFIRMATION_CODE_INVALID", "CONFIRMATION_CODE_LOCKED", "APPROVAL_REQUIRED", "REQUEST_EXPIRED", "PASSCODE_INVALID", "PASSCODE_USED", "PASSCODE_LOCKED", "OTP_NOT_CONFIGURED"].includes(code)) fail(code);
|
|
1802
|
-
fail(response.status === 401 || response.status === 403 ? "CMS_UNAUTHORIZED" : "CMS_REQUEST_FAILED");
|
|
1803
|
-
}
|
|
1804
|
-
return await limitedJson(response);
|
|
1805
|
-
} catch (error) {
|
|
1806
|
-
if (error instanceof AuthLinkError) throw error;
|
|
1807
|
-
if (signal?.aborted) throw signal.reason;
|
|
1808
|
-
return fail("NETWORK_ERROR");
|
|
1809
|
-
} finally {
|
|
1810
|
-
clearTimeout(timeout);
|
|
1811
|
-
signal?.removeEventListener("abort", cancel);
|
|
1812
|
-
}
|
|
1813
|
-
};
|
|
1814
|
-
return call;
|
|
1815
|
-
}
|
|
1816
|
-
function createAuthLinkClient(options) {
|
|
1817
|
-
const call = createBackendCall(options);
|
|
1818
|
-
return {
|
|
1819
|
-
async create(mode, { idempotencyKey, signal }) {
|
|
1820
|
-
if (!["bind", "confirm"].includes(mode) || !token.test(idempotencyKey)) fail("INVALID_CREATE_OPTIONS");
|
|
1821
|
-
return validateTicket(await call("/requests", { purpose: mode }, signal, idempotencyKey), mode, options.authLinkOrigin);
|
|
1822
|
-
},
|
|
1823
|
-
async result(ticket, signal, confirmationCode) {
|
|
1824
|
-
if (!token.test(ticket.requestId)) fail("INVALID_TICKET");
|
|
1825
|
-
if (typeof confirmationCode !== "string" || !/^[0-9]{6}$/.test(confirmationCode)) fail("CONFIRMATION_CODE_REQUIRED");
|
|
1826
|
-
return await call(`/requests/${ticket.requestId}/result`, { confirmationCode }, signal);
|
|
1827
|
-
}
|
|
1828
|
-
};
|
|
1829
|
-
}
|
|
1830
|
-
function requestKey() {
|
|
1831
|
-
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
1832
|
-
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1833
|
-
}
|
|
1834
|
-
function initialSnapshot() {
|
|
1835
|
-
return { status: "idle", code: null, secondsRemaining: 0, codeSecondsRemaining: 0, error: null };
|
|
1836
|
-
}
|
|
1837
|
-
function abortable(promise, signal) {
|
|
1838
|
-
signal.throwIfAborted();
|
|
1839
|
-
let cancel;
|
|
1840
|
-
const abort = new Promise((_, reject) => {
|
|
1841
|
-
cancel = () => reject(signal.reason);
|
|
1842
|
-
signal.addEventListener("abort", cancel, { once: true });
|
|
1843
|
-
});
|
|
1844
|
-
return Promise.race([promise, abort]).finally(() => signal.removeEventListener("abort", cancel));
|
|
1845
|
-
}
|
|
1846
|
-
async function observeRequest(input, options) {
|
|
1847
|
-
const { client, signal, onUpdate } = options;
|
|
1848
|
-
let ticket;
|
|
1849
|
-
try {
|
|
1850
|
-
ticket = validateTicket(input, options.expectedMode);
|
|
1851
|
-
} catch (error) {
|
|
1852
|
-
if (!signal.aborted) options.onError?.(error);
|
|
1853
|
-
return;
|
|
1854
|
-
}
|
|
1855
|
-
const expiry = Date.parse(ticket.expiresAt);
|
|
1856
|
-
const deadline = performance.now() + Math.max(0, expiry - Date.now());
|
|
1857
|
-
let ended = false, busy = false;
|
|
1858
|
-
let done;
|
|
1859
|
-
const finished = new Promise((resolve) => {
|
|
1860
|
-
done = resolve;
|
|
1861
|
-
});
|
|
1862
|
-
const controller = new AbortController();
|
|
1863
|
-
const cancel = () => {
|
|
1864
|
-
ended = true;
|
|
1865
|
-
controller.abort();
|
|
1866
|
-
done();
|
|
1867
|
-
};
|
|
1868
|
-
signal.addEventListener("abort", cancel, { once: true });
|
|
1869
|
-
let snapshot = initialSnapshot();
|
|
1870
|
-
const remaining = () => Math.max(0, (deadline - performance.now()) / 1e3);
|
|
1871
|
-
const tick = () => {
|
|
1872
|
-
if (ended || signal.aborted) return;
|
|
1873
|
-
if (remaining() <= 0) {
|
|
1874
|
-
ended = true;
|
|
1875
|
-
controller.abort();
|
|
1876
|
-
onUpdate({ ...snapshot, status: "expired", code: null, submitting: false });
|
|
1877
|
-
void Promise.resolve().then(() => options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
|
|
1878
|
-
done();
|
|
1879
|
-
return;
|
|
1880
|
-
}
|
|
1881
|
-
snapshot = {
|
|
1882
|
-
...snapshot,
|
|
1883
|
-
status: "pending",
|
|
1884
|
-
code: ticket.pairingCode,
|
|
1885
|
-
secondsRemaining: Math.ceil(remaining()),
|
|
1886
|
-
codeSecondsRemaining: Math.ceil(remaining()),
|
|
1887
|
-
submitting: busy
|
|
1888
|
-
};
|
|
1889
|
-
onUpdate(snapshot);
|
|
1890
|
-
};
|
|
1891
|
-
const submit = async (confirmationCode) => {
|
|
1892
|
-
if (ended || signal.aborted || busy) return;
|
|
1893
|
-
if (!/^[0-9]{6}$/.test(confirmationCode)) {
|
|
1894
|
-
snapshot = { ...snapshot, error: new AuthLinkError("CONFIRMATION_CODE_REQUIRED") };
|
|
1895
|
-
tick();
|
|
1896
|
-
return;
|
|
1897
|
-
}
|
|
1898
|
-
if (remaining() <= 0) {
|
|
1899
|
-
tick();
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
busy = true;
|
|
1903
|
-
snapshot = { ...snapshot, error: null };
|
|
1904
|
-
tick();
|
|
1905
|
-
try {
|
|
1906
|
-
const value = object(await abortable(client.result(ticket, controller.signal, confirmationCode), controller.signal));
|
|
1907
|
-
if (ended || signal.aborted) return;
|
|
1908
|
-
if (value.requestId !== ticket.requestId) fail("REQUEST_MISMATCH");
|
|
1909
|
-
if (value.status === "confirmed") {
|
|
1910
|
-
if (!token.test(String(value.verificationId)) || value.authenticationPolicy !== "device-presence.v1" || value.expiresAt !== ticket.expiresAt || value.purpose !== ticket.purpose || remaining() <= 0 || "action" in value || "transactionHash" in value) fail("INVALID_CONFIRMATION");
|
|
1911
|
-
ended = true;
|
|
1912
|
-
onUpdate({ ...snapshot, status: "confirmed", code: null, submitting: false });
|
|
1913
|
-
try {
|
|
1914
|
-
await options.onConfirmed?.(value);
|
|
1915
|
-
} catch {
|
|
1916
|
-
options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
|
|
1917
|
-
}
|
|
1918
|
-
done();
|
|
1919
|
-
} else if (value.status === "rejected" || value.status === "expired") {
|
|
1920
|
-
ended = true;
|
|
1921
|
-
onUpdate({ ...snapshot, status: value.status, code: null, submitting: false });
|
|
1922
|
-
try {
|
|
1923
|
-
if (value.status === "rejected") await options.onRejected?.({ status: "rejected", code: "REJECTED", requestId: ticket.requestId, purpose: ticket.purpose });
|
|
1924
|
-
else await options.onExpired?.();
|
|
1925
|
-
} catch {
|
|
1926
|
-
options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
|
|
1927
|
-
} finally {
|
|
1928
|
-
done();
|
|
1929
|
-
}
|
|
1930
|
-
} else {
|
|
1931
|
-
fail("CONFIRMATION_NOT_READY");
|
|
1932
|
-
}
|
|
1933
|
-
} catch (error) {
|
|
1934
|
-
if (!ended && !signal.aborted) {
|
|
1935
|
-
const raw = error instanceof Error ? error : new AuthLinkError("NETWORK_ERROR");
|
|
1936
|
-
const code = raw.message.replace(/^AUTHLINK_/, "");
|
|
1937
|
-
const failure = ["CONFIRMATION_CODE_LOCKED", "CONFIRMATION_CODE_INVALID", "APPROVAL_REQUIRED", "REQUEST_EXPIRED"].includes(code) ? new AuthLinkError(code) : raw;
|
|
1938
|
-
snapshot = { ...snapshot, error: failure };
|
|
1939
|
-
if (code === "CONFIRMATION_CODE_LOCKED" || code === "REQUEST_EXPIRED") {
|
|
1940
|
-
ended = true;
|
|
1941
|
-
onUpdate({ ...snapshot, code: null, submitting: false, status: code === "REQUEST_EXPIRED" ? "expired" : "error" });
|
|
1942
|
-
if (code === "REQUEST_EXPIRED") void Promise.resolve().then(() => options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
|
|
1943
|
-
else options.onError?.(failure);
|
|
1944
|
-
done();
|
|
1945
|
-
} else options.onError?.(failure);
|
|
1946
|
-
}
|
|
1947
|
-
} finally {
|
|
1948
|
-
busy = false;
|
|
1949
|
-
tick();
|
|
1950
|
-
}
|
|
1951
|
-
};
|
|
1952
|
-
tick();
|
|
1953
|
-
options.onReady?.(submit);
|
|
1954
|
-
const timer = setInterval(tick, 1e3);
|
|
1955
|
-
if (signal.aborted) cancel();
|
|
1956
|
-
try {
|
|
1957
|
-
await finished;
|
|
1958
|
-
} finally {
|
|
1959
|
-
clearInterval(timer);
|
|
1960
|
-
controller.abort();
|
|
1961
|
-
signal.removeEventListener("abort", cancel);
|
|
1657
|
+
throw new AuthLinkError("INVALID_QR");
|
|
1962
1658
|
}
|
|
1659
|
+
const token = /^[A-Za-z0-9_-]{43}$/;
|
|
1660
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname);
|
|
1661
|
+
if (payload.v !== 1 || typeof payload.requestId !== "string" || !token.test(payload.requestId) || typeof payload.token !== "string" || !token.test(payload.token) || origin.origin !== payload.origin || origin.protocol !== "https:" && !(origin.protocol === "http:" && loopback) || expectedOrigin !== void 0 && expectedOrigin !== origin.origin) throw new AuthLinkError("INVALID_QR");
|
|
1662
|
+
const image = qrcode_default(0, "M");
|
|
1663
|
+
image.addData(JSON.stringify({ v: 1, origin: origin.origin, requestId: payload.requestId, token: payload.token }), "Byte");
|
|
1664
|
+
image.make();
|
|
1665
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(image.createSvgTag({ cellSize: 4, margin: 16, scalable: true }))}`;
|
|
1963
1666
|
}
|
|
1964
1667
|
|
|
1965
1668
|
// src/locales/en.json
|
|
@@ -2392,26 +2095,20 @@ function getMessages(locale) {
|
|
|
2392
2095
|
|
|
2393
2096
|
// src/view.ts
|
|
2394
2097
|
var node = (tag, props, ...children) => ({ tag, props, children });
|
|
2098
|
+
var digits = (value) => value.replace(/[^0-9]/g, "");
|
|
2395
2099
|
function widgetView(options) {
|
|
2396
|
-
const { snapshot: s, mode,
|
|
2397
|
-
const
|
|
2398
|
-
const
|
|
2399
|
-
const
|
|
2400
|
-
const
|
|
2401
|
-
const
|
|
2402
|
-
const expiryTime = `00:${String(Math.max(0, Math.ceil(s.secondsRemaining))).padStart(2, "0")}`;
|
|
2403
|
-
const statusNode = () => node(
|
|
2404
|
-
"p",
|
|
2405
|
-
{
|
|
2406
|
-
class: "authlink-status",
|
|
2407
|
-
role: s.status === "error" ? "alert" : "status",
|
|
2408
|
-
"data-expiring": String(expiring)
|
|
2409
|
-
},
|
|
2410
|
-
node("span", { class: "authlink-status-dot", "aria-hidden": "true" }),
|
|
2411
|
-
expiring ? l.expiring : status,
|
|
2412
|
-
...expiring ? [node("span", { class: "authlink-expiry", "aria-hidden": "true" }, ` \xB7 ${expiryTime}`)] : []
|
|
2413
|
-
);
|
|
2100
|
+
const { snapshot: s, mode } = options, locale = resolveLocale(options.locale), l = getMessages(locale);
|
|
2101
|
+
const pending = s.status === "pending", hasQr = pending && mode === "bind" && Boolean(options.qr);
|
|
2102
|
+
const expiring = pending && s.secondsRemaining !== void 0 && s.secondsRemaining <= 30;
|
|
2103
|
+
const status = s.status === "pending" ? l.otpWaiting : l[s.status];
|
|
2104
|
+
const remaining = s.secondsRemaining ?? 0;
|
|
2105
|
+
const expiryTime = `${String(Math.floor(remaining / 60)).padStart(2, "0")}:${String(remaining % 60).padStart(2, "0")}`;
|
|
2414
2106
|
const children = [node("h3", { class: "authlink-title" }, l[mode])];
|
|
2107
|
+
const errorNode = s.error ? node(
|
|
2108
|
+
"p",
|
|
2109
|
+
{ class: "authlink-input-error", role: "alert" },
|
|
2110
|
+
["PASSCODE_LOCKED", "RATE_LIMITED", "AUTHLINK_PASSCODE_LOCKED"].includes(s.error.message) ? l.codeLocked : l.otpError
|
|
2111
|
+
) : void 0;
|
|
2415
2112
|
if (hasQr) children.push(node("p", { class: "authlink-subtitle" }, l.scan));
|
|
2416
2113
|
if (pending) {
|
|
2417
2114
|
const qr = hasQr ? [node(
|
|
@@ -2421,18 +2118,18 @@ function widgetView(options) {
|
|
|
2421
2118
|
node("figcaption", { class: "authlink-qr-caption" }, l.scan)
|
|
2422
2119
|
)] : [];
|
|
2423
2120
|
const detail = [
|
|
2424
|
-
node("p", { class: "authlink-help" },
|
|
2121
|
+
node("p", { class: "authlink-help" }, l.otpHelp),
|
|
2425
2122
|
node(
|
|
2426
2123
|
"form",
|
|
2427
2124
|
{ class: "authlink-code-form", onSubmit: (event) => {
|
|
2428
2125
|
event.preventDefault();
|
|
2429
2126
|
const input = event.currentTarget.querySelector("input");
|
|
2430
|
-
if (input) void options.submitCode
|
|
2127
|
+
if (input) void options.submitCode(input.value);
|
|
2431
2128
|
} },
|
|
2432
|
-
node("label", { for: "authlink-
|
|
2129
|
+
node("label", { for: "authlink-passcode" }, l.otpCode),
|
|
2433
2130
|
node("input", {
|
|
2434
|
-
id: "authlink-
|
|
2435
|
-
name:
|
|
2131
|
+
id: "authlink-passcode",
|
|
2132
|
+
name: "passcode",
|
|
2436
2133
|
type: "text",
|
|
2437
2134
|
inputmode: "numeric",
|
|
2438
2135
|
autocomplete: "one-time-code",
|
|
@@ -2440,273 +2137,74 @@ function widgetView(options) {
|
|
|
2440
2137
|
maxlength: 6,
|
|
2441
2138
|
minlength: 6,
|
|
2442
2139
|
required: true,
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2140
|
+
...s.submitting ? { disabled: true } : {},
|
|
2141
|
+
"data-authlink-focus": "passcode",
|
|
2142
|
+
value: options.inputCode,
|
|
2143
|
+
onInput: (event) => {
|
|
2144
|
+
const input = event.target, original = input.value;
|
|
2145
|
+
const start = input.selectionStart ?? original.length, end = input.selectionEnd ?? start;
|
|
2146
|
+
const code = digits(original).slice(0, 6);
|
|
2147
|
+
if (original !== code) {
|
|
2148
|
+
input.value = code;
|
|
2149
|
+
input.setSelectionRange(Math.min(6, digits(original.slice(0, start)).length), Math.min(6, digits(original.slice(0, end)).length));
|
|
2150
|
+
}
|
|
2151
|
+
options.setCode(code);
|
|
2152
|
+
},
|
|
2153
|
+
onPaste: (event) => {
|
|
2154
|
+
if (!event.clipboardData) return;
|
|
2155
|
+
event.preventDefault();
|
|
2156
|
+
const input = event.target, pasted = digits(event.clipboardData.getData("text"));
|
|
2157
|
+
if (!pasted) return;
|
|
2158
|
+
const start = input.selectionStart ?? input.value.length, end = input.selectionEnd ?? start;
|
|
2159
|
+
const before = input.value.slice(0, start), after = input.value.slice(end);
|
|
2160
|
+
const insert = pasted.slice(0, Math.max(0, 6 - before.length - after.length));
|
|
2161
|
+
input.value = before + insert + after;
|
|
2162
|
+
input.setSelectionRange(before.length + insert.length, before.length + insert.length);
|
|
2163
|
+
options.setCode(input.value);
|
|
2164
|
+
}
|
|
2446
2165
|
}),
|
|
2447
|
-
node(
|
|
2448
|
-
|
|
2449
|
-
{ type: "submit", class: "authlink-button", ...s.submitting ? { disabled: true } : {} },
|
|
2450
|
-
s.submitting ? l.loading : l.submitCode
|
|
2451
|
-
)
|
|
2452
|
-
),
|
|
2453
|
-
...s.error ? [node("p", { class: "authlink-input-error", role: "alert" }, options.passcodeMode ? s.error.message === "OTP_NOT_CONFIGURED" ? l.otpSetup : l.otpError : l.codeError)] : []
|
|
2166
|
+
node("button", { type: "submit", class: "authlink-button", ...s.submitting ? { disabled: true } : {} }, s.submitting ? l.loading : l.submitCode)
|
|
2167
|
+
)
|
|
2454
2168
|
];
|
|
2455
|
-
children.push(node("div", { class: "authlink-body" }, ...qr, node("div", { class: "authlink-details" }, ...detail)));
|
|
2456
|
-
children.push(node("div", { class: "authlink-footer" }, statusNode()));
|
|
2457
|
-
} else {
|
|
2458
|
-
children.push(statusNode());
|
|
2459
|
-
if (s.error?.message === "CONFIRMATION_CODE_LOCKED") children.push(node("p", { class: "authlink-input-error", role: "alert" }, l.codeLocked));
|
|
2460
|
-
if (s.status === "error" && s.error?.message !== "CONFIRMATION_CODE_LOCKED") children.push(node("button", {
|
|
2461
|
-
type: "button",
|
|
2462
|
-
class: "authlink-button",
|
|
2463
|
-
"data-authlink-focus": "retry",
|
|
2464
|
-
onClick: retry
|
|
2465
|
-
}, l.retry));
|
|
2466
|
-
if (refresh && ["expired", "rejected", "error"].includes(s.status)) children.push(node("button", {
|
|
2467
|
-
type: "button",
|
|
2468
|
-
class: "authlink-button",
|
|
2469
|
-
"data-authlink-focus": "refresh",
|
|
2470
|
-
onClick: refresh
|
|
2471
|
-
}, l.refresh));
|
|
2169
|
+
children.push(node("div", { class: "authlink-body" }, ...qr, node("div", { class: "authlink-details" }, ...detail, ...errorNode ? [errorNode] : [])));
|
|
2472
2170
|
}
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
"
|
|
2477
|
-
"
|
|
2478
|
-
|
|
2479
|
-
|
|
2171
|
+
if (!pending && errorNode) children.push(errorNode);
|
|
2172
|
+
children.push(node("div", { class: "authlink-footer" }, node(
|
|
2173
|
+
"p",
|
|
2174
|
+
{ class: "authlink-status", role: "status", "data-expiring": String(expiring) },
|
|
2175
|
+
node("span", { class: "authlink-status-dot", "aria-hidden": "true" }),
|
|
2176
|
+
expiring ? l.expiring : status,
|
|
2177
|
+
...expiring ? [node("span", { class: "authlink-expiry", "aria-hidden": "true" }, ` \xB7 ${expiryTime}`)] : []
|
|
2178
|
+
)));
|
|
2179
|
+
if (s.status === "expired" && options.refresh) children.push(node("button", {
|
|
2180
|
+
type: "button",
|
|
2181
|
+
class: "authlink-button",
|
|
2182
|
+
...s.submitting ? { disabled: true } : {},
|
|
2183
|
+
onClick: options.refresh
|
|
2184
|
+
}, s.submitting ? l.loading : l.refresh));
|
|
2185
|
+
return node("section", { lang: locale, class: "authlink-widget", "data-status": s.status, "data-has-qr": String(hasQr), "aria-label": l[mode] }, ...children);
|
|
2480
2186
|
}
|
|
2481
2187
|
|
|
2482
2188
|
// src/modal.ts
|
|
2483
|
-
function element(value
|
|
2189
|
+
function element(value) {
|
|
2484
2190
|
if (typeof value === "string") return document.createTextNode(value);
|
|
2485
|
-
|
|
2486
|
-
const result = svg ? document.createElementNS("http://www.w3.org/2000/svg", value.tag) : document.createElement(value.tag);
|
|
2191
|
+
const result = document.createElement(value.tag);
|
|
2487
2192
|
for (const [key, v] of Object.entries(value.props)) {
|
|
2488
2193
|
if (key.startsWith("on") && typeof v === "function") result.addEventListener(key.slice(2).toLowerCase(), v);
|
|
2489
2194
|
else if (v !== void 0 && v !== null) result.setAttribute(key, String(v));
|
|
2490
2195
|
}
|
|
2491
|
-
result.append(...value.children.map(
|
|
2196
|
+
result.append(...value.children.map(element));
|
|
2492
2197
|
return result;
|
|
2493
2198
|
}
|
|
2494
|
-
function
|
|
2199
|
+
function open(options, mode) {
|
|
2495
2200
|
if (typeof document === "undefined" || !document.body) throw new AuthLinkError("BROWSER_REQUIRED");
|
|
2496
|
-
|
|
2497
|
-
const
|
|
2201
|
+
if (typeof options.submit !== "function") throw new AuthLinkError("SUBMIT_REQUIRED");
|
|
2202
|
+
const binding = mode === "bind" ? options : void 0;
|
|
2203
|
+
let image = binding ? bindingImage(binding, binding.authLinkOrigin) : void 0;
|
|
2204
|
+
let expires = options.expiresAt === void 0 ? void 0 : deadline(options.expiresAt);
|
|
2498
2205
|
const dialog = document.createElement("dialog");
|
|
2499
2206
|
if (typeof dialog.showModal !== "function") throw new AuthLinkError("DIALOG_UNSUPPORTED");
|
|
2500
2207
|
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
2501
|
-
dialog.className = `authlink-dialog ${options.className ?? ""}`.trim();
|
|
2502
|
-
dialog.setAttribute("aria-modal", "true");
|
|
2503
|
-
const header = document.createElement("div");
|
|
2504
|
-
header.className = "authlink-header";
|
|
2505
|
-
const brand = document.createElement("span");
|
|
2506
|
-
brand.className = "authlink-brand";
|
|
2507
|
-
brand.textContent = "AuthLink";
|
|
2508
|
-
brand.tabIndex = -1;
|
|
2509
|
-
const closeButton = document.createElement("button");
|
|
2510
|
-
closeButton.type = "button";
|
|
2511
|
-
closeButton.className = "authlink-close";
|
|
2512
|
-
const closeIcon = document.createElement("span");
|
|
2513
|
-
closeIcon.className = "authlink-close-icon";
|
|
2514
|
-
closeIcon.setAttribute("aria-hidden", "true");
|
|
2515
|
-
closeButton.append(closeIcon);
|
|
2516
|
-
const content = document.createElement("div");
|
|
2517
|
-
content.className = "authlink-modal-content";
|
|
2518
|
-
header.append(brand, closeButton);
|
|
2519
|
-
dialog.append(header, content);
|
|
2520
|
-
let locale = options.locale, snapshot = initialSnapshot(), closed = false;
|
|
2521
|
-
let abort;
|
|
2522
|
-
let inputCode = "";
|
|
2523
|
-
let submitCode;
|
|
2524
|
-
const image = qrImage(ticket);
|
|
2525
|
-
let progressFill, progressFrame, progressDeadline = 0;
|
|
2526
|
-
const motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
|
2527
|
-
const stopProgress = () => {
|
|
2528
|
-
if (progressFrame !== void 0) window.cancelAnimationFrame(progressFrame);
|
|
2529
|
-
progressFrame = void 0;
|
|
2530
|
-
};
|
|
2531
|
-
const drawProgress = () => {
|
|
2532
|
-
stopProgress();
|
|
2533
|
-
if (closed || !progressFill?.isConnected) return;
|
|
2534
|
-
const remaining = Math.max(0, progressDeadline - performance.now());
|
|
2535
|
-
progressFill.style.transform = `scaleX(${Math.min(1, remaining / 3e4)})`;
|
|
2536
|
-
if (remaining > 0 && !motion?.matches && typeof window.requestAnimationFrame === "function") {
|
|
2537
|
-
progressFrame = window.requestAnimationFrame(drawProgress);
|
|
2538
|
-
}
|
|
2539
|
-
};
|
|
2540
|
-
const syncProgress = (remainingMs) => {
|
|
2541
|
-
progressDeadline = performance.now() + remainingMs;
|
|
2542
|
-
drawProgress();
|
|
2543
|
-
};
|
|
2544
|
-
motion?.addEventListener?.("change", drawProgress);
|
|
2545
|
-
const render = () => {
|
|
2546
|
-
if (closed) return;
|
|
2547
|
-
const language = resolveLocale(locale), messages2 = getMessages(language);
|
|
2548
|
-
dialog.lang = language;
|
|
2549
|
-
dialog.setAttribute("aria-label", messages2[mode]);
|
|
2550
|
-
dialog.dataset.mode = mode;
|
|
2551
|
-
dialog.dataset.hasQr = String(snapshot.status === "pending" && (mode === "bind" || Boolean(options.showQr)));
|
|
2552
|
-
closeButton.setAttribute("aria-label", messages2.close);
|
|
2553
|
-
const focused = document.activeElement;
|
|
2554
|
-
const focusKey = focused instanceof HTMLElement && content.contains(focused) ? focused.dataset.authlinkFocus : void 0;
|
|
2555
|
-
const next = element(widgetView({
|
|
2556
|
-
mode,
|
|
2557
|
-
locale,
|
|
2558
|
-
snapshot,
|
|
2559
|
-
qr: image,
|
|
2560
|
-
showQr: options.showQr,
|
|
2561
|
-
inputCode,
|
|
2562
|
-
setCode: (code) => {
|
|
2563
|
-
inputCode = code;
|
|
2564
|
-
},
|
|
2565
|
-
submitCode: (code) => submitCode?.(code) ?? Promise.resolve(),
|
|
2566
|
-
retry,
|
|
2567
|
-
refresh: options.onRefresh
|
|
2568
|
-
}));
|
|
2569
|
-
const oldCode = content.querySelector(".authlink-code");
|
|
2570
|
-
const nextCode = next.querySelector(".authlink-code");
|
|
2571
|
-
const codeChanged = Boolean(oldCode && nextCode && snapshot.code && oldCode.textContent !== nextCode.textContent);
|
|
2572
|
-
if (oldCode && nextCode && oldCode.textContent === nextCode.textContent) {
|
|
2573
|
-
oldCode.setAttribute("aria-label", nextCode.getAttribute("aria-label"));
|
|
2574
|
-
nextCode.replaceWith(oldCode);
|
|
2575
|
-
}
|
|
2576
|
-
const oldProgress = content.querySelector(".authlink-progress");
|
|
2577
|
-
const nextProgress = next.querySelector(".authlink-progress");
|
|
2578
|
-
if (oldProgress && nextProgress) {
|
|
2579
|
-
oldProgress.setAttribute("aria-valuenow", nextProgress.getAttribute("aria-valuenow"));
|
|
2580
|
-
oldProgress.setAttribute("aria-label", nextProgress.getAttribute("aria-label"));
|
|
2581
|
-
nextProgress.replaceWith(oldProgress);
|
|
2582
|
-
}
|
|
2583
|
-
const oldInput = content.querySelector("input[name=confirmationCode]");
|
|
2584
|
-
const nextInput = next.querySelector("input[name=confirmationCode]");
|
|
2585
|
-
if (oldInput && nextInput) nextInput.replaceWith(oldInput);
|
|
2586
|
-
content.replaceChildren(next);
|
|
2587
|
-
progressFill = content.querySelector(".authlink-progress-fill") ?? void 0;
|
|
2588
|
-
drawProgress();
|
|
2589
|
-
if (codeChanged && !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
|
2590
|
-
nextCode?.animate?.([{ opacity: 0.5 }, { opacity: 1 }], { duration: 180, easing: "ease-out" });
|
|
2591
|
-
}
|
|
2592
|
-
if (focusKey) (Array.from(content.querySelectorAll("[data-authlink-focus]")).find((item) => item.dataset.authlinkFocus === focusKey) ?? brand).focus({ preventScroll: true });
|
|
2593
|
-
};
|
|
2594
|
-
const dispose = (notify) => {
|
|
2595
|
-
if (closed) return;
|
|
2596
|
-
closed = true;
|
|
2597
|
-
abort?.abort();
|
|
2598
|
-
stopProgress();
|
|
2599
|
-
progressFill = void 0;
|
|
2600
|
-
motion?.removeEventListener?.("change", drawProgress);
|
|
2601
|
-
if (dialog.open) dialog.close();
|
|
2602
|
-
dialog.remove();
|
|
2603
|
-
if (!document.querySelector("dialog.authlink-dialog[open]")) {
|
|
2604
|
-
if (document.body.style.overflow === "hidden") document.body.style.overflow = document.body.dataset.authlinkOverflow ?? "";
|
|
2605
|
-
delete document.body.dataset.authlinkOverflow;
|
|
2606
|
-
}
|
|
2607
|
-
if (previousFocus?.isConnected) previousFocus.focus();
|
|
2608
|
-
if (notify) options.onClose?.();
|
|
2609
|
-
};
|
|
2610
|
-
const delivered = /* @__PURE__ */ new Set();
|
|
2611
|
-
const once = (status, callback) => {
|
|
2612
|
-
if (delivered.has(status)) return;
|
|
2613
|
-
delivered.add(status);
|
|
2614
|
-
return callback();
|
|
2615
|
-
};
|
|
2616
|
-
const retry = () => {
|
|
2617
|
-
if (closed) return;
|
|
2618
|
-
abort?.abort();
|
|
2619
|
-
abort = new AbortController();
|
|
2620
|
-
snapshot = initialSnapshot();
|
|
2621
|
-
render();
|
|
2622
|
-
void observeRequest(ticket, {
|
|
2623
|
-
client: options.client,
|
|
2624
|
-
intervalMs: options.intervalMs,
|
|
2625
|
-
expectedMode: mode,
|
|
2626
|
-
signal: abort.signal,
|
|
2627
|
-
onReady: (submit) => {
|
|
2628
|
-
submitCode = submit;
|
|
2629
|
-
},
|
|
2630
|
-
onCodeTiming: syncProgress,
|
|
2631
|
-
onUpdate: (value) => {
|
|
2632
|
-
snapshot = value;
|
|
2633
|
-
if (value.status !== "rejected") render();
|
|
2634
|
-
},
|
|
2635
|
-
onConfirmed: (value) => once("confirmed", () => {
|
|
2636
|
-
dispose(false);
|
|
2637
|
-
try {
|
|
2638
|
-
return Promise.resolve(options.onConfirmed?.(value)).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
|
|
2639
|
-
} catch {
|
|
2640
|
-
options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
|
|
2641
|
-
}
|
|
2642
|
-
}),
|
|
2643
|
-
onRejected: (value) => once("rejected", () => {
|
|
2644
|
-
dispose(false);
|
|
2645
|
-
try {
|
|
2646
|
-
return Promise.resolve(options.onRejected?.(value)).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
|
|
2647
|
-
} catch {
|
|
2648
|
-
options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
|
|
2649
|
-
}
|
|
2650
|
-
}),
|
|
2651
|
-
onExpired: () => once("expired", () => options.onExpired?.()),
|
|
2652
|
-
onError: options.onError
|
|
2653
|
-
});
|
|
2654
|
-
};
|
|
2655
|
-
closeButton.addEventListener("click", () => dispose(true));
|
|
2656
|
-
dialog.addEventListener("cancel", (event) => {
|
|
2657
|
-
event.preventDefault();
|
|
2658
|
-
dispose(true);
|
|
2659
|
-
});
|
|
2660
|
-
dialog.addEventListener("close", () => dispose(true));
|
|
2661
|
-
render();
|
|
2662
|
-
document.body.append(dialog);
|
|
2663
|
-
try {
|
|
2664
|
-
dialog.showModal();
|
|
2665
|
-
} catch (error) {
|
|
2666
|
-
dialog.remove();
|
|
2667
|
-
throw error;
|
|
2668
|
-
}
|
|
2669
|
-
brand.focus({ preventScroll: true });
|
|
2670
|
-
if (document.body.dataset.authlinkOverflow === void 0) document.body.dataset.authlinkOverflow = document.body.style.overflow;
|
|
2671
|
-
document.body.style.overflow = "hidden";
|
|
2672
|
-
retry();
|
|
2673
|
-
return {
|
|
2674
|
-
close: () => dispose(true),
|
|
2675
|
-
destroy: () => dispose(false),
|
|
2676
|
-
setLocale: (value) => {
|
|
2677
|
-
locale = value;
|
|
2678
|
-
render();
|
|
2679
|
-
},
|
|
2680
|
-
retry
|
|
2681
|
-
};
|
|
2682
|
-
}
|
|
2683
|
-
var openAuthLinkBind = (options) => openAuthLink({ ...options, mode: "bind" });
|
|
2684
|
-
var openAuthLinkConfirm = (options) => openAuthLink({ ...options, mode: "confirm" });
|
|
2685
|
-
|
|
2686
|
-
// src/passcode.ts
|
|
2687
|
-
function validateOTPResult(value) {
|
|
2688
|
-
if (!value || typeof value !== "object") throw new AuthLinkError("INVALID_RESPONSE");
|
|
2689
|
-
const result = value;
|
|
2690
|
-
const expiry = typeof result.expiresAt === "string" ? Date.parse(result.expiresAt) : NaN;
|
|
2691
|
-
if (result.status !== "confirmed" || result.authenticationPolicy !== "device-totp.v1" || typeof result.verificationId !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(result.verificationId) || !Number.isFinite(expiry) || expiry <= Date.now() || expiry > Date.now() + 9e4 || "requestId" in result || "state" in result || "businessId" in result) throw new AuthLinkError("INVALID_CONFIRMATION");
|
|
2692
|
-
return result;
|
|
2693
|
-
}
|
|
2694
|
-
function createPasscodeClient(options) {
|
|
2695
|
-
const call = createBackendCall(options);
|
|
2696
|
-
return {
|
|
2697
|
-
async verify(passcode, signal) {
|
|
2698
|
-
if (!/^[0-9]{6}$/.test(passcode)) throw new AuthLinkError("PASSCODE_INVALID");
|
|
2699
|
-
return validateOTPResult(await call("/auth", { passcode }, signal));
|
|
2700
|
-
}
|
|
2701
|
-
};
|
|
2702
|
-
}
|
|
2703
|
-
function openPasscode(options) {
|
|
2704
|
-
if (typeof document === "undefined" || !document.body) throw new AuthLinkError("BROWSER_REQUIRED");
|
|
2705
|
-
const deadline = Date.parse(options.expiresAt);
|
|
2706
|
-
if (!Number.isFinite(deadline) || deadline <= Date.now() || deadline > Date.now() + 3e5) throw new AuthLinkError("INVALID_EXPIRY");
|
|
2707
|
-
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
2708
|
-
const dialog = document.createElement("dialog");
|
|
2709
|
-
if (typeof dialog.showModal !== "function") throw new AuthLinkError("DIALOG_UNSUPPORTED");
|
|
2710
2208
|
dialog.className = "authlink-dialog";
|
|
2711
2209
|
dialog.setAttribute("aria-modal", "true");
|
|
2712
2210
|
const header = document.createElement("div");
|
|
@@ -2725,91 +2223,143 @@ function openPasscode(options) {
|
|
|
2725
2223
|
content.className = "authlink-modal-content";
|
|
2726
2224
|
header.append(brand, close);
|
|
2727
2225
|
dialog.append(header, content);
|
|
2728
|
-
let locale = options.locale, closed = false, busy = false, code = "", error;
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
let payload;
|
|
2226
|
+
let locale = options.locale, closed = false, busy = false, code = "", error = null;
|
|
2227
|
+
let abort, timer, expiredDelivered = false;
|
|
2228
|
+
const notifyError = (cause) => {
|
|
2229
|
+
const value = cause instanceof Error ? cause : new AuthLinkError("VERIFICATION_FAILED");
|
|
2733
2230
|
try {
|
|
2734
|
-
|
|
2231
|
+
options.onError?.(value);
|
|
2735
2232
|
} catch {
|
|
2736
|
-
throw new AuthLinkError("INVALID_TICKET");
|
|
2737
2233
|
}
|
|
2738
|
-
|
|
2739
|
-
}
|
|
2740
|
-
const
|
|
2741
|
-
|
|
2234
|
+
return value;
|
|
2235
|
+
};
|
|
2236
|
+
const callback = (fn) => {
|
|
2237
|
+
try {
|
|
2238
|
+
void Promise.resolve(fn?.()).catch(() => notifyError(new AuthLinkError("CALLBACK_FAILED")));
|
|
2239
|
+
} catch {
|
|
2240
|
+
notifyError(new AuthLinkError("CALLBACK_FAILED"));
|
|
2241
|
+
}
|
|
2242
|
+
};
|
|
2742
2243
|
const dispose = (notify) => {
|
|
2743
2244
|
if (closed) return;
|
|
2744
2245
|
closed = true;
|
|
2745
|
-
abort
|
|
2246
|
+
abort?.abort();
|
|
2746
2247
|
clearInterval(timer);
|
|
2248
|
+
if (dialog.open) dialog.close();
|
|
2747
2249
|
dialog.remove();
|
|
2748
|
-
previousFocus?.focus();
|
|
2749
|
-
if (notify) options.onClose
|
|
2250
|
+
if (previousFocus?.isConnected) previousFocus.focus();
|
|
2251
|
+
if (notify) callback(options.onClose);
|
|
2750
2252
|
};
|
|
2253
|
+
const expired = () => expires !== void 0 && Date.now() >= expires;
|
|
2751
2254
|
const render = () => {
|
|
2752
2255
|
if (closed) return;
|
|
2753
2256
|
const l = getMessages(locale);
|
|
2754
2257
|
dialog.lang = resolveLocale(locale);
|
|
2755
2258
|
dialog.dataset.mode = mode;
|
|
2756
|
-
dialog.dataset.hasQr = String(Boolean(image));
|
|
2259
|
+
dialog.dataset.hasQr = String(Boolean(image) && !expired());
|
|
2757
2260
|
dialog.setAttribute("aria-label", l[mode]);
|
|
2758
2261
|
close.setAttribute("aria-label", l.close);
|
|
2759
|
-
const focused =
|
|
2760
|
-
const
|
|
2761
|
-
const
|
|
2762
|
-
|
|
2262
|
+
const focused = document.activeElement;
|
|
2263
|
+
const focusKey = focused instanceof HTMLElement && content.contains(focused) ? focused.dataset.authlinkFocus : void 0;
|
|
2264
|
+
const oldInput = content.querySelector("input");
|
|
2265
|
+
const next = element(widgetView({
|
|
2763
2266
|
mode,
|
|
2764
2267
|
locale,
|
|
2765
|
-
|
|
2766
|
-
|
|
2268
|
+
snapshot: {
|
|
2269
|
+
status: expired() ? "expired" : "pending",
|
|
2270
|
+
submitting: busy,
|
|
2271
|
+
error,
|
|
2272
|
+
secondsRemaining: expires === void 0 ? void 0 : Math.max(0, Math.ceil((expires - Date.now()) / 1e3))
|
|
2273
|
+
},
|
|
2767
2274
|
qr: image,
|
|
2768
|
-
showQr: Boolean(image),
|
|
2769
2275
|
inputCode: code,
|
|
2770
2276
|
setCode: (value) => {
|
|
2771
2277
|
code = value;
|
|
2772
2278
|
},
|
|
2773
2279
|
submitCode: submit,
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2280
|
+
refresh: binding?.onRefresh ? refresh : void 0
|
|
2281
|
+
}));
|
|
2282
|
+
const nextInput = next.querySelector("input");
|
|
2283
|
+
if (oldInput && nextInput) {
|
|
2284
|
+
oldInput.disabled = busy;
|
|
2285
|
+
if (oldInput.value !== code) oldInput.value = code;
|
|
2286
|
+
nextInput.replaceWith(oldInput);
|
|
2287
|
+
}
|
|
2288
|
+
content.replaceChildren(next);
|
|
2289
|
+
if (focusKey) content.querySelector(`[data-authlink-focus="${focusKey}"]`)?.focus({ preventScroll: true });
|
|
2780
2290
|
};
|
|
2781
|
-
const
|
|
2782
|
-
if (closed
|
|
2783
|
-
if (
|
|
2291
|
+
const tick = () => {
|
|
2292
|
+
if (closed) return;
|
|
2293
|
+
if (expired() && !expiredDelivered) {
|
|
2294
|
+
expiredDelivered = true;
|
|
2295
|
+
abort?.abort();
|
|
2296
|
+
busy = false;
|
|
2784
2297
|
render();
|
|
2298
|
+
callback(options.onExpired);
|
|
2299
|
+
} else if (expires !== void 0) render();
|
|
2300
|
+
};
|
|
2301
|
+
const startTimer = () => {
|
|
2302
|
+
clearInterval(timer);
|
|
2303
|
+
if (expires !== void 0) timer = setInterval(tick, 1e3);
|
|
2304
|
+
};
|
|
2305
|
+
const submit = async (value) => {
|
|
2306
|
+
if (closed || busy || expired()) {
|
|
2307
|
+
tick();
|
|
2785
2308
|
return;
|
|
2786
2309
|
}
|
|
2787
2310
|
if (!/^[0-9]{6}$/.test(value)) {
|
|
2788
|
-
error = new AuthLinkError("PASSCODE_INVALID");
|
|
2311
|
+
error = notifyError(new AuthLinkError("PASSCODE_INVALID"));
|
|
2789
2312
|
render();
|
|
2790
2313
|
return;
|
|
2791
2314
|
}
|
|
2792
2315
|
code = value;
|
|
2793
2316
|
busy = true;
|
|
2794
|
-
error =
|
|
2317
|
+
error = null;
|
|
2318
|
+
const current = new AbortController();
|
|
2319
|
+
abort = current;
|
|
2795
2320
|
render();
|
|
2796
2321
|
try {
|
|
2797
|
-
const
|
|
2798
|
-
if (
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
} catch {
|
|
2802
|
-
options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
|
|
2803
|
-
}
|
|
2322
|
+
const pending = options.submit(value, { signal: current.signal });
|
|
2323
|
+
if (!pending || typeof pending.then !== "function") throw new AuthLinkError("SUBMIT_MUST_RETURN_PROMISE");
|
|
2324
|
+
await pending;
|
|
2325
|
+
if (closed || current.signal.aborted || abort !== current) return;
|
|
2804
2326
|
dispose(false);
|
|
2327
|
+
callback(options.onSuccess);
|
|
2805
2328
|
} catch (cause) {
|
|
2806
|
-
if (!closed && !
|
|
2807
|
-
|
|
2808
|
-
|
|
2329
|
+
if (!closed && !current.signal.aborted && abort === current) error = notifyError(cause);
|
|
2330
|
+
} finally {
|
|
2331
|
+
if (!closed && abort === current) {
|
|
2332
|
+
busy = false;
|
|
2333
|
+
render();
|
|
2809
2334
|
}
|
|
2335
|
+
}
|
|
2336
|
+
};
|
|
2337
|
+
const refresh = async () => {
|
|
2338
|
+
if (closed || busy || !binding?.onRefresh || !expired()) return;
|
|
2339
|
+
busy = true;
|
|
2340
|
+
error = null;
|
|
2341
|
+
expiredDelivered = true;
|
|
2342
|
+
clearInterval(timer);
|
|
2343
|
+
const current = new AbortController();
|
|
2344
|
+
abort?.abort();
|
|
2345
|
+
abort = current;
|
|
2346
|
+
render();
|
|
2347
|
+
try {
|
|
2348
|
+
const ticket = await binding.onRefresh({ signal: current.signal });
|
|
2349
|
+
if (closed || current.signal.aborted || abort !== current) return;
|
|
2350
|
+
const nextImage = bindingImage(ticket, binding.authLinkOrigin), nextDeadline = deadline(ticket.expiresAt);
|
|
2351
|
+
image = nextImage;
|
|
2352
|
+
expires = nextDeadline;
|
|
2353
|
+
code = "";
|
|
2354
|
+
expiredDelivered = false;
|
|
2355
|
+
startTimer();
|
|
2356
|
+
} catch (cause) {
|
|
2357
|
+
if (!closed && !current.signal.aborted && abort === current) error = notifyError(cause);
|
|
2810
2358
|
} finally {
|
|
2811
|
-
|
|
2812
|
-
|
|
2359
|
+
if (!closed && abort === current) {
|
|
2360
|
+
busy = false;
|
|
2361
|
+
render();
|
|
2362
|
+
}
|
|
2813
2363
|
}
|
|
2814
2364
|
};
|
|
2815
2365
|
close.addEventListener("click", () => dispose(true));
|
|
@@ -2817,18 +2367,17 @@ function openPasscode(options) {
|
|
|
2817
2367
|
event.preventDefault();
|
|
2818
2368
|
dispose(true);
|
|
2819
2369
|
});
|
|
2370
|
+
dialog.addEventListener("close", () => dispose(true));
|
|
2820
2371
|
document.body.append(dialog);
|
|
2821
2372
|
render();
|
|
2822
|
-
|
|
2373
|
+
try {
|
|
2374
|
+
dialog.showModal();
|
|
2375
|
+
} catch (cause) {
|
|
2376
|
+
dispose(false);
|
|
2377
|
+
throw cause;
|
|
2378
|
+
}
|
|
2823
2379
|
content.querySelector("input")?.focus();
|
|
2824
|
-
|
|
2825
|
-
if (closed) return;
|
|
2826
|
-
if (Date.now() >= deadline) {
|
|
2827
|
-
render();
|
|
2828
|
-
clearInterval(timer);
|
|
2829
|
-
void Promise.resolve(options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
|
|
2830
|
-
}
|
|
2831
|
-
}, 1e3);
|
|
2380
|
+
startTimer();
|
|
2832
2381
|
return {
|
|
2833
2382
|
close: () => dispose(true),
|
|
2834
2383
|
destroy: () => dispose(false),
|
|
@@ -2837,24 +2386,20 @@ function openPasscode(options) {
|
|
|
2837
2386
|
render();
|
|
2838
2387
|
},
|
|
2839
2388
|
retry: () => {
|
|
2840
|
-
|
|
2841
|
-
|
|
2389
|
+
if (!closed && !busy) {
|
|
2390
|
+
error = null;
|
|
2391
|
+
tick();
|
|
2392
|
+
render();
|
|
2393
|
+
}
|
|
2842
2394
|
}
|
|
2843
2395
|
};
|
|
2844
2396
|
}
|
|
2397
|
+
var openVerify = (options) => open(options, "confirm");
|
|
2398
|
+
var openBind = (options) => open(options, "bind");
|
|
2845
2399
|
export {
|
|
2846
2400
|
AuthLinkError,
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
observeRequest,
|
|
2851
|
-
openAuthLink,
|
|
2852
|
-
openAuthLinkBind,
|
|
2853
|
-
openAuthLinkConfirm,
|
|
2854
|
-
openPasscode,
|
|
2855
|
-
qrImage,
|
|
2856
|
-
requestKey,
|
|
2857
|
-
supportedLocales,
|
|
2858
|
-
validateTicket
|
|
2401
|
+
openBind,
|
|
2402
|
+
openVerify,
|
|
2403
|
+
supportedLocales
|
|
2859
2404
|
};
|
|
2860
2405
|
//# sourceMappingURL=index.js.map
|