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
46 changes: 43 additions & 3 deletions packages/agentconnect-ui/src/login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,36 @@ async function fetchStatus(session: string): Promise<RelayStatus | null> {

const TERMINAL: MachineState['kind'][] = ['success', 'expired', 'failed'];

// The OMS relay return URI is now the bare, static `/login` (required to
// match its allowlist exactly), so it can no longer carry the pairing
// session as a query param. Instead, the session is stashed here before the
// redirect to Google and recovered from here on the bounce back, since a
// sessionStorage entry survives that round trip on the same origin.
const SESSION_STORE_KEY = 'oms_login_session';

export function LoginPage() {
const session = getSessionId(window.location.search, window.location.hash);
const relayReturn = isRelayReturn(window.location.search);
// Resolve the session from the url first (fresh CLI announce, or the
// legacy `?s=` shape); fall back to whatever was stashed before the
// redirect to Google. Runs once per mount via the lazy state initializer,
// so the sessionStorage write on a fresh url load never thrashes.
const [session] = useState<string>(() => {
const fromUrl = getSessionId(window.location.search, window.location.hash);
if (fromUrl) {
try {
window.sessionStorage.setItem(SESSION_STORE_KEY, fromUrl);
} catch {
// Best-effort: storage may be unavailable (private browsing, etc).
// This load still has the session; it just won't survive a redirect.
}
return fromUrl;
}
try {
return window.sessionStorage.getItem(SESSION_STORE_KEY) ?? '';
} catch {
return '';
}
});
const [state, setState] = useState<MachineState>(() =>
relayReturn ? reduce(initialState, { type: 'relay-return' }) : initialState
);
Expand All @@ -78,10 +105,23 @@ export function LoginPage() {
// A failed post (a network error, or a 410 for a session that already
// expired) must not leave the user stuck on the "finishing sign in"
// spinner until the ~10-minute poll timeout, so a false result dispatches
// the terminal failed state right away.
// the terminal failed state right away. If the session could not be
// recovered at all (sessionStorage empty, e.g. a different browser or a
// cleared session), there is nothing to post against, so it fails fast
// instead of posting an empty session to the relay.
useEffect(() => {
if (postedRelayCallback.current || !session || !relayReturn) return;
if (postedRelayCallback.current || !relayReturn) return;
postedRelayCallback.current = true;
if (!session) {
dispatch({
type: 'status',
status: {
status: 'error',
message: 'Could not recover the login session. Re-run wallet login in your terminal.'
}
});
return;
}
void postAction(session, { type: 'oidc-callback', callbackUrl: window.location.href }).then(
(ok) => {
if (ok) return;
Expand Down
22 changes: 9 additions & 13 deletions packages/agentconnect-ui/src/login/returnUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,28 @@ describe('isRelayReturn', () => {
expect(isRelayReturn('#abc')).toBe(false);
});

it('is false for a bare `s` with no other query keys', () => {
expect(isRelayReturn('?s=abc')).toBe(false);
it('is true for a bare oauth callback param, no `s` needed', () => {
expect(isRelayReturn('?code=xyz')).toBe(true);
});

it('is true once a relay callback param joins `s`', () => {
it('is true when `s` and an oauth callback param are both present', () => {
expect(isRelayReturn('?s=abc&code=xyz')).toBe(true);
});

it('is true with multiple relay callback params alongside `s`', () => {
expect(isRelayReturn('?s=abc&state=1&code=2')).toBe(true);
it('is true for a bare `state` param', () => {
expect(isRelayReturn('?state=1')).toBe(true);
});

it('is true when the relay reports an oauth error', () => {
expect(isRelayReturn('?s=abc&error=access_denied')).toBe(true);
expect(isRelayReturn('?error=access_denied')).toBe(true);
});

it('is false when a link wrapper appends utm params to a pasted link', () => {
it('is false for `s` alongside a non-oauth query param', () => {
expect(isRelayReturn('?s=abc&utm_source=x')).toBe(false);
});

it('is false when a link wrapper appends a gclid param to a pasted link', () => {
expect(isRelayReturn('?s=abc&gclid=x')).toBe(false);
});

it('is false when `s` is missing, even with other query keys', () => {
expect(isRelayReturn('?code=xyz')).toBe(false);
it('is false for a non-oauth query param with no `s`', () => {
expect(isRelayReturn('?utm_source=x')).toBe(false);
});

it('is false for an empty query string', () => {
Expand Down
32 changes: 18 additions & 14 deletions packages/agentconnect-ui/src/login/returnUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@
// - CLI announce: `/login#<session>` (fragment). The CLI opens this in the
// user's browser with nothing to lose by putting the session in the
// fragment, since there is no round trip through a third party.
// - Relay return: `/login?s=<session>&<oms params>` (query). Once Google
// sign-in bounces through the OMS relay and back, the session has to
// survive as a query param, since a fragment is never sent to (or
// preserved by) a server in the middle of that redirect chain, and the
// relay appends its own callback params (code, state, etc.) alongside it.
// - Relay return: `/login?<oms params>` (query only, e.g. `code`, `state`).
// The return URI registered with the OMS relay must be the bare, static
// `/login` to satisfy its allowlist (an exact-string match), so it can no
// longer carry `?s=`. The pairing session is instead stashed in
// sessionStorage by the component before the redirect to Google, and
// recovered from there when the browser bounces back; these helpers only
// see the query/fragment, not sessionStorage.

// Session id lives in the `?s=` query param (used for the OMS relay return
// URI, since fragments may be consumed by the relay); the `#` fragment is
// kept as a fallback for older announce links.
// Session id lives in the `?s=` query param (used for the legacy relay
// return shape, and still supported); the `#` fragment is the primary
// carrier for the CLI announce link. On an OMS relay return neither is
// present, since the return URI is now the bare, static `/login` — the
// component falls back to sessionStorage in that case.
export function getSessionId(search: string, hash: string): string {
const fromQuery = new URLSearchParams(search).get('s');
if (fromQuery) return fromQuery;
Expand All @@ -30,13 +34,13 @@ const OAUTH_CALLBACK_PARAMS = ['code', 'state', 'error'];

// True when this load is the browser bouncing back from the OMS relay after
// Google sign-in, not a fresh open. We key specifically on the OAuth callback
// params (`code`, `state`, `error`) the relay appends alongside `s`, rather
// than on "any other query key present": a link wrapper or ad click can
// append tracking params (`utm_*`, `gclid`, etc.) to a pasted `/login?s=...`
// link, and that must not be mistaken for a relay return that posts a bogus
// callback to the relay.
// params (`code`, `state`, `error`) the relay appends to the static `/login`
// return URI, rather than on "any other query key present": a link wrapper or
// ad click can append tracking params (`utm_*`, `gclid`, etc.) to a pasted
// `/login?...` link, and that must not be mistaken for a relay return that
// posts a bogus callback to the relay. This no longer requires `s` alongside
// them, since the return URI is static and never carries it.
export function isRelayReturn(search: string): boolean {
const params = new URLSearchParams(search);
if (!params.get('s')) return false;
return OAUTH_CALLBACK_PARAMS.some((key) => params.has(key));
}
2 changes: 1 addition & 1 deletion packages/polygon-agent-cli/src/lib/browser-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('runBrowserLogin', () => {

expect(result).toEqual({ walletAddress: '0xW', loginMethod: 'google' });
expect(calls).toContain('announce:https://ui.test/login#sessionid12345678');
expect(calls).toContain('startOidc:true:https://ui.test/login?s=sessionid12345678');
expect(calls).toContain('startOidc:true:https://ui.test/login');
expect(calls).toContain('completeOidc:https://ui.test/login?s=sessionid12345678');
expect(statuses).toEqual([
{ status: 'auth-url', url: 'https://accounts.google.com/auth' },
Expand Down
14 changes: 9 additions & 5 deletions packages/polygon-agent-cli/src/lib/browser-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,17 @@ export async function runBrowserLogin(
}

if (action.type === 'google') {
// The relay's return page carries this pairing session (`s`) so it
// survives the OAuth round-trip; the OMS relay owns the Google
// callback and bounces the browser back to that page, which posts
// its full URL back to us as an `oidc-callback` action below.
// The OMS relay validates this return URI against the project's
// allowlist with an exact string match, so it must stay the bare,
// static `/login` (no query): a per-login `?s=` would never match a
// static registration. The pairing session instead survives the
// OAuth round trip via sessionStorage on the page itself, which the
// relay bounces back to with its own callback params appended; the
// page then posts the full return URL back to us as an
// `oidc-callback` action below.
const { authorizationUrl } = await wallet.startOidcRedirectAuth({
provider: deps.oidcProviderGoogle,
omsRelayReturnUri: `${opts.uiBase}/login?s=${session}`
omsRelayReturnUri: `${opts.uiBase}/login`
});
await relay.setStatus(session, { status: 'auth-url', url: authorizationUrl });
continue;
Expand Down
Loading