mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-05 09:46:53 +00:00
DPoP in frontend, why not
This commit is contained in:
@@ -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
@@ -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():
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user