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
38 changes: 31 additions & 7 deletions src/gateways/cli-auth-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, string>([
['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;
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/services/results-polling.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
}

Expand Down
41 changes: 37 additions & 4 deletions test/unit/session-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<Record<string, string>> = [];
Expand Down
105 changes: 102 additions & 3 deletions test/unit/supabase-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ describe('Supabase key from /cli-config', () => {
const realFetch = globalThis.fetch;
let requested: string[];

function stubFetch(respond: () => Response | Promise<Response>): void {
function stubFetch(respond: (url: string) => Response | Promise<Response>): 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;
}

Expand Down Expand Up @@ -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<SessionRefreshError> {
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);
});
});
});
Loading