From b7eb6582bab262815f0537e2fd0c1c5970ffd77f Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 15:05:34 -0500 Subject: [PATCH 1/5] Ensure user is properly created if session points to deleted user --- src/auth/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/auth/index.ts b/src/auth/index.ts index 757cd87..f3b4320 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -74,9 +74,18 @@ export async function handleAuth( } } - const isNewUser = !userIdStr; + let isNewUser = !userIdStr; const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId(); const userStub = env.USER.get(id); + + if (userIdStr) { + // Verify user still exists (has a profile) + const profileData = await userStub.getProfile(); + if (Object.keys(profileData).length === 0) { + isNewUser = true; + } + } + userIdStr = id.toString(); // Fetch and Store Avatar (Only for new users) From ff2137fd2710101a1bed9fba203d59a455584c29 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 15:11:54 -0500 Subject: [PATCH 2/5] Correctly handle stale sessions by deleting them and clearing cookies instead of re-creating user with same ID --- src/auth/index.ts | 21 ++++++--- src/index.ts | 94 ++++++++++++++++++++++++++++++++++------ test/integration.spec.ts | 28 ++++++++++++ 3 files changed, 124 insertions(+), 19 deletions(-) diff --git a/src/auth/index.ts b/src/auth/index.ts index f3b4320..57ee2b4 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -49,6 +49,7 @@ export async function handleAuth( const resolveData = await credentialStub.get(profile.id); let userIdStr: string | null = null; + let staleSessionId: string | null = null; if (resolveData) { userIdStr = resolveData.user_id; @@ -68,24 +69,32 @@ export async function handleAuth( if (sessionCookieEncrypted) { const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); if (sessionCookie && sessionCookie.includes(':')) { - userIdStr = sessionCookie.split(':')[1]; + const parts = sessionCookie.split(':'); + staleSessionId = parts[0]; + userIdStr = parts[1]; } } } } - let isNewUser = !userIdStr; - const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId(); - const userStub = env.USER.get(id); - if (userIdStr) { // Verify user still exists (has a profile) + const userStub = env.USER.get(env.USER.idFromString(userIdStr)); const profileData = await userStub.getProfile(); if (Object.keys(profileData).length === 0) { - isNewUser = true; + // User was deleted! + if (staleSessionId) { + try { + await userStub.deleteSession(staleSessionId); + } catch (e) {} + } + userIdStr = null; } } + const isNewUser = !userIdStr; + const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId(); + const userStub = env.USER.get(id); userIdStr = id.toString(); // Fetch and Store Avatar (Only for new users) diff --git a/src/index.ts b/src/index.ts index 166371c..cea61fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -174,7 +174,7 @@ function getActiveProviders(env: StartupAPIEnv): string[] { async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: string, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); if (!user || !isAdmin(user, env)) { - return new Response('Forbidden', { status: 403 }); + return checkAndClearStaleSession(request, env, cookieManager, new Response('Forbidden', { status: 403 })); } const url = new URL(request.url); @@ -282,7 +282,9 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } const { id: doId, profile: initialProfile, credential } = user; @@ -336,7 +338,9 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo async function handleUpdateProfile(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } const profileData = await request.json(); const userStub = env.USER.get(env.USER.idFromString(user.id)); @@ -374,7 +378,9 @@ async function handleAccountMembers( cookieManager: CookieManager, ): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } const userStub = env.USER.get(env.USER.idFromString(user.id)); const memberships = await userStub.getMemberships(); @@ -424,7 +430,9 @@ async function handleAccountDetails( cookieManager: CookieManager, ): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } const userStub = env.USER.get(env.USER.idFromString(user.id)); const memberships = await userStub.getMemberships(); @@ -479,15 +487,67 @@ async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieMa try { const id = env.USER.idFromString(doId); const userStub = env.USER.get(id); - const data = await userStub.validateSession(sessionId); - if (data.valid) return { id: doId, sessionId, profile: data.profile, credential: data.credential }; + const result = await userStub.validateSession(sessionId); + if (result.valid) return { id: doId, sessionId, profile: result.profile, credential: result.credential }; } catch (e) {} return null; } +async function checkAndClearStaleSession( + request: Request, + env: StartupAPIEnv, + cookieManager: CookieManager, + originalResponse: Response, +): Promise { + const cookieHeader = request.headers.get('Cookie'); + if (!cookieHeader) return originalResponse; + + const cookies = parseCookies(cookieHeader); + const sessionCookieEncrypted = cookies['session_id']; + if (!sessionCookieEncrypted) return originalResponse; + + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); + if (!sessionCookie || !sessionCookie.includes(':')) return originalResponse; + + const [sessionId, doId] = sessionCookie.split(':'); + try { + const id = env.USER.idFromString(doId); + const userStub = env.USER.get(id); + + // If we are here, it means getUserFromSession already returned null. + // We want to know IF it's because the user was deleted. + const profile = await userStub.getProfile(); + if (Object.keys(profile).length === 0) { + // User was deleted! Clear session in DO and remove cookie. + await userStub.deleteSession(sessionId); + + const headers = new Headers(originalResponse.headers); + headers.set('Set-Cookie', 'session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'); + + // If it was a redirect, we just update the headers + if (originalResponse.status === 301 || originalResponse.status === 302) { + return new Response(null, { + status: originalResponse.status, + headers, + }); + } + + // Otherwise return new response with same body/status but updated headers + return new Response(originalResponse.body, { + status: originalResponse.status, + headers, + }); + } + } catch (e) {} + + return originalResponse; +} + async function handleListCredentials(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } const userStub = env.USER.get(env.USER.idFromString(user.id)); return Response.json(await userStub.listCredentials()); @@ -495,7 +555,9 @@ async function handleListCredentials(request: Request, env: StartupAPIEnv, cooki async function handleDeleteCredential(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } const { provider } = (await request.json()) as { provider: string }; const userStub = env.USER.get(env.USER.idFromString(user.id)); @@ -509,7 +571,9 @@ async function handleDeleteCredential(request: Request, env: StartupAPIEnv, cook async function handleMeImage(request: Request, env: StartupAPIEnv, type: string, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } try { const id = env.USER.idFromString(user.id); @@ -667,7 +731,9 @@ function parseCookies(cookieHeader: string): Record { async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } try { const id = env.USER.idFromString(user.id); @@ -701,7 +767,9 @@ async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieMana async function handleSwitchAccount(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); - if (!user) return new Response('Unauthorized', { status: 401 }); + if (!user) { + return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 })); + } const { account_id } = (await request.json()) as { account_id: string }; @@ -727,7 +795,7 @@ async function handleSSR( ): Promise { const user = await getUserFromSession(request, env, cookieManager); if (!user) { - return Response.redirect(url.origin + '/', 302); + return checkAndClearStaleSession(request, env, cookieManager, Response.redirect(url.origin + '/', 302)); } const { id: doId, sessionId, profile: initialProfile, credential } = user; diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 43c445f..8e65cf2 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -215,6 +215,34 @@ describe('Integration Tests', () => { expect(validData.valid).toBe(false); }); + it('should proactively clear stale session cookie for deleted user', async () => { + // 1. Create a user and session + const id = env.USER.newUniqueId(); + const idStr = id.toString(); + const stub = env.USER.get(id); + await stub.updateProfile({ name: 'Deletable' }); + const { sessionId } = await stub.createSession(); + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${idStr}`); + + // 2. Delete the user + const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); + await systemStub.deleteUser(idStr); + + // 3. Try to access a page with the stale cookie + const res = await SELF.fetch('http://example.com/users/profile.html', { + headers: { + Cookie: `session_id=${encryptedCookie}`, + }, + redirect: 'manual', + }); + + // Should be redirected to clear the cookie + expect(res.status).toBe(302); + const setCookie = res.headers.get('Set-Cookie'); + expect(setCookie).toContain('session_id=;'); + expect(setCookie).toContain('Max-Age=0'); + }); + it('should not change profile picture when logging in with a secondary credential', async () => { // 1. Setup a user with an initial credential and avatar const id = env.USER.newUniqueId(); From a00b72c102977aa570dbf443354875208144e924 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 15:22:22 -0500 Subject: [PATCH 3/5] Implement return_url support for login and logout redirects --- public/users/accounts.html | 2 +- public/users/power-strip.js | 7 ++++--- public/users/profile.html | 2 +- src/auth/index.ts | 35 ++++++++++++++++++++++++++++++++-- src/index.ts | 38 +++++++++++++++++++++++++++++++------ 5 files changed, 71 insertions(+), 13 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 75f076e..c6d931d 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -16,7 +16,7 @@
- ← Back to Home + ← Back to Home

Account Settings

{{ssr:account_name}}
diff --git a/public/users/power-strip.js b/public/users/power-strip.js index 9b23a43..8dc423c 100644 --- a/public/users/power-strip.js +++ b/public/users/power-strip.js @@ -98,9 +98,10 @@ class PowerStrip extends HTMLElement { } render() { - const googleLink = `${this.basePath}/auth/google`; - const twitchLink = `${this.basePath}/auth/twitch`; - const logoutLink = `${this.basePath}/logout`; + const returnUrl = encodeURIComponent(window.location.href); + const googleLink = `${this.basePath}/auth/google?return_url=${returnUrl}`; + const twitchLink = `${this.basePath}/auth/twitch?return_url=${returnUrl}`; + const logoutLink = `${this.basePath}/logout?return_url=${returnUrl}`; const providersStr = this.getAttribute('providers') || ''; const providers = providersStr.split(','); diff --git a/public/users/profile.html b/public/users/profile.html index 2d01c4a..7ca42cd 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -16,7 +16,7 @@
- ← Back to Home + ← Back to Home

User Profile

{{ssr:profile_name}}
diff --git a/src/auth/index.ts b/src/auth/index.ts index 57ee2b4..6bdd353 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -26,7 +26,13 @@ export async function handleAuth( // Handle Auth Start for (const provider of activeProviders) { if (provider.isMatch(path, authPath)) { - const authUrl = provider.getAuthUrl(`state-${provider.name}`); + const returnUrl = url.searchParams.get('return_url'); + const stateObj = { + nonce: Math.random().toString(36).substring(2), + return_url: returnUrl, + }; + const state = btoa(JSON.stringify(stateObj)); + const authUrl = provider.getAuthUrl(state); return Response.redirect(authUrl, 302); } } @@ -38,6 +44,17 @@ export async function handleAuth( const code = url.searchParams.get('code'); if (!code) return new Response('Missing code', { status: 400 }); + const stateBase64 = url.searchParams.get('state'); + let returnUrl: string | null = null; + if (stateBase64) { + try { + const stateObj = JSON.parse(atob(stateBase64)); + returnUrl = stateObj.return_url; + } catch (e) { + console.error('Failed to parse state', e); + } + } + try { const token = await provider.getToken(code); const profile = await provider.getUserProfile(token.access_token); @@ -175,8 +192,22 @@ export async function handleAuth( const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`); const headers = new Headers(); headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); - headers.set('Location', !isNewUser ? usersPath + 'profile.html' : '/'); + let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/'; + if (returnUrl) { + try { + const parsedReturn = new URL(returnUrl, origin); + if (parsedReturn.origin === origin) { + redirectUrl = parsedReturn.toString(); + } + } catch (e) { + if (returnUrl.startsWith('/')) { + redirectUrl = returnUrl; + } + } + } + + headers.set('Location', redirectUrl); return new Response(null, { status: 302, headers }); } catch (e: any) { return new Response('Auth failed: ' + e.message, { status: 500 }); diff --git a/src/index.ts b/src/index.ts index cea61fd..8643ba7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,7 +124,7 @@ export default { } if (url.pathname === usersPath + 'logout') { - return handleLogout(request, env, usersPath, cookieManager); + return handleLogout(request, env, url, usersPath, cookieManager); } // Admin Routes @@ -690,7 +690,13 @@ async function handleAccountImage( } } -async function handleLogout(request: Request, env: StartupAPIEnv, usersPath: string, cookieManager: CookieManager): Promise { +async function handleLogout( + request: Request, + env: StartupAPIEnv, + url: URL, + usersPath: string, + cookieManager: CookieManager, +): Promise { const cookieHeader = request.headers.get('Cookie'); if (cookieHeader) { const cookies = parseCookies(cookieHeader); @@ -714,7 +720,24 @@ async function handleLogout(request: Request, env: StartupAPIEnv, usersPath: str const headers = new Headers(); headers.set('Set-Cookie', 'session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'); - headers.set('Location', '/'); + + let redirectUrl = '/'; + const returnUrl = url.searchParams.get('return_url'); + if (returnUrl) { + const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin; + try { + const parsedReturn = new URL(returnUrl, origin); + if (parsedReturn.origin === origin) { + redirectUrl = parsedReturn.toString(); + } + } catch (e) { + if (returnUrl.startsWith('/')) { + redirectUrl = returnUrl; + } + } + } + + headers.set('Location', redirectUrl); return new Response(null, { status: 302, headers }); } @@ -872,6 +895,7 @@ async function handleSSR( // Prepare SSR values const replacements: Record = { + home_url: env.ORIGIN_URL || url.origin, providers: getActiveProviders(env).join(','), profile_json: JSON.stringify(data).replace(/"/g, '"'), credentials_json: JSON.stringify(credentials).replace(/"/g, '"'), @@ -887,7 +911,7 @@ async function handleSSR( : '', nav_account_display: account && (account.role === 1 || data.is_admin) ? 'display: block;' : 'display: none;', credentials_list_html: renderCredentialsList(credentials, data.credential?.provider), - link_credentials_html: renderLinkCredentialsList(getActiveProviders(env)), + link_credentials_html: renderLinkCredentialsList(getActiveProviders(env), url.href), }; if (account) { @@ -990,15 +1014,17 @@ function getProviderIcon(provider: string): string { return ''; } -function renderLinkCredentialsList(providers: string[]): string { +function renderLinkCredentialsList(providers: string[], returnUrl?: string): string { if (providers.length === 0) { return ''; } + const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ''; + return providers .map((provider) => { return ` - From f6399bd09eaaf6aa88c2e045c853b074f0ef0f3d Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 15:25:30 -0500 Subject: [PATCH 4/5] Revert Back to Home links to use relative / instead of ORIGIN_URL environment variable --- public/users/accounts.html | 2 +- public/users/profile.html | 2 +- src/index.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index c6d931d..75f076e 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -16,7 +16,7 @@
- ← Back to Home + ← Back to Home

Account Settings

{{ssr:account_name}}
diff --git a/public/users/profile.html b/public/users/profile.html index 7ca42cd..2d01c4a 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -16,7 +16,7 @@
- ← Back to Home + ← Back to Home

User Profile

{{ssr:profile_name}}
diff --git a/src/index.ts b/src/index.ts index 8643ba7..0bd529c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -895,7 +895,6 @@ async function handleSSR( // Prepare SSR values const replacements: Record = { - home_url: env.ORIGIN_URL || url.origin, providers: getActiveProviders(env).join(','), profile_json: JSON.stringify(data).replace(/"/g, '"'), credentials_json: JSON.stringify(credentials).replace(/"/g, '"'), From abb818671b62239f6e18cbe31afdd1e84410e1fe Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 15:35:57 -0500 Subject: [PATCH 5/5] Thoroughly delete DurableObjects for users and accounts using deleteAll() and add resilience to missing tables --- src/AccountDO.ts | 30 +++++---- src/UserDO.ts | 127 +++++++++++++++++++++++---------------- test/integration.spec.ts | 7 ++- 3 files changed, 95 insertions(+), 69 deletions(-) diff --git a/src/AccountDO.ts b/src/AccountDO.ts index e14a389..ce99d2b 100644 --- a/src/AccountDO.ts +++ b/src/AccountDO.ts @@ -63,12 +63,14 @@ export class AccountDO extends DurableObject { } async getInfo() { - const result = this.sql.exec('SELECT key, value FROM account_info'); const info: Record = {}; - for (const row of result) { - // @ts-ignore - info[row.key] = JSON.parse(row.value as string); - } + try { + const result = this.sql.exec('SELECT key, value FROM account_info'); + for (const row of result) { + // @ts-ignore + info[row.key] = JSON.parse(row.value as string); + } + } catch (e) {} return info; } @@ -191,9 +193,6 @@ export class AccountDO extends DurableObject { } } - this.sql.exec('DELETE FROM account_info'); - this.sql.exec('DELETE FROM members'); - // Delete all account images from R2 const prefix = `account/${this.ctx.id.toString()}/`; const listed = await this.env.IMAGE_STORAGE.list({ prefix }); @@ -202,17 +201,22 @@ export class AccountDO extends DurableObject { await this.env.IMAGE_STORAGE.delete(keys); } + // Wipe all Durable Object storage + await this.ctx.storage.deleteAll(); + return { success: true }; } // Billing Implementation private getBillingState(): any { - const result = this.sql.exec("SELECT value FROM account_info WHERE key = 'billing'"); - for (const row of result) { - // @ts-ignore - return JSON.parse(row.value as string); - } + try { + const result = this.sql.exec("SELECT value FROM account_info WHERE key = 'billing'"); + for (const row of result) { + // @ts-ignore + return JSON.parse(row.value as string); + } + } catch (e) {} return { plan_slug: 'free', status: 'active', diff --git a/src/UserDO.ts b/src/UserDO.ts index 6bca011..117bd5b 100644 --- a/src/UserDO.ts +++ b/src/UserDO.ts @@ -55,55 +55,59 @@ export class UserDO extends DurableObject { * @returns A Promise resolving to the session status and user profile. */ async validateSession(sessionId: string) { - // Check session - const sessionResult = this.sql.exec('SELECT * FROM sessions WHERE id = ?', sessionId); - const session = sessionResult.next().value as any; + try { + // Check session + const sessionResult = this.sql.exec('SELECT * FROM sessions WHERE id = ?', sessionId); + const session = sessionResult.next().value as any; - if (!session) { - return { valid: false }; - } + if (!session) { + return { valid: false }; + } - if (session.expires_at < Date.now()) { - return { valid: false, error: 'Expired' }; - } + if (session.expires_at < Date.now()) { + return { valid: false, error: 'Expired' }; + } - let profile: Record = {}; + let profile: Record = {}; - // Get profile data from local 'profile' table - const customProfileResult = this.sql.exec('SELECT key, value FROM profile'); - for (const row of customProfileResult) { - try { - // @ts-ignore - profile[row.key] = JSON.parse(row.value as string); - } catch (e) {} - } - - // Determine login context (provider and subject_id) - const sessionMeta = session.meta ? JSON.parse(session.meta) : {}; - const loginProvider = sessionMeta.provider; - let credential: Record = {}; - - if (loginProvider) { - credential.provider = loginProvider; - const credResult = this.sql.exec('SELECT subject_id FROM user_credentials WHERE provider = ?', loginProvider); - const credRow = credResult.next().value as any; - if (credRow) { - credential.subject_id = credRow.subject_id; + // Get profile data from local 'profile' table + const customProfileResult = this.sql.exec('SELECT key, value FROM profile'); + for (const row of customProfileResult) { + try { + // @ts-ignore + profile[row.key] = JSON.parse(row.value as string); + } catch (e) {} } - } else { - // Fallback: get first available credential if no provider in session - const credResult = this.sql.exec('SELECT provider, subject_id FROM user_credentials LIMIT 1'); - const credRow = credResult.next().value as any; - if (credRow) { - credential.provider = credRow.provider; - credential.subject_id = credRow.subject_id; + + // Determine login context (provider and subject_id) + const sessionMeta = session.meta ? JSON.parse(session.meta) : {}; + const loginProvider = sessionMeta.provider; + let credential: Record = {}; + + if (loginProvider) { + credential.provider = loginProvider; + const credResult = this.sql.exec('SELECT subject_id FROM user_credentials WHERE provider = ?', loginProvider); + const credRow = credResult.next().value as any; + if (credRow) { + credential.subject_id = credRow.subject_id; + } + } else { + // Fallback: get first available credential if no provider in session + const credResult = this.sql.exec('SELECT provider, subject_id FROM user_credentials LIMIT 1'); + const credRow = credResult.next().value as any; + if (credRow) { + credential.provider = credRow.provider; + credential.subject_id = credRow.subject_id; + } } - } - // Ensure the ID is set - profile.id = this.ctx.id.toString(); + // Ensure the ID is set + profile.id = this.ctx.id.toString(); - return { valid: true, profile, credential }; + return { valid: true, profile, credential }; + } catch (e) { + return { valid: false }; + } } /** @@ -112,12 +116,14 @@ export class UserDO extends DurableObject { * @returns A Promise resolving to a JSON response containing the profile key-value pairs. */ async getProfile() { - const result = this.sql.exec('SELECT key, value FROM profile'); const profile: Record = {}; - for (const row of result) { - // @ts-ignore - profile[row.key] = JSON.parse(row.value as string); - } + try { + const result = this.sql.exec('SELECT key, value FROM profile'); + for (const row of result) { + // @ts-ignore + profile[row.key] = JSON.parse(row.value as string); + } + } catch (e) {} return profile; } @@ -206,13 +212,19 @@ export class UserDO extends DurableObject { * @returns A Promise resolving to a JSON response indicating success. */ async deleteSession(sessionId: string) { - this.sql.exec('DELETE FROM sessions WHERE id = ?', sessionId); + try { + this.sql.exec('DELETE FROM sessions WHERE id = ?', sessionId); + } catch (e) {} return { success: true }; } async getMemberships() { - const result = this.sql.exec('SELECT account_id, role, is_current FROM memberships'); - return Array.from(result); + try { + const result = this.sql.exec('SELECT account_id, role, is_current FROM memberships'); + return Array.from(result); + } catch (e) { + return []; + } } async addMembership(account_id: string, role: number, is_current?: boolean) { @@ -303,10 +315,16 @@ export class UserDO extends DurableObject { } async delete() { - this.sql.exec('DELETE FROM profile'); - this.sql.exec('DELETE FROM sessions'); - this.sql.exec('DELETE FROM memberships'); - this.sql.exec('DELETE FROM user_credentials'); + // Delete all credentials from provider-specific CredentialDOs + const credentialsMapping = this.sql.exec('SELECT provider, subject_id FROM user_credentials'); + for (const row of credentialsMapping) { + try { + const stub = this.env.CREDENTIAL.get(this.env.CREDENTIAL.idFromName(row.provider as string)); + await stub.delete(row.subject_id as string); + } catch (e) { + console.error(`Failed to delete credential mapping for provider ${row.provider}`, e); + } + } // Delete all user images from R2 const prefix = `user/${this.ctx.id.toString()}/`; @@ -316,6 +334,9 @@ export class UserDO extends DurableObject { await this.env.IMAGE_STORAGE.delete(keys); } + // Wipe all Durable Object storage + await this.ctx.storage.deleteAll(); + return { success: true }; } } diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 8e65cf2..e23b3ae 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -238,9 +238,10 @@ describe('Integration Tests', () => { // Should be redirected to clear the cookie expect(res.status).toBe(302); - const setCookie = res.headers.get('Set-Cookie'); - expect(setCookie).toContain('session_id=;'); - expect(setCookie).toContain('Max-Age=0'); + const cookies = res.headers.getSetCookie(); + const sessionCookieClear = cookies.find((c) => c.startsWith('session_id=;')); + expect(sessionCookieClear).toBeDefined(); + expect(sessionCookieClear).toContain('Max-Age=0'); }); it('should not change profile picture when logging in with a secondary credential', async () => {