DPoP in frontend, why not

This commit is contained in:
lewis
2026-01-06 19:58:04 +02:00
parent 767f89ae76
commit 8f600b6691
12 changed files with 590 additions and 108 deletions
+9
View File
@@ -20,6 +20,7 @@ import { err, isErr, isOk, ok, type Result } from "./types/result.ts";
import { assertNever } from "./types/exhaustive.ts";
import {
checkForOAuthCallback,
clearAllOAuthState,
clearOAuthCallbackParams,
handleOAuthCallback,
refreshOAuthToken,
@@ -274,6 +275,12 @@ function setLoading(previousSession: Session | null = null): void {
setState(createLoading(getSavedAccounts(), previousSession));
}
export function clearError(): void {
if (state.current.kind === "error") {
setState(createUnauthenticated(getSavedAccounts()));
}
}
async function tryRefreshToken(): Promise<string | null> {
if (state.current.kind !== "authenticated") return null;
const currentSession = state.current.session;
@@ -323,6 +330,7 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
applyLocaleFromSession(session);
return { oauthLoginCompleted: true };
} catch (e) {
clearAllOAuthState();
setError({
type: "oauth",
message: e instanceof Error ? e.message : "OAuth login failed",
@@ -398,6 +406,7 @@ export async function login(
}
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
clearAllOAuthState();
setLoading();
try {
await startOAuthLogin();
+244 -39
View File
@@ -1,5 +1,8 @@
const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";
const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier";
const DPOP_KEY_STORE = "tranquil_pds_dpop_keys";
const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce";
const SCOPES = [
"atproto",
"repo:*?action=create",
@@ -7,9 +10,11 @@ const SCOPES = [
"repo:*?action=delete",
"blob:*/*",
].join(" ");
const CLIENT_ID = !(import.meta.env.DEV)
? `${globalThis.location.origin}/oauth/client-metadata.json`
: `http://localhost/?scope=${SCOPES}`;
const REDIRECT_URI = `${globalThis.location.origin}/app/`;
interface OAuthState {
@@ -18,6 +23,12 @@ interface OAuthState {
returnTo?: string;
}
interface DPoPKeyPair {
publicKey: CryptoKey;
privateKey: CryptoKey;
jwk: JsonWebKey;
}
function generateRandomString(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
@@ -73,11 +84,191 @@ function clearOAuthState(): void {
sessionStorage.removeItem(OAUTH_VERIFIER_KEY);
}
function clearDPoPNonce(): void {
sessionStorage.removeItem(DPOP_NONCE_KEY);
}
export function clearAllOAuthState(): void {
clearOAuthState();
clearDPoPNonce();
}
async function openKeyStore(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DPOP_KEY_STORE, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("keys")) {
db.createObjectStore("keys");
}
};
});
}
async function storeDPoPKeyPair(keyPair: DPoPKeyPair): Promise<void> {
const db = await openKeyStore();
return new Promise((resolve, reject) => {
const tx = db.transaction("keys", "readwrite");
const store = tx.objectStore("keys");
store.put(keyPair.publicKey, "publicKey");
store.put(keyPair.privateKey, "privateKey");
store.put(keyPair.jwk, "jwk");
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
}
async function loadDPoPKeyPair(): Promise<DPoPKeyPair | null> {
try {
const db = await openKeyStore();
return new Promise((resolve, reject) => {
const tx = db.transaction("keys", "readonly");
const store = tx.objectStore("keys");
const publicKeyReq = store.get("publicKey");
const privateKeyReq = store.get("privateKey");
const jwkReq = store.get("jwk");
tx.oncomplete = () => {
db.close();
if (publicKeyReq.result && privateKeyReq.result && jwkReq.result) {
resolve({
publicKey: publicKeyReq.result,
privateKey: privateKeyReq.result,
jwk: jwkReq.result,
});
} else {
resolve(null);
}
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
} catch {
return null;
}
}
async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"],
);
const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
return {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
jwk,
};
}
async function getOrCreateDPoPKeyPair(): Promise<DPoPKeyPair> {
const existing = await loadDPoPKeyPair();
if (existing) return existing;
const keyPair = await generateDPoPKeyPair();
await storeDPoPKeyPair(keyPair);
return keyPair;
}
async function createDPoPProof(
keyPair: DPoPKeyPair,
method: string,
url: string,
nonce?: string,
accessTokenHash?: string,
): Promise<string> {
const header = {
typ: "dpop+jwt",
alg: "ES256",
jwk: {
kty: keyPair.jwk.kty,
crv: keyPair.jwk.crv,
x: keyPair.jwk.x,
y: keyPair.jwk.y,
},
};
const payload: Record<string, unknown> = {
jti: generateRandomString(16),
htm: method.toUpperCase(),
htu: url.split("?")[0],
iat: Math.floor(Date.now() / 1000),
};
if (nonce) {
payload.nonce = nonce;
}
if (accessTokenHash) {
payload.ath = accessTokenHash;
}
const headerB64 = base64UrlEncode(
new TextEncoder().encode(JSON.stringify(header)).buffer as ArrayBuffer,
);
const payloadB64 = base64UrlEncode(
new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer,
);
const signingInput = `${headerB64}.${payloadB64}`;
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
keyPair.privateKey,
new TextEncoder().encode(signingInput),
);
const sigBytes = new Uint8Array(signature);
const signatureB64 = base64UrlEncode(sigBytes.buffer);
return `${signingInput}.${signatureB64}`;
}
async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
const canonical = JSON.stringify({
crv: jwk.crv,
kty: jwk.kty,
x: jwk.x,
y: jwk.y,
});
const hash = await sha256(canonical);
return base64UrlEncode(hash);
}
function getDPoPNonce(): string | null {
return sessionStorage.getItem(DPOP_NONCE_KEY);
}
function setDPoPNonce(nonce: string): void {
sessionStorage.setItem(DPOP_NONCE_KEY, nonce);
}
function extractDPoPNonceFromResponse(response: Response): void {
const nonce = response.headers.get("DPoP-Nonce");
if (nonce) {
setDPoPNonce(nonce);
}
}
export async function startOAuthLogin(): Promise<void> {
clearAllOAuthState();
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const keyPair = await getOrCreateDPoPKeyPair();
const dpopJkt = await computeJwkThumbprint(keyPair.jwk);
saveOAuthState({ state, codeVerifier });
const parResponse = await fetch("/oauth/par", {
@@ -91,6 +282,7 @@ export async function startOAuthLogin(): Promise<void> {
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
dpop_jkt: dpopJkt,
}),
});
@@ -121,6 +313,46 @@ export interface OAuthTokens {
sub: string;
}
async function tokenRequest(
params: URLSearchParams,
retryWithNonce = true,
): Promise<OAuthTokens> {
const keyPair = await getOrCreateDPoPKeyPair();
const tokenEndpoint = `${globalThis.location.origin}/oauth/token`;
const dpopProof = await createDPoPProof(
keyPair,
"POST",
tokenEndpoint,
getDPoPNonce() ?? undefined,
);
const response = await fetch("/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"DPoP": dpopProof,
},
body: params,
});
extractDPoPNonceFromResponse(response);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) {
return tokenRequest(params, false);
}
throw new Error(
error.error_description || error.error || "Token request failed",
);
}
return response.json();
}
export async function handleOAuthCallback(
code: string,
state: string,
@@ -135,56 +367,29 @@ export async function handleOAuthCallback(
throw new Error("OAuth state mismatch. Please try logging in again.");
}
const tokenResponse = await fetch("/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code: code,
redirect_uri: REDIRECT_URI,
code_verifier: savedState.codeVerifier,
}),
const params = new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code: code,
redirect_uri: REDIRECT_URI,
code_verifier: savedState.codeVerifier,
});
clearOAuthState();
if (!tokenResponse.ok) {
const error = await tokenResponse.json().catch(() => ({
error: "Unknown error",
}));
throw new Error(
error.error_description || error.error ||
"Failed to exchange code for tokens",
);
}
return tokenResponse.json();
return tokenRequest(params);
}
export async function refreshOAuthToken(
refreshToken: string,
): Promise<OAuthTokens> {
const tokenResponse = await fetch("/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
const params = new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
});
if (!tokenResponse.ok) {
const error = await tokenResponse.json().catch(() => ({
error: "Unknown error",
}));
throw new Error(
error.error_description || error.error || "Failed to refresh token",
);
}
return tokenResponse.json();
return tokenRequest(params);
}
export function checkForOAuthCallback():
+22 -4
View File
@@ -285,14 +285,24 @@
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-7);
gap: var(--space-4);
}
@media (max-width: 500px) {
header {
flex-direction: column-reverse;
align-items: flex-start;
}
}
header h1 {
margin: 0;
min-width: 0;
}
.account-dropdown {
position: relative;
max-width: 100%;
}
.account-trigger {
@@ -305,6 +315,14 @@
border-radius: var(--radius-md);
cursor: pointer;
color: var(--text-primary);
max-width: 100%;
}
.account-trigger .account-handle {
font-weight: var(--font-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-trigger:hover:not(:disabled) {
@@ -316,10 +334,6 @@
cursor: not-allowed;
}
.account-trigger .account-handle {
font-weight: var(--font-medium);
}
.dropdown-arrow {
font-size: 0.625rem;
color: var(--text-secondary);
@@ -383,6 +397,8 @@
padding: var(--space-6);
border-radius: var(--radius-xl);
margin-bottom: var(--space-7);
overflow: hidden;
min-width: 0;
}
section h2 {
@@ -400,10 +416,12 @@
dt {
font-weight: var(--font-medium);
color: var(--text-secondary);
max-width: 6rem;
}
dd {
margin: 0;
min-width: 0;
}
.mono {
+9 -12
View File
@@ -6,6 +6,7 @@
getAuthState,
switchAccount,
forgetAccount,
clearError,
matchAuthState,
type SavedAccount,
type AuthError,
@@ -14,6 +15,7 @@
import { _ } from '../lib/i18n'
import { isOk, isErr } from '../lib/types/result'
import { unsafeAsDid, type Did } from '../lib/types/branded'
import { toast } from '../lib/toast.svelte'
type PageState =
| { kind: 'login' }
@@ -32,17 +34,17 @@
return auth.savedAccounts
}
function getErrorMessage(): string | null {
if (auth.kind === 'error') {
return auth.error.message
}
return null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
$effect(() => {
if (auth.kind === 'error') {
toast.error(auth.error.message)
clearError()
}
})
$effect(() => {
const accounts = getSavedAccounts()
const loading = isLoading()
@@ -108,16 +110,11 @@
resendMessage = null
}
const errorMessage = $derived(getErrorMessage())
const savedAccounts = $derived(getSavedAccounts())
const loading = $derived(isLoading())
</script>
<div class="login-page">
{#if errorMessage}
<div class="message error">{errorMessage}</div>
{/if}
{#if pageState.kind === 'verification'}
<header class="page-header">
<h1>{$_('verification.title')}</h1>
+8
View File
@@ -29,6 +29,8 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s ease;
overflow-wrap: anywhere;
word-break: break-word;
}
h1, h2, h3, h4, h5, h6 {
@@ -336,12 +338,16 @@ hr {
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: var(--space-6);
overflow: hidden;
min-width: 0;
}
.section {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: var(--space-6);
overflow: hidden;
min-width: 0;
}
.section + .section {
@@ -469,6 +475,8 @@ hr {
border-radius: var(--radius-xl);
padding: var(--space-6);
height: fit-content;
overflow: hidden;
min-width: 0;
}
.info-panel h3 {
+13 -15
View File
@@ -4,6 +4,7 @@ import AppPasswords from "../routes/AppPasswords.svelte";
import {
clearMocks,
errorResponse,
getErrorToasts,
jsonResponse,
mockData,
mockEndpoint,
@@ -51,7 +52,7 @@ describe("AppPasswords", () => {
beforeEach(() => {
setupAuthenticatedUser();
});
it("shows loading text while fetching passwords", () => {
it("shows loading skeleton while fetching passwords", () => {
mockEndpoint(
"com.atproto.server.listAppPasswords",
() =>
@@ -59,8 +60,8 @@ describe("AppPasswords", () => {
setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100)
),
);
render(AppPasswords);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
const { container } = render(AppPasswords);
expect(container.querySelectorAll(".skeleton-item").length).toBeGreaterThan(0);
});
});
describe("empty state", () => {
@@ -236,7 +237,7 @@ describe("AppPasswords", () => {
.toBeInTheDocument();
});
});
it("shows error when creation fails", async () => {
it("shows error toast when creation fails", async () => {
mockEndpoint(
"com.atproto.server.createAppPassword",
() => errorResponse("InvalidRequest", "Name already exists", 400),
@@ -250,8 +251,8 @@ describe("AppPasswords", () => {
});
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => {
expect(screen.getByText(/name already exists/i)).toBeInTheDocument();
expect(screen.getByText(/name already exists/i)).toHaveClass("error");
const errors = getErrorToasts();
expect(errors.some((e) => /name already exists/i.test(e))).toBe(true);
});
});
});
@@ -358,7 +359,7 @@ describe("AppPasswords", () => {
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
});
});
it("shows error when revocation fails", async () => {
it("shows error toast when revocation fails", async () => {
globalThis.confirm = vi.fn(() => true);
mockEndpoint(
"com.atproto.server.listAppPasswords",
@@ -374,8 +375,8 @@ describe("AppPasswords", () => {
});
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
await waitFor(() => {
expect(screen.getByText(/server error/i)).toBeInTheDocument();
expect(screen.getByText(/server error/i)).toHaveClass("error");
const errors = getErrorToasts();
expect(errors.some((e) => /server error/i.test(e))).toBe(true);
});
});
});
@@ -383,18 +384,15 @@ describe("AppPasswords", () => {
beforeEach(() => {
setupAuthenticatedUser();
});
it("shows error when loading passwords fails", async () => {
it("shows error toast when loading passwords fails", async () => {
mockEndpoint(
"com.atproto.server.listAppPasswords",
() => errorResponse("InternalError", "Database connection failed", 500),
);
render(AppPasswords);
await waitFor(() => {
expect(screen.getByText(/database connection failed/i))
.toBeInTheDocument();
expect(screen.getByText(/database connection failed/i)).toHaveClass(
"error",
);
const errors = getErrorToasts();
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true);
});
});
});
+14 -17
View File
@@ -4,6 +4,8 @@ import Comms from "../routes/Comms.svelte";
import {
clearMocks,
errorResponse,
getErrorToasts,
getToasts,
jsonResponse,
mockData,
mockEndpoint,
@@ -71,7 +73,7 @@ describe("Comms", () => {
() => jsonResponse({ notifications: [] }),
);
});
it("shows loading text while fetching preferences", () => {
it("shows loading skeleton while fetching preferences", () => {
mockEndpoint(
"_account.getNotificationPrefs",
() =>
@@ -82,8 +84,8 @@ describe("Comms", () => {
)
),
);
render(Comms);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
const { container } = render(Comms);
expect(container.querySelectorAll(".skeleton-section").length).toBeGreaterThan(0);
});
});
describe("channel options", () => {
@@ -354,7 +356,7 @@ describe("Comms", () => {
.toBeInTheDocument();
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
});
it("shows success message after saving", async () => {
it("shows success toast after saving", async () => {
mockEndpoint(
"_account.getNotificationPrefs",
() => jsonResponse(mockData.notificationPrefs()),
@@ -372,11 +374,11 @@ describe("Comms", () => {
screen.getByRole("button", { name: /save preferences/i }),
);
await waitFor(() => {
expect(screen.getByText(/preferences saved/i))
.toBeInTheDocument();
const toasts = getToasts();
expect(toasts.some((t) => t.type === "success" && /saved/i.test(t.message))).toBe(true);
});
});
it("shows error when save fails", async () => {
it("shows error toast when save fails", async () => {
mockEndpoint(
"_account.getNotificationPrefs",
() => jsonResponse(mockData.notificationPrefs()),
@@ -395,13 +397,8 @@ describe("Comms", () => {
screen.getByRole("button", { name: /save preferences/i }),
);
await waitFor(() => {
expect(screen.getByText(/invalid channel configuration/i))
.toBeInTheDocument();
expect(
screen.getByText(/invalid channel configuration/i).closest(
".message",
),
).toHaveClass("error");
const errors = getErrorToasts();
expect(errors.some((e) => /invalid channel configuration/i.test(e))).toBe(true);
});
});
it("reloads preferences after successful save", async () => {
@@ -490,15 +487,15 @@ describe("Comms", () => {
() => jsonResponse({ notifications: [] }),
);
});
it("shows error when loading preferences fails", async () => {
it("shows error toast when loading preferences fails", async () => {
mockEndpoint(
"_account.getNotificationPrefs",
() => errorResponse("InternalError", "Database connection failed", 500),
);
render(Comms);
await waitFor(() => {
expect(screen.getByText(/database connection failed/i))
.toBeInTheDocument();
const errors = getErrorToasts();
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true);
});
});
});
+3 -2
View File
@@ -25,8 +25,9 @@ describe("Dashboard", () => {
});
});
it("shows loading state while checking auth", () => {
render(Dashboard);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
const { container } = render(Dashboard);
expect(container.querySelector(".skeleton-section")).toBeInTheDocument();
expect(container.querySelectorAll(".skeleton-card").length).toBeGreaterThan(0);
});
});
describe("authenticated view", () => {
+7 -3
View File
@@ -15,6 +15,7 @@ import {
unsafeAsHandle,
unsafeAsRefreshToken,
} from "../lib/types/branded.ts";
import { getToasts } from "../lib/toast.svelte.ts";
describe("Login", () => {
beforeEach(() => {
@@ -147,7 +148,7 @@ describe("Login", () => {
});
describe("error handling", () => {
it("displays error message when auth state has error", async () => {
it("displays error message as toast when auth state has error", async () => {
_testSetState({
session: null,
loading: false,
@@ -156,8 +157,11 @@ describe("Login", () => {
});
render(Login);
await waitFor(() => {
expect(screen.getByText(/oauth login failed/i)).toBeInTheDocument();
expect(screen.getByText(/oauth login failed/i)).toHaveClass("error");
const toasts = getToasts();
const errorToast = toasts.find(
(t) => t.type === "error" && t.message.includes("OAuth login failed"),
);
expect(errorToast).toBeDefined();
});
});
});
+17 -15
View File
@@ -4,6 +4,8 @@ import Settings from "../routes/Settings.svelte";
import {
clearMocks,
errorResponse,
getErrorToasts,
getToasts,
jsonResponse,
mockData,
mockEndpoint,
@@ -140,7 +142,7 @@ describe("Settings", () => {
expect(capturedBody?.token).toBe("123456");
});
});
it("shows success message after email update", async () => {
it("shows success toast after email update", async () => {
mockEndpoint(
"com.atproto.server.requestEmailUpdate",
() => jsonResponse({ tokenRequired: true }),
@@ -171,8 +173,8 @@ describe("Settings", () => {
screen.getByRole("button", { name: /confirm email change/i }),
);
await waitFor(() => {
expect(screen.getByText(/email updated/i))
.toBeInTheDocument();
const toasts = getToasts();
expect(toasts.some((t) => t.type === "success" && /email.*updated/i.test(t.message))).toBe(true);
});
});
it("shows cancel button to return to initial state", async () => {
@@ -205,7 +207,7 @@ describe("Settings", () => {
.toBeInTheDocument();
});
});
it("shows error when request fails", async () => {
it("shows error toast when request fails", async () => {
mockEndpoint(
"com.atproto.server.requestEmailUpdate",
() => errorResponse("InvalidEmail", "Invalid email format", 400),
@@ -219,7 +221,8 @@ describe("Settings", () => {
screen.getByRole("button", { name: /change email/i }),
);
await waitFor(() => {
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
const errors = getErrorToasts();
expect(errors.some((e) => /invalid email format/i.test(e))).toBe(true);
});
});
});
@@ -261,7 +264,7 @@ describe("Settings", () => {
expect(screen.getByRole("button", { name: /change handle/i }))
.toBeInTheDocument();
});
it("shows success message after handle change", async () => {
it("shows success toast after handle change", async () => {
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
mockEndpoint(
"com.atproto.server.getSession",
@@ -279,11 +282,11 @@ describe("Settings", () => {
const button = screen.getByRole("button", { name: /change handle/i });
await fireEvent.submit(button.closest("form")!);
await waitFor(() => {
expect(screen.getByText(/handle updated/i))
.toBeInTheDocument();
const toasts = getToasts();
expect(toasts.some((t) => t.type === "success" && /handle.*updated/i.test(t.message))).toBe(true);
});
});
it("shows error when handle change fails", async () => {
it("shows error toast when handle change fails", async () => {
mockEndpoint(
"com.atproto.identity.updateHandle",
() =>
@@ -302,9 +305,8 @@ describe("Settings", () => {
const button = screen.getByRole("button", { name: /change handle/i });
await fireEvent.submit(button.closest("form")!);
await waitFor(() => {
const errorMessage = screen.queryByText(/handle is already taken/i) ||
screen.queryByText(/handle update failed/i);
expect(errorMessage).toBeInTheDocument();
const errors = getErrorToasts();
expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(true);
});
});
});
@@ -500,7 +502,7 @@ describe("Settings", () => {
).toBeInTheDocument();
});
});
it("shows error when deletion fails", async () => {
it("shows error toast when deletion fails", async () => {
globalThis.confirm = vi.fn(() => true);
mockEndpoint(
"com.atproto.server.requestAccountDelete",
@@ -532,8 +534,8 @@ describe("Settings", () => {
screen.getByRole("button", { name: /permanently delete account/i }),
);
await waitFor(() => {
expect(screen.getByText(/invalid confirmation code/i))
.toBeInTheDocument();
const errors = getErrorToasts();
expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(true);
});
});
});
+12 -1
View File
@@ -1,6 +1,7 @@
import { vi } from "vitest";
import type { AppPassword, InviteCode, Session } from "../lib/api.ts";
import { _testSetState } from "../lib/auth.svelte.ts";
import { _testSetState, _testResetState } from "../lib/auth.svelte.ts";
import { toast, clearAllToasts, getToasts } from "../lib/toast.svelte.ts";
import {
unsafeAsAccessToken,
unsafeAsDid,
@@ -70,7 +71,17 @@ export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
}
export function clearMocks(): void {
mockHandlers.clear();
_testResetState();
clearAllToasts();
}
export function getErrorToasts(): string[] {
return getToasts()
.filter((t) => t.type === "error")
.map((t) => t.message);
}
export { toast, getToasts };
function extractEndpoint(url: string): string {
const match = url.match(/\/xrpc\/([^?]+)/);
return match ? match[1] : url;
+232
View File
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
saveOAuthState,
checkForOAuthCallback,
clearOAuthCallbackParams,
} from "../lib/oauth";
describe("OAuth utilities", () => {
beforeEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
describe("generateState", () => {
it("generates a 64-character hex string", () => {
const state = generateState();
expect(state).toMatch(/^[0-9a-f]{64}$/);
});
it("generates unique values", () => {
const states = new Set(Array.from({ length: 100 }, () => generateState()));
expect(states.size).toBe(100);
});
});
describe("generateCodeVerifier", () => {
it("generates a 64-character hex string", () => {
const verifier = generateCodeVerifier();
expect(verifier).toMatch(/^[0-9a-f]{64}$/);
});
it("generates unique values", () => {
const verifiers = new Set(
Array.from({ length: 100 }, () => generateCodeVerifier()),
);
expect(verifiers.size).toBe(100);
});
});
describe("generateCodeChallenge", () => {
it("generates a base64url-encoded SHA-256 hash", async () => {
const verifier = "test-verifier-12345";
const challenge = await generateCodeChallenge(verifier);
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
expect(challenge).not.toContain("+");
expect(challenge).not.toContain("/");
expect(challenge).not.toContain("=");
});
it("produces consistent output for same input", async () => {
const verifier = "consistent-test-verifier";
const challenge1 = await generateCodeChallenge(verifier);
const challenge2 = await generateCodeChallenge(verifier);
expect(challenge1).toBe(challenge2);
});
it("produces different output for different inputs", async () => {
const challenge1 = await generateCodeChallenge("verifier-1");
const challenge2 = await generateCodeChallenge("verifier-2");
expect(challenge1).not.toBe(challenge2);
});
it("produces correct S256 challenge", async () => {
const challenge = await generateCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
});
describe("saveOAuthState", () => {
it("stores state and verifier in sessionStorage", () => {
saveOAuthState({ state: "test-state", codeVerifier: "test-verifier" });
expect(sessionStorage.getItem("tranquil_pds_oauth_state")).toBe(
"test-state",
);
expect(sessionStorage.getItem("tranquil_pds_oauth_verifier")).toBe(
"test-verifier",
);
});
});
describe("checkForOAuthCallback", () => {
it("returns null when no code/state in URL", () => {
Object.defineProperty(globalThis.location, "search", {
value: "",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/",
writable: true,
configurable: true,
});
expect(checkForOAuthCallback()).toBeNull();
});
it("returns code and state when present in URL", () => {
Object.defineProperty(globalThis.location, "search", {
value: "?code=auth-code-123&state=state-456",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/",
writable: true,
configurable: true,
});
const result = checkForOAuthCallback();
expect(result).toEqual({ code: "auth-code-123", state: "state-456" });
});
it("returns null on migrate path even with code/state", () => {
Object.defineProperty(globalThis.location, "search", {
value: "?code=auth-code-123&state=state-456",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/migrate",
writable: true,
configurable: true,
});
expect(checkForOAuthCallback()).toBeNull();
});
it("returns null when only code is present", () => {
Object.defineProperty(globalThis.location, "search", {
value: "?code=auth-code-123",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/",
writable: true,
configurable: true,
});
expect(checkForOAuthCallback()).toBeNull();
});
it("returns null when only state is present", () => {
Object.defineProperty(globalThis.location, "search", {
value: "?state=state-456",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/",
writable: true,
configurable: true,
});
expect(checkForOAuthCallback()).toBeNull();
});
});
describe("clearOAuthCallbackParams", () => {
it("removes query params from URL", () => {
const replaceStateSpy = vi.spyOn(globalThis.history, "replaceState");
Object.defineProperty(globalThis.location, "href", {
value: "http://localhost:3000/app/?code=123&state=456",
writable: true,
configurable: true,
});
clearOAuthCallbackParams();
expect(replaceStateSpy).toHaveBeenCalled();
const callArgs = replaceStateSpy.mock.calls[0];
expect(callArgs[0]).toEqual({});
expect(callArgs[1]).toBe("");
const urlString = callArgs[2] as string;
expect(urlString).toBe("http://localhost:3000/app/");
expect(urlString).not.toContain("?");
expect(urlString).not.toContain("code=");
expect(urlString).not.toContain("state=");
});
});
});
describe("DPoP proof generation", () => {
it("base64url encoding produces valid output", async () => {
const testData = new Uint8Array([72, 101, 108, 108, 111]);
const buffer = testData.buffer;
const binary = Array.from(testData, (byte) => String.fromCharCode(byte)).join("");
const base64url = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
expect(base64url).toBe("SGVsbG8");
expect(base64url).not.toContain("+");
expect(base64url).not.toContain("/");
expect(base64url).not.toContain("=");
});
it("JWK thumbprint uses correct key ordering for EC keys", () => {
const jwk = {
kty: "EC",
crv: "P-256",
x: "test-x",
y: "test-y",
};
const canonical = JSON.stringify({
crv: jwk.crv,
kty: jwk.kty,
x: jwk.x,
y: jwk.y,
});
expect(canonical).toBe('{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}');
const keys = Object.keys(JSON.parse(canonical));
expect(keys).toEqual(["crv", "kty", "x", "y"]);
for (let i = 1; i < keys.length; i++) {
expect(keys[i - 1] < keys[i]).toBe(true);
}
});
});