Skip to content

Commit 46a6fe1

Browse files
authored
fix: improve token expiration logic to prefer JWT expiry over stale config (#10)
* fix: improve token expiration logic to prefer JWT expiry over stale config * remove expiresAt handling from auth session and token logic
1 parent 1984d22 commit 46a6fe1

6 files changed

Lines changed: 45 additions & 95 deletions

File tree

src/auth/session.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
writeGlobalConfig,
44
type EnsembleUserConfig,
55
} from '../config/globalConfig.js';
6-
import { decodeIdTokenClaims, getIdTokenExpiryMs, isTokenExpired } from './token.js';
6+
import { decodeIdTokenClaims, isTokenExpired } from './token.js';
77
import { getEnsembleFirebaseApiKey } from '../config/env.js';
88

99
const DEFAULT_REFRESH_API_BASE = 'https://securetoken.googleapis.com/v1/token';
@@ -37,7 +37,6 @@ async function refreshIdToken(refreshToken: string): Promise<{
3737
idToken: string;
3838
refreshToken: string;
3939
userId?: string;
40-
expiresAt?: number;
4140
}> {
4241
const apiKey = getEnsembleFirebaseApiKey();
4342
if (!apiKey) {
@@ -62,17 +61,10 @@ async function refreshIdToken(refreshToken: string): Promise<{
6261
throw new Error(`Token refresh failed: ${reason}`);
6362
}
6463

65-
const expiresInSec = Number(data.expires_in);
66-
const expiresAt =
67-
Number.isFinite(expiresInSec) && expiresInSec > 0
68-
? Date.now() + expiresInSec * 1000
69-
: getIdTokenExpiryMs(data.id_token);
70-
7164
return {
7265
idToken: data.id_token,
7366
refreshToken: data.refresh_token ?? refreshToken,
7467
userId: data.user_id,
75-
expiresAt,
7668
};
7769
}
7870

@@ -124,7 +116,7 @@ export async function getValidAuthSession(): Promise<AuthSessionResult> {
124116
};
125117
}
126118

127-
if (!isTokenExpired(user.idToken, user.expiresAt)) {
119+
if (!isTokenExpired(user.idToken)) {
128120
return {
129121
ok: true,
130122
idToken: user.idToken,
@@ -152,7 +144,6 @@ export async function getValidAuthSession(): Promise<AuthSessionResult> {
152144
email: claims.email ?? user.email,
153145
idToken: refreshed.idToken,
154146
refreshToken: refreshed.refreshToken,
155-
expiresAt: refreshed.expiresAt,
156147
};
157148
const updatedConfig: EnsembleUserConfig = {
158149
...config,

src/auth/token.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,33 +40,8 @@ export function getIdTokenExpiryMs(idToken: string): number | undefined {
4040
return typeof exp === 'number' ? exp * 1000 : undefined;
4141
}
4242

43-
export function normalizeExpiresAt(expiresAt: unknown): number | undefined {
44-
if (typeof expiresAt === 'number' && Number.isFinite(expiresAt)) {
45-
// Accept either epoch seconds or epoch milliseconds.
46-
return expiresAt > 1_000_000_000_000 ? expiresAt : expiresAt * 1000;
47-
}
48-
49-
if (typeof expiresAt === 'string') {
50-
const trimmed = expiresAt.trim();
51-
if (!trimmed) return undefined;
52-
53-
// Numeric strings are supported too.
54-
const asNumber = Number(trimmed);
55-
if (Number.isFinite(asNumber)) {
56-
return asNumber > 1_000_000_000_000 ? asNumber : asNumber * 1000;
57-
}
58-
59-
const dateMs = Date.parse(trimmed);
60-
if (!Number.isNaN(dateMs)) {
61-
return dateMs;
62-
}
63-
}
64-
65-
return undefined;
66-
}
67-
68-
export function isTokenExpired(idToken: string, expiresAt?: number, bufferSeconds = 60): boolean {
69-
const expiry = normalizeExpiresAt(expiresAt) ?? getIdTokenExpiryMs(idToken);
43+
export function isTokenExpired(idToken: string, bufferSeconds = 60): boolean {
44+
const expiry = getIdTokenExpiryMs(idToken);
7045
if (expiry === undefined) return true;
7146
return expiry <= Date.now() + bufferSeconds * 1000;
7247
}

src/commands/login.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,7 @@ import {
99
writeGlobalConfig,
1010
type EnsembleUserConfig,
1111
} from '../config/globalConfig.js';
12-
import {
13-
decodeIdTokenClaims,
14-
getIdTokenExpiryMs,
15-
isTokenExpired,
16-
normalizeExpiresAt,
17-
} from '../auth/token.js';
12+
import { decodeIdTokenClaims, isTokenExpired } from '../auth/token.js';
1813
import { resolveVerboseFlag } from '../core/cliError.js';
1914
import { getEnsembleAuthBaseUrl } from '../config/env.js';
2015
import { ui } from '../core/ui.js';
@@ -61,20 +56,19 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
6156
const idToken = existing?.user?.idToken;
6257
if (idToken && !isTokenExpired(idToken)) {
6358
const claims = decodeIdTokenClaims(idToken);
64-
const normalizedExpiresAt = existing.user?.expiresAt ?? getIdTokenExpiryMs(idToken);
6559
const mergedUser = {
66-
...(existing.user ?? { uid: claims.uid ?? 'cli-user', idToken }),
6760
uid: existing.user?.uid ?? claims.uid ?? 'cli-user',
6861
name: existing.user?.name ?? claims.name ?? undefined,
6962
email: existing.user?.email ?? claims.email ?? undefined,
7063
idToken,
71-
expiresAt: normalizedExpiresAt,
64+
refreshToken: existing.user?.refreshToken,
7265
};
7366

7467
if (
68+
existing.user?.uid !== mergedUser.uid ||
7569
existing.user?.name !== mergedUser.name ||
7670
existing.user?.email !== mergedUser.email ||
77-
existing.user?.expiresAt !== mergedUser.expiresAt
71+
existing.user?.refreshToken !== mergedUser.refreshToken
7872
) {
7973
await writeGlobalConfig({
8074
...existing,
@@ -101,7 +95,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
10195
const tokenPromise = new Promise<{
10296
token: string;
10397
refreshToken?: string;
104-
expiresAt?: number;
10598
}>((resolve, reject) => {
10699
const timeout = setTimeout(() => {
107100
server.close();
@@ -129,7 +122,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
129122
const data = JSON.parse(body) as {
130123
token?: string;
131124
refreshToken?: string;
132-
expiresAt?: number | string;
133125
state?: string;
134126
};
135127
// Some auth providers may not echo back our `cliState` in the callback payload.
@@ -145,7 +137,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
145137
resolve({
146138
token: data.token,
147139
refreshToken: typeof data.refreshToken === 'string' ? data.refreshToken : undefined,
148-
expiresAt: normalizeExpiresAt(data.expiresAt),
149140
});
150141
} else {
151142
res.writeHead(400, {
@@ -191,7 +182,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
191182
let callbackData: {
192183
token: string;
193184
refreshToken?: string;
194-
expiresAt?: number;
195185
};
196186
try {
197187
callbackData = await tokenPromise;
@@ -203,7 +193,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
203193

204194
const token = callbackData.token;
205195
const { uid, name, email } = decodeIdTokenClaims(token);
206-
const expiresAt = callbackData.expiresAt ?? getIdTokenExpiryMs(token);
207196

208197
const newConfig: EnsembleUserConfig = {
209198
...existing,
@@ -213,7 +202,6 @@ export async function loginCommand(options: LoginOptions = {}): Promise<void> {
213202
email: email ?? undefined,
214203
idToken: token,
215204
refreshToken: callbackData.refreshToken,
216-
expiresAt,
217205
},
218206
};
219207

src/config/globalConfig.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export interface EnsembleUserConfig {
99
email?: string;
1010
idToken: string;
1111
refreshToken?: string;
12-
expiresAt?: number;
1312
};
1413
[key: string]: unknown;
1514
}

tests/auth/session.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,38 @@ describe('getValidAuthSession', () => {
182182
}
183183
});
184184

185+
it('returns ok without refresh when jwt is valid even if legacy config has stale expiresAt', async () => {
186+
const token = makeJwt({
187+
userId: 'u1',
188+
email: 'a@b.com',
189+
exp: Math.floor(Date.now() / 1000) + 3600,
190+
});
191+
vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({
192+
user: {
193+
uid: 'u1',
194+
email: 'a@b.com',
195+
idToken: token,
196+
refreshToken: 'refresh-123',
197+
expiresAt: Date.now() - 3600_000,
198+
},
199+
} as EnsembleUserConfig);
200+
201+
const fetchMock = vi.fn();
202+
const originalFetch = globalThis.fetch;
203+
globalThis.fetch = fetchMock;
204+
205+
const result = await getValidAuthSession();
206+
207+
globalThis.fetch = originalFetch;
208+
209+
expect(result.ok).toBe(true);
210+
if (result.ok) {
211+
expect(result.idToken).toBe(token);
212+
expect(result.refreshed).toBe(false);
213+
}
214+
expect(fetchMock).not.toHaveBeenCalled();
215+
});
216+
185217
it('refreshes token when expired and refresh token exists', async () => {
186218
const oldToken = makeJwt({
187219
userId: 'u1',

tests/auth/token.test.ts

Lines changed: 5 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import {
3-
decodeIdTokenClaims,
4-
getIdTokenExpiryMs,
5-
normalizeExpiresAt,
6-
isTokenExpired,
7-
} from '../../src/auth/token.js';
2+
import { decodeIdTokenClaims, getIdTokenExpiryMs, isTokenExpired } from '../../src/auth/token.js';
83

94
function base64urlEncode(str: string): string {
105
return Buffer.from(str, 'utf8')
@@ -86,35 +81,6 @@ describe('getIdTokenExpiryMs', () => {
8681
});
8782
});
8883

89-
describe('normalizeExpiresAt', () => {
90-
it('accepts epoch milliseconds as-is', () => {
91-
const ms = 1735689600000;
92-
expect(normalizeExpiresAt(ms)).toBe(ms);
93-
});
94-
95-
it('converts epoch seconds to milliseconds', () => {
96-
expect(normalizeExpiresAt(1735689600)).toBe(1735689600000);
97-
});
98-
99-
it('accepts numeric string (seconds)', () => {
100-
expect(normalizeExpiresAt('1735689600')).toBe(1735689600000);
101-
});
102-
103-
it('accepts numeric string (milliseconds)', () => {
104-
expect(normalizeExpiresAt('1735689600000')).toBe(1735689600000);
105-
});
106-
107-
it('returns undefined for empty string', () => {
108-
expect(normalizeExpiresAt('')).toBeUndefined();
109-
expect(normalizeExpiresAt(' ')).toBeUndefined();
110-
});
111-
112-
it('returns undefined for invalid input', () => {
113-
expect(normalizeExpiresAt(null)).toBeUndefined();
114-
expect(normalizeExpiresAt(undefined)).toBeUndefined();
115-
});
116-
});
117-
11884
describe('isTokenExpired', () => {
11985
beforeEach(() => {
12086
vi.useFakeTimers();
@@ -142,14 +108,13 @@ describe('isTokenExpired', () => {
142108
vi.setSystemTime(new Date('2025-01-01T12:00:30Z'));
143109
// exp = 2025-01-01 13:00 UTC; with 60s buffer, token still valid at 12:00:30
144110
const token = makeJwt({ userId: 'u1', exp: 1735736400 });
145-
expect(isTokenExpired(token, undefined, 60)).toBe(false);
146-
expect(isTokenExpired(token, undefined, 0)).toBe(false);
111+
expect(isTokenExpired(token, 60)).toBe(false);
112+
expect(isTokenExpired(token, 0)).toBe(false);
147113
});
148114

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

0 commit comments

Comments
 (0)