diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index 7ab20e4fa..c56d59217 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -1,7 +1,6 @@ import { safeJwtDecode } from '@agicash/utils'; import type { AuthUser } from '@agicash/wallet-sdk'; import * as Sentry from '@sentry/react-router'; -import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64'; import { queryOptions, useQueryClient, @@ -252,26 +251,19 @@ export const useAuthActions = (): AuthActions => { const initiateGoogleAuth = useCallback(async () => { const { authUrl } = await sdk.auth.initiateGoogleAuth(); - // Stash the current location under a session id and thread it through the - // OAuth state param, so the callback route can restore the deep link. - const authLocation = new URL(authUrl); - const stateParam = authLocation.searchParams.get('state'); - const state = stateParam - ? JSON.parse(new TextDecoder().decode(decodeURLSafe(stateParam))) - : {}; - - const oauthLoginSession = oauthLoginSessionStorage.create({ - search: location.search, - hash: location.hash, - }); - state.sessionId = oauthLoginSession.sessionId; - - const stateEncoded = encodeURLSafe( - new TextEncoder().encode(JSON.stringify(state)), - ); - authLocation.searchParams.set('state', stateEncoded); + // The enclave matches the returned `state` by exact equality, so it must go + // back untouched (Maple repo, services/opensecret/docs/oauth-callbacks.md). + // The current location is stashed under it so the callback route can + // restore the deep link. + const state = new URL(authUrl).searchParams.get('state'); + if (state) { + oauthLoginSessionStorage.create(state, { + search: location.search, + hash: location.hash, + }); + } - return { authUrl: authLocation.href }; + return { authUrl }; }, []); const verifyEmail = useCallback( diff --git a/apps/web-wallet/app/features/user/oauth-login-session-storage.test.ts b/apps/web-wallet/app/features/user/oauth-login-session-storage.test.ts new file mode 100644 index 000000000..3b6bde37d --- /dev/null +++ b/apps/web-wallet/app/features/user/oauth-login-session-storage.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { oauthLoginSessionStorage } from './oauth-login-session-storage'; + +const createMemoryStorage = (): Storage => { + const items = new Map(); + return { + get length() { + return items.size; + }, + clear: () => items.clear(), + getItem: (key) => items.get(key) ?? null, + key: (index) => [...items.keys()][index] ?? null, + removeItem: (key) => { + items.delete(key); + }, + setItem: (key, value) => { + items.set(key, value); + }, + }; +}; + +// Shaped like the enclave's state: base64url JSON, here with padding and symbols. +const state = 'eyJjc3JmX3Rva2VuIjoiYWJjIiwiY2xpZW50X2lkIjoiMTIzIn0='; + +describe('oauthLoginSessionStorage', () => { + beforeEach(() => { + globalThis.sessionStorage = createMemoryStorage(); + }); + + afterEach(() => { + (globalThis as { sessionStorage?: Storage }).sessionStorage = undefined; + }); + + it('stores the login location under a hash of the OAuth state', () => { + const session = oauthLoginSessionStorage.create(state, { + search: '?redirectTo=%2Fsend', + hash: '#token', + }); + + expect(session).toMatchObject({ + search: '?redirectTo=%2Fsend', + hash: '#token', + }); + expect(sessionStorage.key(0)).toMatch(/^oauthLoginSession__[0-9a-f]{64}$/); + expect(oauthLoginSessionStorage.get(state)).toEqual(session); + expect(oauthLoginSessionStorage.get(state.slice(0, -1))).toBeNull(); + }); + + it('removes the session by state', () => { + oauthLoginSessionStorage.create(state, { search: '', hash: '' }); + + oauthLoginSessionStorage.remove(state); + + expect(oauthLoginSessionStorage.get(state)).toBeNull(); + expect(sessionStorage.length).toBe(0); + }); +}); diff --git a/apps/web-wallet/app/features/user/oauth-login-session-storage.ts b/apps/web-wallet/app/features/user/oauth-login-session-storage.ts index 6d3a6d659..77bc0297e 100644 --- a/apps/web-wallet/app/features/user/oauth-login-session-storage.ts +++ b/apps/web-wallet/app/features/user/oauth-login-session-storage.ts @@ -1,37 +1,34 @@ +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex } from '@noble/hashes/utils'; + const oauthLoginSessionStorageKeyPrefix = 'oauthLoginSession_'; type OauthLoginSession = { - sessionId: string; search: string; hash: string; createdAt: string; }; +const storageKey = (state: string) => + `${oauthLoginSessionStorageKeyPrefix}_${bytesToHex(sha256(new TextEncoder().encode(state)))}`; + export const oauthLoginSessionStorage = { - get: (sessionId: string): OauthLoginSession | null => { - const session = sessionStorage.getItem( - `${oauthLoginSessionStorageKeyPrefix}_${sessionId}`, - ); + get: (state: string): OauthLoginSession | null => { + const session = sessionStorage.getItem(storageKey(state)); return session ? (JSON.parse(session) as OauthLoginSession) : null; }, create: ( - session: Omit, + state: string, + location: Omit, ): OauthLoginSession => { - const sessionId = crypto.randomUUID(); const sessionToStore = { - ...session, - sessionId, + ...location, createdAt: new Date().toISOString(), }; - sessionStorage.setItem( - `${oauthLoginSessionStorageKeyPrefix}_${sessionId}`, - JSON.stringify(sessionToStore), - ); + sessionStorage.setItem(storageKey(state), JSON.stringify(sessionToStore)); return sessionToStore; }, - remove: (sessionId: string) => { - sessionStorage.removeItem( - `${oauthLoginSessionStorageKeyPrefix}_${sessionId}`, - ); + remove: (state: string) => { + sessionStorage.removeItem(storageKey(state)); }, }; diff --git a/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx b/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx index 16f1a8614..fe727cf89 100644 --- a/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx +++ b/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx @@ -1,4 +1,3 @@ -import { decodeURLSafe } from '@stablelib/base64'; import { redirect } from 'react-router'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; import { sdk } from '~/features/shared/sdk.client'; @@ -64,10 +63,7 @@ export async function clientLoader({ await invalidateAuthQueries(); - const stateValue = JSON.parse(new TextDecoder().decode(decodeURLSafe(state))); - const oauthLoginSession = oauthLoginSessionStorage.get( - stateValue.sessionId ?? '', - ); + const oauthLoginSession = oauthLoginSessionStorage.get(state); if (!oauthLoginSession) { throw redirect('/'); @@ -79,7 +75,7 @@ export async function clientLoader({ const passthroughSearch = searchParams.size > 0 ? `?${searchParams}` : ''; const url = `${redirectTo}${passthroughSearch}${oauthLoginSession.hash}`; - oauthLoginSessionStorage.remove(oauthLoginSession.sessionId); + oauthLoginSessionStorage.remove(state); // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it // See https://github.com/remix-run/remix/discussions/10721