diff --git a/packages/core/auth-js/src/GoTrueClient.ts b/packages/core/auth-js/src/GoTrueClient.ts index 4f85623bca..27603b1e9b 100644 --- a/packages/core/auth-js/src/GoTrueClient.ts +++ b/packages/core/auth-js/src/GoTrueClient.ts @@ -2981,7 +2981,18 @@ export default class GoTrueClient { if (maybeSession !== null) { if (this._isValidSession(maybeSession)) { - currentSession = maybeSession + // Decode the JWT to validate its structure and extract the exp + // claim. If the JWT is malformed, clear the session. Otherwise, + // assign the session unconditionally — the drift correction below + // will align expires_at with the JWT exp, and the existing + // hasExpired check will trigger a refresh if needed. + try { + decodeJWT(maybeSession.access_token) + currentSession = maybeSession + } catch (e) { + this._debug('#getSession()', 'failed to decode JWT from session', e) + await this._removeSession() + } } else { this._debug('#getSession()', 'session from storage is not valid') await this._removeSession() @@ -2997,6 +3008,27 @@ export default class GoTrueClient { // in the background), very eager users of getSession() -- like // realtime-js -- might send a valid JWT which will expire by the time it // reaches the server. + // Cross-check: if the JWT exp and session expires_at diverge by more + // than 60 seconds, prefer the JWT exp as the source of truth. + if (currentSession) { + try { + const { payload } = decodeJWT(currentSession.access_token) + if (payload.exp && currentSession.expires_at) { + const drift = Math.abs(payload.exp - currentSession.expires_at) + if (drift > 60) { + this._debug( + '#getSession()', + 'JWT exp and session expires_at diverge by', + drift, + 'seconds — using JWT exp' + ) + currentSession.expires_at = payload.exp + } + } + } catch (e) { + // already handled above + } + } const hasExpired = currentSession.expires_at ? currentSession.expires_at * 1000 - Date.now() < EXPIRY_MARGIN_MS : false diff --git a/packages/core/auth-js/test/GoTrueClient.test.ts b/packages/core/auth-js/test/GoTrueClient.test.ts index 88f0124f56..fd1090b9f0 100644 --- a/packages/core/auth-js/test/GoTrueClient.test.ts +++ b/packages/core/auth-js/test/GoTrueClient.test.ts @@ -345,6 +345,152 @@ describe('GoTrueClient', () => { expect(error).toBeNull() }) + test('getSession() triggers refresh when JWT exp has expired but expires_at is in the future', async () => { + // @ts-expect-error 'Allow access to protected storage' + const storage = authWithSession.storage + // @ts-expect-error 'Allow access to protected storageKey' + const storageKey = authWithSession.storageKey + + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') + const expiredPayload = Buffer.from( + JSON.stringify({ + sub: '00000000-0000-0000-0000-000000000000', + exp: Math.floor(Date.now() / 1000) - 3600, + iat: Math.floor(Date.now() / 1000) - 7200, + }) + ).toString('base64url') + const expiredJWT = `${header}.${expiredPayload}.fake_signature` + + await storage.setItem( + storageKey, + JSON.stringify({ + access_token: expiredJWT, + refresh_token: 'fake-refresh-token', + expires_in: 3600, + expires_at: Math.floor(Date.now() / 1000) + 3600, + token_type: 'bearer', + user: { + id: '00000000-0000-0000-0000-000000000000', + aud: 'authenticated', + role: 'authenticated', + email: 'test@example.com', + email_confirmed_at: new Date().toISOString(), + phone: '', + confirmed_at: new Date().toISOString(), + last_sign_in_at: new Date().toISOString(), + app_metadata: { provider: 'email', providers: ['email'] }, + user_metadata: {}, + identities: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + is_anonymous: false, + }, + }) + ) + // Mock _refreshAccessToken to avoid network calls + // @ts-expect-error 'Allow mocking private method return type' + refreshAccessTokenSpy.mockResolvedValueOnce({ + data: { user: null, session: null }, + error: new AuthError('Mocked refresh failure'), + }) + try { + const { data: result, error: resultError } = await authWithSession.getSession() + + expect(refreshAccessTokenSpy).toHaveBeenCalledTimes(1) + expect(result.session).toBeNull() + expect(resultError).not.toBeNull() + } finally { + refreshAccessTokenSpy.mockRestore() + } + + test('getSession() corrects expires_at when JWT exp diverges by more than 60s', async () => { + // @ts-expect-error 'Allow access to protected storage' + const storage = authWithSession.storage + // @ts-expect-error 'Allow access to protected storageKey' + const storageKey = authWithSession.storageKey + + const futureExp = Math.floor(Date.now() / 1000) + 300 + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') + const payload = Buffer.from( + JSON.stringify({ + sub: '00000000-0000-0000-0000-000000000000', + exp: futureExp, + iat: Math.floor(Date.now() / 1000) - 60, + }) + ).toString('base64url') + const jwt = `${header}.${payload}.fake_signature` + + await storage.setItem( + storageKey, + JSON.stringify({ + access_token: jwt, + refresh_token: 'fake-refresh-token', + expires_in: 600, + expires_at: Math.floor(Date.now() / 1000) + 600, + token_type: 'bearer', + user: { + id: '00000000-0000-0000-0000-000000000000', + aud: 'authenticated', + role: 'authenticated', + email: 'test@example.com', + email_confirmed_at: new Date().toISOString(), + phone: '', + confirmed_at: new Date().toISOString(), + last_sign_in_at: new Date().toISOString(), + app_metadata: { provider: 'email', providers: ['email'] }, + user_metadata: {}, + identities: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + is_anonymous: false, + }, + }) + ) + + const { data: result, error: resultError } = await authWithSession.getSession() + expect(resultError).toBeNull() + expect(result.session).not.toBeNull() + expect(result.session!.expires_at).toBe(futureExp) + }) + + test('getSession() clears session when JWT is malformed', async () => { + // @ts-expect-error 'Allow access to protected storage' + const storage = authWithSession.storage + // @ts-expect-error 'Allow access to protected storageKey' + const storageKey = authWithSession.storageKey + + await storage.setItem( + storageKey, + JSON.stringify({ + access_token: 'not-a-valid-jwt', + refresh_token: 'fake-refresh-token', + expires_in: 3600, + expires_at: Math.floor(Date.now() / 1000) + 3600, + token_type: 'bearer', + user: { + id: '00000000-0000-0000-0000-000000000000', + aud: 'authenticated', + role: 'authenticated', + email: 'test@example.com', + email_confirmed_at: new Date().toISOString(), + phone: '', + confirmed_at: new Date().toISOString(), + last_sign_in_at: new Date().toISOString(), + app_metadata: { provider: 'email', providers: ['email'] }, + user_metadata: {}, + identities: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + is_anonymous: false, + }, + }) + ) + + const { data: result, error: resultError } = await authWithSession.getSession() + expect(resultError).toBeNull() + expect(result.session).toBeNull() + }) + test('refresh should only happen once', async () => { const { email, password } = mockUserCredentials()