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
36 changes: 15 additions & 21 deletions apps/web-wallet/app/features/user/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
verifyEmail as osVerifyEmail,
} from '@agicash/opensecret';
import * as Sentry from '@sentry/react-router';
import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64';
import {
queryOptions,
useQueryClient,
Expand Down Expand Up @@ -224,26 +223,21 @@ export const useAuthActions = (): AuthActions => {
);

const initiateGoogleAuth = useCallback(async () => {
const response = await osInitiateGoogleAuth('');

const authLocation = new URL(response.auth_url);
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);

return { authUrl: authLocation.href };
const { auth_url: authUrl } = await osInitiateGoogleAuth('');

// 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 };
}, []);

const signUpGuest = useCallback(async () => {
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,5 +1,4 @@
import { handleGoogleCallback } from '@agicash/opensecret';
import { decodeURLSafe } from '@stablelib/base64';
import { redirect } from 'react-router';
import { LoadingScreen } from '~/features/loading/LoadingScreen';
import { getErrorMessage } from '~/features/shared/error';
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