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
10 changes: 5 additions & 5 deletions packages/core/auth-js/src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1938,11 +1938,11 @@ export default class GoTrueClient {
} catch (error) {
if (isAuthError(error)) {
if (isAuthSessionMissingError(error)) {
// JWT contains a `session_id` which does not correspond to an active
// session in the database, indicating the user is signed out.

await this._removeSession()
await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
// The JWT's `session_id` does not correspond to an active session in
// the database. This can be transient — do not destroy the local
// session. If the session is truly invalid, _callRefreshToken() will
// fail on the next refresh and clean up at that point.
this._debug('#_getUser()', 'session not found on server, preserving local session')
}

return this._returnResult({ data: { user: null }, error })
Expand Down
5 changes: 3 additions & 2 deletions packages/core/auth-js/src/lib/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ export async function handleError(error: unknown) {
)
} else if (errorCode === 'session_not_found') {
// The `session_id` inside the JWT does not correspond to a row in the
// `sessions` table. This usually means the user has signed out, has been
// deleted, or their session has somehow been terminated.
// `sessions` table. This can indicate the user has signed out, has been
// deleted, or their session has been terminated — but it may also be
// transient (e.g. server-side race, brief network partition).
throw new AuthSessionMissingError()
}

Expand Down
25 changes: 24 additions & 1 deletion packages/core/auth-js/test/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { MockServer } from 'jest-mock-server'
import { API_VERSION_HEADER_NAME } from '../src/lib/constants'
import { AuthUnknownError, AuthApiError, AuthRetryableFetchError } from '../src/lib/errors'
import {
AuthUnknownError,
AuthApiError,
AuthRetryableFetchError,
AuthSessionMissingError,
} from '../src/lib/errors'
import { _request, handleError } from '../src/lib/fetch'

describe('fetch', () => {
Expand Down Expand Up @@ -199,6 +204,24 @@ describe('handleError', () => {
}
),
},
{
name: 'with API version 2024-01-01 and session_not_found error code',
code: undefined,
ename: 'AuthSessionMissingError',
response: new Response(
JSON.stringify({
code: 'session_not_found',
message: 'Session from session_id claim in JWT does not exist',
}),
{
status: 403,
statusText: 'Forbidden',
headers: {
[API_VERSION_HEADER_NAME]: '2024-01-01',
},
}
),
},
].forEach((example) => {
it(`should handle error response ${example.name}`, async () => {
let error: any = null
Expand Down
208 changes: 208 additions & 0 deletions packages/core/auth-js/test/gotrue-client-getUser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { MockServer } from 'jest-mock-server'
import { API_VERSION_HEADER_NAME } from '../src/lib/constants'
import GoTrueClient from '../src/GoTrueClient'

class MemoryStorage {
private _storage: { [name: string]: string } = {}

async setItem(name: string, value: string) {
this._storage[name] = value
}

async getItem(name: string): Promise<string | null> {
return this._storage[name] ?? null
}

async removeItem(name: string) {
delete this._storage[name]
}
}

function createMockSession() {
return {
access_token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2Vzc2lvbl9pZCI6InRlc3Qtc2Vzc2lvbiIsImV4cCI6OTk5OTk5OTk5OX0.fake',
refresh_token: 'fake-refresh-token',
expires_in: 3600,
expires_at: Math.floor(Date.now() / 1000) + 3600,
token_type: 'bearer',
user: {
id: 'test-user-id',
aud: 'authenticated',
role: 'authenticated',
email: 'test@example.com',
app_metadata: {},
user_metadata: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
}
}

const storageKey = 'test-storage-key'

function createClient(url: string, storage: MemoryStorage) {
return new GoTrueClient({
url,
autoRefreshToken: false,
persistSession: true,
storage,
storageKey,
detectSessionInUrl: false,
})
}

function mockSessionNotFound(server: MockServer) {
return server.get('/user').mockImplementationOnce((ctx) => {
ctx.status = 403
ctx.set(API_VERSION_HEADER_NAME, '2024-01-01')
ctx.body = {
code: 'session_not_found',
message: 'Session from session_id claim in JWT does not exist',
}
})
}

describe('_getUser session preservation', () => {
const server = new MockServer()

beforeAll(async () => await server.start())
afterAll(async () => await server.stop())
beforeEach(() => server.reset())

it('getUser() with session_not_found does not remove session from storage', async () => {
const storage = new MemoryStorage()
const mockSession = createMockSession()
await storage.setItem(storageKey, JSON.stringify(mockSession))

const url = server.getURL().toString().replace(/\/$/, '')
const client = createClient(url, storage)

const route = mockSessionNotFound(server)

const { data, error } = await client.getUser()

expect(route).toHaveBeenCalledTimes(1)
expect(data.user).toBeNull()
expect(error).not.toBeNull()

const sessionAfter = await storage.getItem(storageKey)
expect(sessionAfter).not.toBeNull()
})

it('getUser() with session_not_found does not fire SIGNED_OUT event', async () => {
const storage = new MemoryStorage()
const mockSession = createMockSession()
await storage.setItem(storageKey, JSON.stringify(mockSession))

const url = server.getURL().toString().replace(/\/$/, '')
const client = createClient(url, storage)

const events: string[] = []
const {
data: { subscription },
} = client.onAuthStateChange((event) => {
events.push(event)
})

// Wait for initialization to complete (fires INITIAL_SESSION)
await client.getSession()
expect(events).toContain('INITIAL_SESSION')

const route = mockSessionNotFound(server)

await client.getUser()

expect(route).toHaveBeenCalledTimes(1)
expect(events).not.toContain('SIGNED_OUT')

subscription.unsubscribe()
})

it('getUser() with session_not_found returns correct error shape', async () => {
const storage = new MemoryStorage()
const mockSession = createMockSession()
await storage.setItem(storageKey, JSON.stringify(mockSession))

const url = server.getURL().toString().replace(/\/$/, '')
const client = createClient(url, storage)

const route = mockSessionNotFound(server)

const result = await client.getUser()

expect(route).toHaveBeenCalledTimes(1)
expect(result.data.user).toBeNull()
expect(result.error).not.toBeNull()
expect(result.error!.name).toEqual('AuthSessionMissingError')
})

it('getUser() with session_not_found preserves code-verifier in storage', async () => {
const storage = new MemoryStorage()
const mockSession = createMockSession()
await storage.setItem(storageKey, JSON.stringify(mockSession))
await storage.setItem(`${storageKey}-code-verifier`, 'test-code-verifier')

const url = server.getURL().toString().replace(/\/$/, '')
const client = createClient(url, storage)

const route = mockSessionNotFound(server)

await client.getUser()

expect(route).toHaveBeenCalledTimes(1)

const codeVerifier = await storage.getItem(`${storageKey}-code-verifier`)
expect(codeVerifier).toEqual('test-code-verifier')
})

it('getUser() with non-session_not_found error does not remove session', async () => {
const storage = new MemoryStorage()
const mockSession = createMockSession()
await storage.setItem(storageKey, JSON.stringify(mockSession))

const url = server.getURL().toString().replace(/\/$/, '')
const client = createClient(url, storage)

const route = server.get('/user').mockImplementationOnce((ctx) => {
ctx.status = 401
ctx.set(API_VERSION_HEADER_NAME, '2024-01-01')
ctx.body = {
code: 'bad_jwt',
message: 'Invalid or expired JWT',
}
})

const { data, error } = await client.getUser()

expect(route).toHaveBeenCalledTimes(1)
expect(data.user).toBeNull()
expect(error).not.toBeNull()
expect(error!.name).toEqual('AuthApiError')

const sessionAfter = await storage.getItem(storageKey)
expect(sessionAfter).not.toBeNull()
})

it('signOut() still removes session (regression)', async () => {
const storage = new MemoryStorage()
const mockSession = createMockSession()
await storage.setItem(storageKey, JSON.stringify(mockSession))

const url = server.getURL().toString().replace(/\/$/, '')
const client = createClient(url, storage)

const route = server.post('/logout').mockImplementationOnce((ctx) => {
ctx.status = 204
ctx.body = ''
})

const { error } = await client.signOut()

expect(route).toHaveBeenCalledTimes(1)
expect(error).toBeNull()

const sessionAfter = await storage.getItem(storageKey)
expect(sessionAfter).toBeNull()
})
})
Loading