diff --git a/src/gateways/cli-auth-gateway.ts b/src/gateways/cli-auth-gateway.ts index 18caf9a..96686ef 100644 --- a/src/gateways/cli-auth-gateway.ts +++ b/src/gateways/cli-auth-gateway.ts @@ -5,7 +5,7 @@ * Does not talk to the dcd API — the dcd-side session exchange lives in the * login command's PKCE rendezvous flow (see src/commands/login.ts). */ -import { createClient, isAuthApiError } from '@supabase/supabase-js'; +import { createClient, isAuthApiError, isAuthSessionMissingError } from '@supabase/supabase-js'; import type { DcdEnvName } from '../config/environments.js'; import { getSupabaseKey } from '../config/supabase-key.js'; @@ -21,12 +21,29 @@ export class SessionRefreshError extends Error { constructor( message: string, readonly definitive: boolean, + /** Supabase Auth's error code (`session_expired`, …), when it sent one. */ + readonly code?: string, ) { super(message); this.name = 'SessionRefreshError'; } } +/** + * What to tell the user for the refusals that have a plain cause. Supabase + * Auth answers `session_expired` once a session passes the project's maximum + * length or inactivity timeout, and `session_not_found` / + * `refresh_token_not_found` once the session has been ended (signed out + * elsewhere, revoked, or removed after expiring). Any other refusal keeps + * Supabase's own message. + */ +const SIGNED_OUT = 'Your session was signed out. Run `dcd login` to sign in again.'; +const REFRESH_REFUSAL_MESSAGES = new Map([ + ['session_expired', 'Your session expired. Run `dcd login` to sign in again.'], + ['session_not_found', SIGNED_OUT], + ['refresh_token_not_found', SIGNED_OUT], +]); + export interface RefreshedSession { access_token: string; refresh_token: string; @@ -66,15 +83,22 @@ export const CliAuthGateway = { if (error || !data.session || !data.user) { // 4xx from GoTrue means the token itself was rejected; anything else // (fetch failure, 5xx) could succeed on retry with the same token. + // supabase-js turns GoTrue's `session_not_found` into an + // AuthSessionMissingError rather than an AuthApiError (it throws the same + // for an empty refresh token); only `dcd login` fixes either. + const apiError = error != null && isAuthApiError(error) ? error : undefined; + const sessionMissing = error != null && isAuthSessionMissingError(error); const definitive = - error != null && - isAuthApiError(error) && - error.status >= 400 && - error.status < 500; + sessionMissing || + (apiError !== undefined && apiError.status >= 400 && apiError.status < 500); + const code = apiError?.code ?? (sessionMissing ? 'session_not_found' : undefined); + const known = definitive && code ? REFRESH_REFUSAL_MESSAGES.get(code) : undefined; throw new SessionRefreshError( - `Failed to refresh session: ${error?.message ?? 'no session returned'}. ` + - `Run \`dcd login\` again.`, + known ?? + `Failed to refresh session: ${error?.message ?? 'no session returned'}. ` + + `Run \`dcd login\` again.`, definitive, + code, ); } const s = data.session; diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index 04589ea..11e7c99 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -628,8 +628,9 @@ export class ResultsPollingService { if (isDefinitiveAuthFailure(error)) { const reason = error instanceof Error ? error.message : String(error); throw new PollingAuthError( - `Your dcd login session expired and could not be refreshed: ${reason}\n\n` + - `The test is still running in the cloud. Run \`dcd login\`, then reconnect with:\n dcd status --upload-id ${uploadId}`, + // The reason already says to run `dcd login`, so don't repeat it. + `${reason}\n\n` + + `The test is still running in the cloud. Once you've signed in again, reconnect with:\n dcd status --upload-id ${uploadId}`, ); } diff --git a/test/unit/session-refresh.test.ts b/test/unit/session-refresh.test.ts index 646f1a5..a0cf242 100644 --- a/test/unit/session-refresh.test.ts +++ b/test/unit/session-refresh.test.ts @@ -329,7 +329,11 @@ describe('session refresh for long-running processes', () => { stubRealtime(); const seen = stubResults(); CliAuthGateway.refresh = async () => { - throw new SessionRefreshError('Invalid Refresh Token: Already Used', true); + throw new SessionRefreshError( + 'Failed to refresh session: Invalid Refresh Token: Already Used. Run `dcd login` again.', + true, + 'refresh_token_already_used', + ); }; const started = realNow(); @@ -341,14 +345,43 @@ describe('session refresh for long-running processes', () => { } expect(caught).to.be.instanceOf(PollingAuthError); - expect((caught as Error).message).to.include('Invalid Refresh Token'); - expect((caught as Error).message).to.include('dcd login'); - expect((caught as Error).message).to.include('dcd status --upload-id u1'); + const message = (caught as Error).message; + expect(message).to.include('Invalid Refresh Token'); + // Said once, by the refresh error, not again by the poll. + expect(message.match(/dcd login/g)).to.have.length(1); + expect(message).to.include('dcd status --upload-id u1'); expect(seen).to.deep.equal([]); // No trip through the 30-attempt retry budget and its backoff. expect(realNow() - started).to.be.below(2000); }); + it('leads with the expiry when the session passed its limit', async () => { + storeSession('stale', 10); + stubRealtime(); + stubResults(); + CliAuthGateway.refresh = async () => { + throw new SessionRefreshError( + 'Your session expired. Run `dcd login` to sign in again.', + true, + 'session_expired', + ); + }; + + let caught: unknown; + try { + await poll(expiredBearer('stale')); + } catch (error) { + caught = error; + } + + expect(caught).to.be.instanceOf(PollingAuthError); + expect((caught as Error).message).to.equal( + 'Your session expired. Run `dcd login` to sign in again.\n\n' + + "The test is still running in the cloud. Once you've signed in again, reconnect with:\n" + + ' dcd status --upload-id u1', + ); + }); + it('downloads the report after a long poll with a refreshed token', async () => { storeSession('fresh', 3600); const headers: Array> = []; diff --git a/test/unit/supabase-key.test.ts b/test/unit/supabase-key.test.ts index 20bd390..32ce7bf 100644 --- a/test/unit/supabase-key.test.ts +++ b/test/unit/supabase-key.test.ts @@ -15,11 +15,12 @@ describe('Supabase key from /cli-config', () => { const realFetch = globalThis.fetch; let requested: string[]; - function stubFetch(respond: () => Response | Promise): void { + function stubFetch(respond: (url: string) => Response | Promise): void { requested = []; globalThis.fetch = (async (input: string | URL | Request) => { - requested.push(String(input)); - return respond(); + const url = input instanceof Request ? input.url : String(input); + requested.push(url); + return respond(url); }) as typeof fetch; } @@ -93,4 +94,102 @@ describe('Supabase key from /cli-config', () => { // Not definitive, so resolveAuth will not delete the user's login. expect((caught as SessionRefreshError).definitive).to.equal(false); }); + + /** + * Supabase Auth refuses a refresh with a 4xx and an error code: in `code` + * when the response is versioned (2024-01-01 and later), in `error_code` + * before that. The CLI names the common causes instead of echoing them. + */ + describe('when Supabase refuses the refresh', () => { + const storedSession = { + access_token: 'a', + refresh_token: 'r', + expires_at: 0, + user_email: 'u@example.com', + user_id: 'u1', + }; + + function refuseRefresh(token: () => Response): void { + stubFetch((url) => + url.endsWith('/cli-config') + ? json({ supabase_publishable_key: 'sb_publishable_abc' }) + : token(), + ); + } + + const versioned = (status: number, code: string, message: string) => + new Response(JSON.stringify({ code, message }), { + status, + headers: { + 'content-type': 'application/json', + 'x-supabase-api-version': '2024-01-01', + }, + }); + + async function refreshError(): Promise { + try { + await CliAuthGateway.refresh('https://cloud.devicecloud.dev', 'prod', storedSession); + } catch (error) { + expect(error).to.be.instanceOf(SessionRefreshError); + return error as SessionRefreshError; + } + throw new Error('expected the refresh to fail'); + } + + it('says the session expired', async () => { + refuseRefresh(() => versioned(403, 'session_expired', 'Invalid Refresh Token: Session Expired')); + + const error = await refreshError(); + + expect(error.message).to.equal('Your session expired. Run `dcd login` to sign in again.'); + expect(error.code).to.equal('session_expired'); + expect(error.definitive).to.equal(true); + expect(requested[1]).to.equal( + 'https://cloud.devicecloud.dev/auth/v1/token?grant_type=refresh_token', + ); + }); + + it('reads the code from an unversioned response too', async () => { + refuseRefresh(() => + json( + { code: 403, error_code: 'session_expired', msg: 'Invalid Refresh Token: Session Expired (Inactivity)' }, + 403, + ), + ); + + const error = await refreshError(); + + expect(error.message).to.equal('Your session expired. Run `dcd login` to sign in again.'); + expect(error.code).to.equal('session_expired'); + expect(error.definitive).to.equal(true); + }); + + it('says the session was signed out when it no longer exists', async () => { + for (const code of ['refresh_token_not_found', 'session_not_found']) { + refuseRefresh(() => versioned(400, code, 'Invalid Refresh Token: Refresh Token Not Found')); + + const error = await refreshError(); + + expect(error.message).to.equal( + 'Your session was signed out. Run `dcd login` to sign in again.', + ); + expect(error.code).to.equal(code); + expect(error.definitive).to.equal(true); + } + }); + + it("keeps Supabase's message for any other refusal", async () => { + refuseRefresh(() => + versioned(400, 'refresh_token_already_used', 'Invalid Refresh Token: Already Used'), + ); + + const error = await refreshError(); + + expect(error.message).to.equal( + 'Failed to refresh session: Invalid Refresh Token: Already Used. Run `dcd login` again.', + ); + expect(error.code).to.equal('refresh_token_already_used'); + expect(error.definitive).to.equal(true); + }); + }); });