Skip to content
Open
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
34 changes: 33 additions & 1 deletion packages/core/auth-js/src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

βšͺ Severity: LOW

This parses the JWT but does not validate required payload claims such as a numeric exp. A stored token with a null, missing, or non-object payload therefore reaches currentSession; the later payload.exp check skips or throws, and getSession() returns it using stale expires_at instead of clearing it.
Helpful? Add πŸ‘ / πŸ‘Ž

πŸ’‘ Fix Suggestion

Suggestion: After decoding the JWT, validate that the decoded payload is a non-null object and that exp is a finite number before assigning currentSession. If the payload is invalid, clear the session instead of using it. Replace the two lines that decode the JWT and assign currentSession with validation logic:

const { payload } = decodeJWT(maybeSession.access_token)
if (payload && typeof payload === 'object' && typeof payload.exp === 'number' && isFinite(payload.exp)) {
  currentSession = maybeSession
} else {
  this._debug('#getSession()', 'JWT payload missing valid exp claim')
  await this._removeSession()
}

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
decodeJWT(maybeSession.access_token)
const { payload } = decodeJWT(maybeSession.access_token)
if (payload && typeof payload === 'object' && typeof payload.exp === 'number' && isFinite(payload.exp)) {
currentSession = maybeSession
} else {
this._debug('#getSession()', 'JWT payload missing valid exp claim')
await this._removeSession()
}

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()
Expand All @@ -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
Expand Down
146 changes: 146 additions & 0 deletions packages/core/auth-js/test/GoTrueClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down