Skip to content

Commit ceef2c4

Browse files
ralyodioclaude
andcommitted
fix(auth): qc login "Unauthorized" after session revocation (0.17.2)
The 2026-10-28 rotation of the auth server's signing keys and sessions left every browser holding an access token that looked unexpired but was refused (bad_jwt: unrecognized kid). /cli/authorize sent that token as a hand-built Bearer, so Approve failed with a bare "Unauthorized" while the page still showed the user signed in. - auth-fetch: on a 401, refresh once and retry; a refresh token the server no longer knows (4xx) signs out locally via onExpired, keeping this device's E2EE keys. Network blips do not sign anyone out. - /cli/authorize: let the wrapper add the token; a dead session now shows "Sign in first" and /auth returns to the same approval link (sessionStorage, /cli/authorize only, so no open redirect). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 43fc1f7 commit ceef2c4

8 files changed

Lines changed: 165 additions & 27 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.17.1",
4+
"version": "0.17.2",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎src/app/auth/page.jsx‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState, useEffect, useRef } from 'react';
44
import { useRouter } from 'next/navigation';
5+
import { afterSignInPath } from '@/lib/auth/after-sign-in.js';
56
import { useAuthStore } from '@/lib/stores/auth.js';
67
import { useShallow } from 'zustand/react/shallow';
78
import { useMessagesStore } from '@/lib/stores/messages.js';
@@ -101,7 +102,7 @@ export default function AuthPage() {
101102
hasPin = r.ok ? (await r.json()).hasPin : false;
102103
} catch {}
103104
if (!hasPin && hasLocalKeys) { setVerifiedSession(session); setStep('backup'); return; }
104-
router.push('/chat');
105+
router.push(afterSignInPath());
105106
}
106107

107108
// A CoinPay redirect (popups blocked) hands the session over in a short-lived
@@ -173,7 +174,7 @@ export default function AuthPage() {
173174
}
174175

175176
useEffect(() => {
176-
if (authenticated) router.replace('/chat');
177+
if (authenticated) router.replace(afterSignInPath());
177178
}, [authenticated]);
178179

179180
// CoinPay popup flow: the popup completes OAuth and postMessages the session
@@ -230,7 +231,7 @@ export default function AuthPage() {
230231

231232
if (result.success) {
232233
if (result.isNewUser) {
233-
router.push('/chat');
234+
router.push(afterSignInPath());
234235
} else {
235236
const sessionToken = result.session?.access_token;
236237
const authHeaders = sessionToken ? { 'Authorization': `Bearer ${sessionToken}` } : {};
@@ -251,7 +252,7 @@ export default function AuthPage() {
251252
return;
252253
}
253254
} catch { setVerifiedSession(result.session); setStep('restore'); return; }
254-
router.push('/chat');
255+
router.push(afterSignInPath());
255256
}
256257
} else if (result.requiresUsername) {
257258
setVerifiedSession(result.session);
@@ -281,7 +282,7 @@ export default function AuthPage() {
281282
await keyManager.generateUserKeys();
282283
setStep('backup');
283284
msgStore.success('Account created! Please set a Backup PIN.');
284-
} catch { msgStore.warning('Account created but failed to generate encryption keys.'); router.push('/chat'); }
285+
} catch { msgStore.warning('Account created but failed to generate encryption keys.'); router.push(afterSignInPath()); }
285286
} else { msgStore.error(data.error || 'Failed to create account'); }
286287
} catch { msgStore.error('Failed to create account. Please try again.'); }
287288
}
@@ -301,7 +302,7 @@ export default function AuthPage() {
301302
const backupRes = await fetch('/api/auth/key-backup', { method: 'PUT', headers: backupHeaders, body: JSON.stringify({ encrypted_keys: encryptedData, pin: backupPin }) });
302303
if (!backupRes.ok) { const e = await backupRes.json().catch(() => ({})); throw new Error(e.error || 'Key backup failed'); }
303304
msgStore.success('Backup PIN set and keys backed up! Welcome to QryptChat!');
304-
router.push('/chat');
305+
router.push(afterSignInPath());
305306
} catch (err) { msgStore.error('Failed to set backup PIN: ' + (err.message || 'Unknown error')); }
306307
}
307308

@@ -313,7 +314,7 @@ export default function AuthPage() {
313314
await privateKeyManager.restoreKeysFromServer(restorePin);
314315
msgStore.success('Encryption keys restored successfully!');
315316
setRestorePin(''); setRestoreAttempts(0);
316-
router.push('/chat');
317+
router.push(afterSignInPath());
317318
} catch (err) {
318319
// The server counts wrong PINs and locks restore; its message says how
319320
// many tries are left or how long to wait.
@@ -463,7 +464,7 @@ export default function AuthPage() {
463464
<button type="submit" disabled={loading || !backupPin || !confirmBackupPin} className="primary-button">
464465
{loading ? <><span className="loading-spinner" /> Setting PIN...</> : 'Set Backup PIN'}
465466
</button>
466-
<button type="button" className="secondary-button" onClick={() => { msgStore.warning('Backup PIN skipped. You can set one later in Settings.'); router.push('/chat'); }}>Skip (Not Recommended)</button>
467+
<button type="button" className="secondary-button" onClick={() => { msgStore.warning('Backup PIN skipped. You can set one later in Settings.'); router.push(afterSignInPath()); }}>Skip (Not Recommended)</button>
467468
</div>
468469
</form>
469470
</div>
@@ -485,7 +486,7 @@ export default function AuthPage() {
485486
<button type="submit" disabled={isRestoring || !restorePin} className="primary-button">
486487
{isRestoring ? <><span className="loading-spinner" /> Restoring...</> : 'Restore Keys'}
487488
</button>
488-
<button type="button" className="secondary-button" onClick={() => { msgStore.warning('Key restore skipped.'); router.push('/chat'); }}>Skip</button>
489+
<button type="button" className="secondary-button" onClick={() => { msgStore.warning('Key restore skipped.'); router.push(afterSignInPath()); }}>Skip</button>
489490
</div>
490491
</form>
491492
</div>

‎src/app/cli/authorize/page.jsx‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Link from 'next/link';
66
import { useAuthStore } from '@/lib/stores/auth.js';
77
import { postQuantumEncryption } from '@/lib/crypto/post-quantum-encryption.js';
88
import { matchCode } from '@/lib/auth/cli-match.js';
9+
import { returnAfterSignIn } from '@/lib/auth/after-sign-in.js';
910

1011
/**
1112
* Approve a `qc login`. The terminal opened this page with a PKCE challenge,
@@ -54,13 +55,12 @@ function Authorize() {
5455
...(k768?.publicKey && k768?.privateKey ? { keys768: { ...k768, algorithm: postQuantumEncryption.kemName768 } } : {}),
5556
};
5657
const keyBlob = await postQuantumEncryption.encryptForRecipient(JSON.stringify({ v: 1, ...keys }), kem);
57-
const session = JSON.parse(localStorage.getItem('qrypt_session') || '{}');
58+
// No Authorization here: the app's fetch wrapper adds the session token,
59+
// refreshing it first, and retries once on 401. A hand-built header from
60+
// localStorage skipped all that and failed with a bare "Unauthorized".
5861
const res = await fetch('/api/cli/authorize', {
5962
method: 'POST',
60-
headers: {
61-
'Content-Type': 'application/json',
62-
...(session.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
63-
},
63+
headers: { 'Content-Type': 'application/json' },
6464
body: JSON.stringify({
6565
code_challenge: challenge,
6666
code_challenge_method: 'S256',
@@ -71,6 +71,11 @@ function Authorize() {
7171
}),
7272
});
7373
const body = await res.json().catch(() => ({}));
74+
if (res.status === 401) {
75+
// The wrapper signs a dead session out, which shows "Sign in again"
76+
// below; a refresh that only failed for now lands here instead.
77+
throw new Error('Your sign-in could not be confirmed. Reload this page and try again, or sign in again.');
78+
}
7479
if (!res.ok || !body.code) throw new Error(body.error_description || body.error || `HTTP ${res.status}`);
7580
setStatus('done');
7681
if (redirectUri === 'oob') {
@@ -111,8 +116,17 @@ function Authorize() {
111116
content = (
112117
<>
113118
<h1>Sign in first</h1>
114-
<p>Sign in to qrypt.chat in this browser, then open the link from your terminal again.</p>
115-
<Link className="cli-btn primary" href="/auth">Sign in</Link>
119+
<p>
120+
Sign in to qrypt.chat in this browser (your session may have expired). You will come straight back here to
121+
approve the terminal, so keep <code>qc login</code> running.
122+
</p>
123+
<Link
124+
className="cli-btn primary"
125+
href="/auth"
126+
onClick={() => returnAfterSignIn(window.location.pathname + window.location.search)}
127+
>
128+
Sign in
129+
</Link>
116130
</>
117131
);
118132
} else if (status === 'done' && oobCode) {

‎src/app/client-layout.jsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ import IncomingCallModal from '@/lib/components/voice-call/IncomingCallModal.jsx
1414
import ActiveCallInterface from '@/lib/components/voice-call/ActiveCallInterface.jsx';
1515

1616
// Before anything fetches: every /api/ call carries the session as a Bearer
17-
// token, since passkey/CoinPay/phone sessions have no cookie.
18-
installApiAuth({ refresh: (token) => useAuthStore.getState().refreshSession(token) });
17+
// token, since passkey/CoinPay/phone sessions have no cookie. A session the
18+
// server has revoked signs out locally, keeping this device's E2EE keys.
19+
installApiAuth({
20+
refresh: (token) => useAuthStore.getState().refreshSession(token),
21+
onExpired: () => useAuthStore.getState().logout({ keepKeys: true }),
22+
});
1923

2024
export default function ClientLayout({ children, footerBottom }) {
2125
const pathname = usePathname();

‎src/lib/api/auth-fetch.js‎

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
* wrapped once: a request that names no Authorization gets the current token,
1010
* refreshed first when it is about to expire. Cookies still ride along, and
1111
* the server falls back to them if the token is refused.
12+
*
13+
* A token can also die before its expiry: rotating the auth server's signing
14+
* keys (2026-10-28) made every stored token "Unauthorized" while the app still
15+
* showed people signed in. So a 401 gets one refresh and one retry, and a
16+
* refresh token the server no longer knows ends the session (`onExpired`).
1217
*/
1318

1419
const EARLY_MS = 30_000;
@@ -29,12 +34,32 @@ export async function currentAccessToken({ refresh, storage = globalThis.localSt
2934
const expiresAt = Number(session.expires_at || 0) * 1000;
3035
if (!expiresAt || expiresAt - Date.now() > EARLY_MS) return session.access_token;
3136
if (!session.refresh_token || !refresh) return null;
32-
// One refresh for every request that notices: refresh tokens rotate.
33-
refreshing ??= Promise.resolve(refresh(session.refresh_token)).finally(() => {
37+
const result = await refreshOnce(refresh, session.refresh_token);
38+
return result?.success ? result.session.access_token : null;
39+
}
40+
41+
/** One refresh for every request that notices: refresh tokens rotate. */
42+
function refreshOnce(refresh, refreshToken) {
43+
refreshing ??= Promise.resolve(refresh(refreshToken)).finally(() => {
3444
refreshing = null;
3545
});
36-
const result = await refreshing.catch(() => null);
37-
return result?.success ? result.session.access_token : null;
46+
return refreshing.catch(() => null);
47+
}
48+
49+
/**
50+
* The server refused `sent`. Returns a token worth one retry, or null.
51+
* Calls onExpired when the refresh token itself is dead (a 4xx, not a blip).
52+
*/
53+
async function recoverToken({ sent, refresh, storage, onExpired }) {
54+
const session = readSession(storage);
55+
if (!session?.access_token) return null;
56+
// Someone already refreshed since this request went out: use theirs.
57+
if (session.access_token !== sent) return session.access_token;
58+
if (!session.refresh_token || !refresh) return null;
59+
const result = await refreshOnce(refresh, session.refresh_token);
60+
if (result?.success) return result.session.access_token;
61+
if (result && !result.transient) await onExpired?.();
62+
return null;
3863
}
3964

4065
/** Is this a request to our own /api/ (not the token endpoint itself)? */
@@ -49,27 +74,41 @@ export function isOwnApi(input, origin) {
4974

5075
/**
5176
* Wrap `target.fetch` (window by default) once.
52-
* @param {{ target?: any, storage?: Storage, refresh?: (refreshToken: string) => Promise<{success: boolean, session?: any}> }} [options]
77+
* @param {{ target?: any, storage?: Storage, refresh?: (refreshToken: string) => Promise<{success: boolean, session?: any, transient?: boolean}>, onExpired?: () => any }} [options]
5378
*/
54-
export function installApiAuth({ target = typeof window !== 'undefined' ? window : undefined, refresh, storage } = {}) {
79+
export function installApiAuth({ target = typeof window !== 'undefined' ? window : undefined, refresh, storage, onExpired } = {}) {
5580
if (!target?.fetch || target.__qryptApiAuth) return;
5681
target.__qryptApiAuth = true;
5782
const original = target.fetch.bind(target);
5883
target.fetch = async (input, init = {}) => {
84+
const store = storage ?? target.localStorage;
85+
let sent = null;
5986
try {
6087
if (isOwnApi(input, target.location?.origin)) {
6188
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
6289
if (!headers.has('authorization')) {
63-
const token = await currentAccessToken({ refresh, storage: storage ?? target.localStorage });
90+
const token = await currentAccessToken({ refresh, storage: store });
6491
if (token) {
6592
headers.set('authorization', `Bearer ${token}`);
6693
init = { ...init, headers };
94+
sent = token;
6795
}
6896
}
6997
}
7098
} catch {
7199
// Never let the wrapper break a request; send it as it was.
72100
}
73-
return original(input, init);
101+
const response = await original(input, init);
102+
// A Request's body is spent by the first send, so only plain calls retry.
103+
if (response.status !== 401 || !sent || input instanceof Request) return response;
104+
try {
105+
const token = await recoverToken({ sent, refresh, storage: store, onExpired });
106+
if (!token) return response;
107+
const headers = new Headers(init.headers);
108+
headers.set('authorization', `Bearer ${token}`);
109+
return await original(input, { ...init, headers });
110+
} catch {
111+
return response;
112+
}
74113
};
75114
}

‎src/lib/api/auth-fetch.test.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,37 @@ describe('installApiAuth', () => {
6363
expect(w.calls[0].auth).toBeNull();
6464
});
6565

66+
it('refreshes and retries once when a live-looking token is refused (signing keys rotated)', async () => {
67+
session(3600, 'dead');
68+
const refresh = vi.fn(async () => {
69+
storage.setItem('qrypt_session', JSON.stringify({ access_token: 'new', refresh_token: 'r2', expires_at: Date.now() / 1000 + 3600 }));
70+
return { success: true, session: { access_token: 'new' } };
71+
});
72+
const w = fakeWindow();
73+
w.fetch.mockImplementation(async (input, init = {}) => {
74+
const auth = new Headers(init.headers).get('authorization');
75+
w.calls.push({ input, auth });
76+
return new Response('{}', { status: auth === 'Bearer dead' ? 401 : 200 });
77+
});
78+
installApiAuth({ target: w, refresh });
79+
const res = await w.fetch('/api/cli/authorize', { method: 'POST', body: '{}' });
80+
expect(res.status).toBe(200);
81+
expect(refresh).toHaveBeenCalledTimes(1);
82+
expect(w.calls.map((c) => c.auth)).toEqual(['Bearer dead', 'Bearer new']);
83+
});
84+
85+
it('ends the session when the refresh token is dead too, but not on a blip', async () => {
86+
for (const [result, expired] of [[{ success: false, transient: false }, 1], [{ success: false, transient: true }, 0]]) {
87+
session(3600, 'dead');
88+
const onExpired = vi.fn();
89+
const w = fakeWindow();
90+
w.fetch.mockImplementation(async () => new Response('{}', { status: 401 }));
91+
installApiAuth({ target: w, refresh: async () => result, onExpired });
92+
expect((await w.fetch('/api/x')).status).toBe(401);
93+
expect(onExpired).toHaveBeenCalledTimes(expired);
94+
}
95+
});
96+
6697
it('never targets the token endpoint itself', () => {
6798
expect(isOwnApi('/api/cli/token', ORIGIN)).toBe(false);
6899
expect(isOwnApi('/api/messages/send', ORIGIN)).toBe(true);

‎src/lib/auth/after-sign-in.js‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Where /auth sends someone once they are signed in. Normally /chat; a page
3+
* that needed a sign-in first (a `qc login` approval) can ask to come back.
4+
* Only our own /cli/authorize is accepted, so this is never an open redirect.
5+
*/
6+
7+
const KEY = 'qrypt_after_sign_in';
8+
const ALLOWED = /^\/cli\/authorize\?[^\s\\]*$/;
9+
10+
/** Remember `path` (e.g. location.pathname + location.search) for after sign-in. */
11+
export function returnAfterSignIn(path, storage = globalThis.sessionStorage) {
12+
try {
13+
if (ALLOWED.test(path)) storage?.setItem(KEY, path);
14+
} catch {}
15+
}
16+
17+
/** The path to go to now that sign-in finished; forgets it once read. */
18+
export function afterSignInPath(storage = globalThis.sessionStorage) {
19+
try {
20+
const path = storage?.getItem(KEY);
21+
storage?.removeItem(KEY);
22+
if (path && ALLOWED.test(path)) return path;
23+
} catch {}
24+
return '/chat';
25+
}

‎src/lib/auth/after-sign-in.test.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { afterSignInPath, returnAfterSignIn } from './after-sign-in.js';
3+
4+
function memory() {
5+
const m = new Map();
6+
return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, v), removeItem: (k) => m.delete(k) };
7+
}
8+
9+
describe('after sign-in return path', () => {
10+
it('comes back to a qc login approval once, then goes to /chat', () => {
11+
const s = memory();
12+
returnAfterSignIn('/cli/authorize?code_challenge=abc&state=x', s);
13+
expect(afterSignInPath(s)).toBe('/cli/authorize?code_challenge=abc&state=x');
14+
expect(afterSignInPath(s)).toBe('/chat');
15+
});
16+
17+
it('refuses anything that is not our own approval page', () => {
18+
const s = memory();
19+
for (const p of ['https://evil.example/cli/authorize?x', '//evil.example/x', '/settings', '/cli/authorize']) {
20+
returnAfterSignIn(p, s);
21+
expect(afterSignInPath(s)).toBe('/chat');
22+
}
23+
});
24+
});

0 commit comments

Comments
 (0)