Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 12 additions & 20 deletions apps/web-wallet/app/features/user/auth.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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);
});
});
31 changes: 14 additions & 17 deletions apps/web-wallet/app/features/user/oauth-login-session-storage.ts
Original file line number Diff line number Diff line change
@@ -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<OauthLoginSession, 'sessionId' | 'createdAt'>,
state: string,
location: Omit<OauthLoginSession, 'createdAt'>,
): 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));
},
};
8 changes: 2 additions & 6 deletions apps/web-wallet/app/routes/_auth.oauth.$provider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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('/');
Expand All @@ -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
Expand Down
Loading