Skip to content

Commit a380666

Browse files
MajorTalclaude
andcommitted
feat(auth): cookie travels alongside Bearer + server-side sign-out
Conservative cookie-migration subset (Option A-light). The v3 gateway sets an HttpOnly __Host-Http-r402_session cookie on successful auth but Kychon was only persisting the JSON access_token in localStorage and sending it as Authorization: Bearer. Two consequences: 1. The cookie was being set but never traveled on subsequent requests (browsers don't send credentials cross-origin without `credentials: 'include'` opt-in). Defense-in-depth missing. 2. signOut() only removed localStorage — the server-side session stayed valid until the access_token's natural expiry. Now every auth.v1/* fetch and the @kychon/sdk capability call carry `credentials: 'include'`, so the cookie travels both ways. signOut() becomes async and POSTs /auth/v1/sign-out, killing the server-side session before clearing the local cache. SignInBarIsland's local copy of signOut() (which previously only cleared localStorage) now routes through the shared @/lib/auth.signOut() so the UI sign-out button participates in the full flow. Bearer header kept alongside the cookie — gateway accepts either, and during the BC window sending both is the safest combination (cookie gives platform-side enforcement; Bearer keeps the existing JS-readable session payload working for getRole()/isAdmin()/getSessionEmail()). Tasks #5#7's bigger refactor (drop localStorage entirely, convert getRole() to async, swap to auth.whoami capability for session state) remain deferred — they touch 8 components across the React island graph and warrant a focused change after this cookie path is verified on the demos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3ca5915 commit a380666

5 files changed

Lines changed: 54 additions & 7 deletions

File tree

packages/sdk/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,10 @@ export function createKychonClient(options: KychonClientOptions) {
628628

629629
const res = await fetchImpl(transport.endpoint, {
630630
method: 'POST',
631+
// include cookies so the platform's HttpOnly `__Host-Http-r402_session`
632+
// travels alongside the Bearer header from requestHeaders(). v3 gateway
633+
// accepts either; sending both is defense-in-depth during the BC window.
634+
credentials: 'include',
631635
headers: await requestHeaders(true),
632636
body: JSON.stringify(envelope),
633637
});

src/components/kychon/SignInBarIsland.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
DropdownMenuItem,
1010
DropdownMenuTrigger,
1111
} from '@/components/kychon/ui';
12-
import { getSession } from '@/lib/auth';
12+
import { getSession, signOut as authSignOut } from '@/lib/auth';
1313
import { openAuthModal } from '@/lib/auth-modal-events';
1414
import { getAvailableLocales, getLocale, setLanguage, t } from '@/lib/i18n';
1515

@@ -112,10 +112,14 @@ function SignInBarIsland({ showLangToggle, showThemeToggle }: SignInBarProps) {
112112
document.dispatchEvent(new CustomEvent('wl-locale-changed', { detail: { locale: next } }));
113113
}
114114

115-
function signOut(): void {
116-
localStorage.removeItem('wl_session');
115+
async function signOut(): Promise<void> {
116+
// Routes through @/lib/auth.signOut so the server-side sign-out POST
117+
// happens and the HttpOnly cookie is killed at the gateway, not just
118+
// the localStorage cache. Fires wl-auth-changed before the redirect
119+
// so any synchronous subscribers (e.g. AdminBar) update before we
120+
// navigate away.
117121
document.dispatchEvent(new CustomEvent('wl-auth-changed'));
118-
window.location.href = '/';
122+
await authSignOut();
119123
}
120124

121125
const showLanguage = showLangToggle && locales.length >= 2;

src/lib/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ async function refreshToken(): Promise<any> {
8383
if (!session?.refresh_token) return null;
8484
const res = await fetch(`${getAPI()}/auth/v1/token?grant_type=refresh_token`, {
8585
method: 'POST',
86+
credentials: 'include',
8687
headers: { 'Content-Type': 'application/json', apikey: getAnonKey() },
8788
body: JSON.stringify({ refresh_token: session.refresh_token }),
8889
});

src/lib/auth.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ export async function signInWithGoogle(): Promise<void> {
292292

293293
const res = await fetch(`${getAPI()}/auth/v1/oauth/google/start`, {
294294
method: 'POST',
295+
credentials: 'include',
295296
headers,
296297
body: JSON.stringify({
297298
redirect_url: `${window.location.origin}/`,
@@ -333,6 +334,7 @@ export async function handleOAuthCallback(): Promise<any> {
333334

334335
const res = await fetch(`${getAPI()}/auth/v1/token?grant_type=authorization_code`, {
335336
method: 'POST',
337+
credentials: 'include',
336338
headers: { 'Content-Type': 'application/json', apikey: getAnonKey() },
337339
body: JSON.stringify({ code, code_verifier: verifier }),
338340
});
@@ -397,6 +399,7 @@ export async function handleMagicLinkCallback(): Promise<any> {
397399
window.history.replaceState(null, '', cleanMagicLinkCallbackUrl());
398400
const res = await fetch(`${getAPI()}/auth/v1/token?grant_type=magic_link`, {
399401
method: 'POST',
402+
credentials: 'include',
400403
headers: publicAuthHeaders(),
401404
body: JSON.stringify({ token }),
402405
});
@@ -422,6 +425,7 @@ export async function requestGoogleConnectionLink(email: string): Promise<void>
422425
const redirectUrl = `${window.location.origin}${currentUrlWithParam(GOOGLE_LINK_RESUME_PARAM, '1')}`;
423426
const res = await fetch(`${getAPI()}/auth/v1/magic-link`, {
424427
method: 'POST',
428+
credentials: 'include',
425429
headers: publicAuthHeaders(),
426430
body: JSON.stringify({
427431
email: normalizedEmail,
@@ -441,6 +445,7 @@ export async function requestGoogleConnectionLink(email: string): Promise<void>
441445
export async function signUp(email: string, password: string): Promise<any> {
442446
const res = await fetch(`${getAPI()}/auth/v1/signup`, {
443447
method: 'POST',
448+
credentials: 'include',
444449
headers: { 'Content-Type': 'application/json', apikey: getAnonKey() },
445450
body: JSON.stringify({ email, password }),
446451
});
@@ -454,6 +459,7 @@ export async function signUp(email: string, password: string): Promise<any> {
454459
export async function signIn(email: string, password: string): Promise<any> {
455460
const res = await fetch(`${getAPI()}/auth/v1/token?grant_type=password`, {
456461
method: 'POST',
462+
credentials: 'include',
457463
headers: { 'Content-Type': 'application/json', apikey: getAnonKey() },
458464
body: JSON.stringify({ email, password }),
459465
});
@@ -472,6 +478,7 @@ export async function setPassword(newPassword: string, currentPassword?: string)
472478

473479
const res = await fetch(`${getAPI()}/auth/v1/user/password`, {
474480
method: 'PUT',
481+
credentials: 'include',
475482
headers: {
476483
'Content-Type': 'application/json',
477484
apikey: getAnonKey(),
@@ -499,6 +506,7 @@ export async function getCurrentUser(appOrigin = window.location.origin): Promis
499506
const url = new URL(`${getAPI()}/auth/v1/user`);
500507
if (appOrigin) url.searchParams.set('app_origin', appOrigin);
501508
const res = await fetch(url.toString(), {
509+
credentials: 'include',
502510
headers: {
503511
apikey: getAnonKey(),
504512
Authorization: `Bearer ${requireAccessToken()}`,
@@ -542,6 +550,7 @@ export function passkeysSupported(): boolean {
542550

543551
export async function listPasskeys(): Promise<any[]> {
544552
const res = await fetch(`${getAPI()}/auth/v1/passkeys`, {
553+
credentials: 'include',
545554
headers: {
546555
apikey: getAnonKey(),
547556
Authorization: `Bearer ${requireAccessToken()}`,
@@ -559,6 +568,7 @@ export async function registerPasskey(label = 'Kychon admin passkey'): Promise<a
559568
const accessToken = requireAccessToken();
560569
const optionsRes = await fetch(`${getAPI()}/auth/v1/passkeys/register/options`, {
561570
method: 'POST',
571+
credentials: 'include',
562572
headers: {
563573
'Content-Type': 'application/json',
564574
apikey: getAnonKey(),
@@ -578,6 +588,7 @@ export async function registerPasskey(label = 'Kychon admin passkey'): Promise<a
578588

579589
const verifyRes = await fetch(`${getAPI()}/auth/v1/passkeys/register/verify`, {
580590
method: 'POST',
591+
credentials: 'include',
581592
headers: {
582593
'Content-Type': 'application/json',
583594
apikey: getAnonKey(),
@@ -602,6 +613,7 @@ export async function signInWithPasskey(email?: string): Promise<any> {
602613
}
603614
const optionsRes = await fetch(`${getAPI()}/auth/v1/passkeys/login/options`, {
604615
method: 'POST',
616+
credentials: 'include',
605617
headers: publicAuthHeaders(),
606618
body: JSON.stringify({
607619
app_origin: window.location.origin,
@@ -620,6 +632,7 @@ export async function signInWithPasskey(email?: string): Promise<any> {
620632

621633
const verifyRes = await fetch(`${getAPI()}/auth/v1/passkeys/login/verify`, {
622634
method: 'POST',
635+
credentials: 'include',
623636
headers: publicAuthHeaders(),
624637
body: JSON.stringify({
625638
challenge_id: optionsBody?.challenge_id,
@@ -697,7 +710,27 @@ function credentialToJSON(credential: PublicKeyCredential): any {
697710
return json;
698711
}
699712

700-
export function signOut(): void {
713+
export async function signOut(): Promise<void> {
714+
// Server-side sign-out so the HttpOnly cookie is killed at the gateway,
715+
// not just the localStorage cache. We send credentials so the gateway
716+
// can identify the session to invalidate. Best-effort: even if the POST
717+
// fails we still clear the local cache and redirect — a stale server
718+
// session is worse than a stale client cache, but better than neither.
719+
try {
720+
const session = getSession();
721+
const headers: Record<string, string> = { apikey: getAnonKey() };
722+
if (typeof session?.access_token === 'string' && session.access_token) {
723+
headers.Authorization = `Bearer ${session.access_token}`;
724+
}
725+
await fetch(`${getAPI()}/auth/v1/sign-out`, {
726+
method: 'POST',
727+
credentials: 'include',
728+
headers,
729+
});
730+
} catch {
731+
// Network failure during sign-out is not user-actionable. Continue to
732+
// clear local state so the UI reflects signed-out.
733+
}
701734
localStorage.removeItem('wl_session');
702735
window.location.href = '/';
703736
}

tests/unit/auth.test.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,13 +479,18 @@ describe('auth.js', () => {
479479
});
480480

481481
describe('signOut', () => {
482-
it('clears session', () => {
482+
it('clears session and POSTs server-side sign-out', async () => {
483483
localStorage.setItem('wl_session', JSON.stringify({ access_token: 'tok' }));
484+
global.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
484485
// Mock window.location
485486
delete global.window.location;
486487
global.window.location = { href: '' };
487-
auth.signOut();
488+
await auth.signOut();
488489
expect(localStorage.getItem('wl_session')).toBeNull();
490+
const [url, init] = global.fetch.mock.calls[0] ?? [];
491+
expect(String(url)).toContain('/auth/v1/sign-out');
492+
expect(init?.method).toBe('POST');
493+
expect(init?.credentials).toBe('include');
489494
});
490495
});
491496
});

0 commit comments

Comments
 (0)