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
13 changes: 2 additions & 11 deletions src/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
writeGlobalConfig,
type EnsembleUserConfig,
} from '../config/globalConfig.js';
import { decodeIdTokenClaims, getIdTokenExpiryMs, isTokenExpired } from './token.js';
import { decodeIdTokenClaims, isTokenExpired } from './token.js';
import { getEnsembleFirebaseApiKey } from '../config/env.js';

const DEFAULT_REFRESH_API_BASE = 'https://securetoken.googleapis.com/v1/token';
Expand Down Expand Up @@ -37,7 +37,6 @@ async function refreshIdToken(refreshToken: string): Promise<{
idToken: string;
refreshToken: string;
userId?: string;
expiresAt?: number;
}> {
const apiKey = getEnsembleFirebaseApiKey();
if (!apiKey) {
Expand All @@ -62,17 +61,10 @@ async function refreshIdToken(refreshToken: string): Promise<{
throw new Error(`Token refresh failed: ${reason}`);
}

const expiresInSec = Number(data.expires_in);
const expiresAt =
Number.isFinite(expiresInSec) && expiresInSec > 0
? Date.now() + expiresInSec * 1000
: getIdTokenExpiryMs(data.id_token);

return {
idToken: data.id_token,
refreshToken: data.refresh_token ?? refreshToken,
userId: data.user_id,
expiresAt,
};
}

Expand Down Expand Up @@ -124,7 +116,7 @@ export async function getValidAuthSession(): Promise<AuthSessionResult> {
};
}

if (!isTokenExpired(user.idToken, user.expiresAt)) {
if (!isTokenExpired(user.idToken)) {
return {
ok: true,
idToken: user.idToken,
Expand Down Expand Up @@ -152,7 +144,6 @@ export async function getValidAuthSession(): Promise<AuthSessionResult> {
email: claims.email ?? user.email,
idToken: refreshed.idToken,
refreshToken: refreshed.refreshToken,
expiresAt: refreshed.expiresAt,
};
const updatedConfig: EnsembleUserConfig = {
...config,
Expand Down
29 changes: 2 additions & 27 deletions src/auth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,33 +40,8 @@ export function getIdTokenExpiryMs(idToken: string): number | undefined {
return typeof exp === 'number' ? exp * 1000 : undefined;
}

export function normalizeExpiresAt(expiresAt: unknown): number | undefined {
if (typeof expiresAt === 'number' && Number.isFinite(expiresAt)) {
// Accept either epoch seconds or epoch milliseconds.
return expiresAt > 1_000_000_000_000 ? expiresAt : expiresAt * 1000;
}

if (typeof expiresAt === 'string') {
const trimmed = expiresAt.trim();
if (!trimmed) return undefined;

// Numeric strings are supported too.
const asNumber = Number(trimmed);
if (Number.isFinite(asNumber)) {
return asNumber > 1_000_000_000_000 ? asNumber : asNumber * 1000;
}

const dateMs = Date.parse(trimmed);
if (!Number.isNaN(dateMs)) {
return dateMs;
}
}

return undefined;
}

export function isTokenExpired(idToken: string, expiresAt?: number, bufferSeconds = 60): boolean {
const expiry = normalizeExpiresAt(expiresAt) ?? getIdTokenExpiryMs(idToken);
export function isTokenExpired(idToken: string, bufferSeconds = 60): boolean {
const expiry = getIdTokenExpiryMs(idToken);
if (expiry === undefined) return true;
return expiry <= Date.now() + bufferSeconds * 1000;
}
20 changes: 4 additions & 16 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,7 @@ import {
writeGlobalConfig,
type EnsembleUserConfig,
} from '../config/globalConfig.js';
import {
decodeIdTokenClaims,
getIdTokenExpiryMs,
isTokenExpired,
normalizeExpiresAt,
} from '../auth/token.js';
import { decodeIdTokenClaims, isTokenExpired } from '../auth/token.js';
import { resolveVerboseFlag } from '../core/cliError.js';
import { getEnsembleAuthBaseUrl } from '../config/env.js';
import { ui } from '../core/ui.js';
Expand Down Expand Up @@ -61,20 +56,19 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
const idToken = existing?.user?.idToken;
if (idToken && !isTokenExpired(idToken)) {
const claims = decodeIdTokenClaims(idToken);
const normalizedExpiresAt = existing.user?.expiresAt ?? getIdTokenExpiryMs(idToken);
const mergedUser = {
...(existing.user ?? { uid: claims.uid ?? 'cli-user', idToken }),
uid: existing.user?.uid ?? claims.uid ?? 'cli-user',
name: existing.user?.name ?? claims.name ?? undefined,
email: existing.user?.email ?? claims.email ?? undefined,
idToken,
expiresAt: normalizedExpiresAt,
refreshToken: existing.user?.refreshToken,
};

if (
existing.user?.uid !== mergedUser.uid ||
existing.user?.name !== mergedUser.name ||
existing.user?.email !== mergedUser.email ||
existing.user?.expiresAt !== mergedUser.expiresAt
existing.user?.refreshToken !== mergedUser.refreshToken
) {
await writeGlobalConfig({
...existing,
Expand All @@ -101,7 +95,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
const tokenPromise = new Promise<{
token: string;
refreshToken?: string;
expiresAt?: number;
}>((resolve, reject) => {
const timeout = setTimeout(() => {
server.close();
Expand Down Expand Up @@ -129,7 +122,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
const data = JSON.parse(body) as {
token?: string;
refreshToken?: string;
expiresAt?: number | string;
state?: string;
};
// Some auth providers may not echo back our `cliState` in the callback payload.
Expand All @@ -145,7 +137,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
resolve({
token: data.token,
refreshToken: typeof data.refreshToken === 'string' ? data.refreshToken : undefined,
expiresAt: normalizeExpiresAt(data.expiresAt),
});
} else {
res.writeHead(400, {
Expand Down Expand Up @@ -191,7 +182,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
let callbackData: {
token: string;
refreshToken?: string;
expiresAt?: number;
};
try {
callbackData = await tokenPromise;
Expand All @@ -203,7 +193,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {

const token = callbackData.token;
const { uid, name, email } = decodeIdTokenClaims(token);
const expiresAt = callbackData.expiresAt ?? getIdTokenExpiryMs(token);

const newConfig: EnsembleUserConfig = {
...existing,
Expand All @@ -213,7 +202,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
email: email ?? undefined,
idToken: token,
refreshToken: callbackData.refreshToken,
expiresAt,
},
};

Expand Down
1 change: 0 additions & 1 deletion src/config/globalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export interface EnsembleUserConfig {
email?: string;
idToken: string;
refreshToken?: string;
expiresAt?: number;
};
[key: string]: unknown;
}
Expand Down
32 changes: 32 additions & 0 deletions tests/auth/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,38 @@ describe('getValidAuthSession', () => {
}
});

it('returns ok without refresh when jwt is valid even if legacy config has stale expiresAt', async () => {
const token = makeJwt({
userId: 'u1',
email: 'a@b.com',
exp: Math.floor(Date.now() / 1000) + 3600,
});
vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({
user: {
uid: 'u1',
email: 'a@b.com',
idToken: token,
refreshToken: 'refresh-123',
expiresAt: Date.now() - 3600_000,
},
} as EnsembleUserConfig);

const fetchMock = vi.fn();
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchMock;

const result = await getValidAuthSession();

globalThis.fetch = originalFetch;

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.idToken).toBe(token);
expect(result.refreshed).toBe(false);
}
expect(fetchMock).not.toHaveBeenCalled();
});

it('refreshes token when expired and refresh token exists', async () => {
const oldToken = makeJwt({
userId: 'u1',
Expand Down
45 changes: 5 additions & 40 deletions tests/auth/token.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
decodeIdTokenClaims,
getIdTokenExpiryMs,
normalizeExpiresAt,
isTokenExpired,
} from '../../src/auth/token.js';
import { decodeIdTokenClaims, getIdTokenExpiryMs, isTokenExpired } from '../../src/auth/token.js';

function base64urlEncode(str: string): string {
return Buffer.from(str, 'utf8')
Expand Down Expand Up @@ -86,35 +81,6 @@ describe('getIdTokenExpiryMs', () => {
});
});

describe('normalizeExpiresAt', () => {
it('accepts epoch milliseconds as-is', () => {
const ms = 1735689600000;
expect(normalizeExpiresAt(ms)).toBe(ms);
});

it('converts epoch seconds to milliseconds', () => {
expect(normalizeExpiresAt(1735689600)).toBe(1735689600000);
});

it('accepts numeric string (seconds)', () => {
expect(normalizeExpiresAt('1735689600')).toBe(1735689600000);
});

it('accepts numeric string (milliseconds)', () => {
expect(normalizeExpiresAt('1735689600000')).toBe(1735689600000);
});

it('returns undefined for empty string', () => {
expect(normalizeExpiresAt('')).toBeUndefined();
expect(normalizeExpiresAt(' ')).toBeUndefined();
});

it('returns undefined for invalid input', () => {
expect(normalizeExpiresAt(null)).toBeUndefined();
expect(normalizeExpiresAt(undefined)).toBeUndefined();
});
});

describe('isTokenExpired', () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down Expand Up @@ -142,14 +108,13 @@ describe('isTokenExpired', () => {
vi.setSystemTime(new Date('2025-01-01T12:00:30Z'));
// exp = 2025-01-01 13:00 UTC; with 60s buffer, token still valid at 12:00:30
const token = makeJwt({ userId: 'u1', exp: 1735736400 });
expect(isTokenExpired(token, undefined, 60)).toBe(false);
expect(isTokenExpired(token, undefined, 0)).toBe(false);
expect(isTokenExpired(token, 60)).toBe(false);
expect(isTokenExpired(token, 0)).toBe(false);
});

it('uses expiresAt when provided', () => {
it('returns true when jwt has no exp claim', () => {
vi.setSystemTime(new Date('2025-01-01T12:00:00Z'));
const token = makeJwt({ userId: 'u1' });
const futureMs = new Date('2025-06-01').getTime();
expect(isTokenExpired(token, futureMs)).toBe(false);
expect(isTokenExpired(token)).toBe(true);
});
});
Loading