Skip to content

Commit d02c5c9

Browse files
fix(auth): source AuthUser.id from Kratos external_id, add identityId
AuthUser.id is now always public.users.id (the Kratos identity's external_id) rather than the Kratos identity id, and a new identityId field carries the Kratos id for admin/Kratos operations. This fixes PostHog identify, tRPC telemetry, and team resolution, which all expect public.users.id. - identity.ts mappers require external_id (no silent fallback to the Kratos id) and set identityId. - getAuthContext refuses a session whose identity has no external_id; the edge gate (isKratosSessionActive) rejects it too, so the user is routed to /sign-in instead of looping, where a fresh login re-runs bootstrap and backfills external_id. - Drop the updateUser override that re-stamped the Kratos id over id. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 52f2309 commit d02c5c9

7 files changed

Lines changed: 104 additions & 18 deletions

File tree

src/core/modules/auth/models.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
export type AuthUser = {
2+
// public.users.id, sourced from the Kratos identity's external_id.
23
id: string
4+
// Ory Kratos identity id (the OAuth2 subject); used for Kratos/admin ops.
5+
identityId: string
36
email: string | null
47
name: string | null
58
avatarUrl: string | null

src/core/server/auth/ory/identity.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,18 @@ import { z } from 'zod'
55
import { l } from '@/core/shared/clients/logger/logger'
66
import type { AuthUser } from '../types'
77

8-
type FromOryIdentityOptions = {
9-
userId?: string
8+
// AuthUser.id is always public.users.id (the identity's external_id), never the
9+
// Kratos identity id. A provisioned user always has one; getAuthContext refuses
10+
// sessions without it, so reaching here without an external_id is an invariant
11+
// violation we fail loudly on rather than mislabel the user.
12+
function requireExternalId(identity: {
13+
id: string
14+
external_id?: string | null
15+
}): string {
16+
if (!identity.external_id) {
17+
throw new Error(`Ory identity ${identity.id} has no external_id`)
18+
}
19+
return identity.external_id
1020
}
1121

1222
export const oryIdentityTraitsSchema = z
@@ -58,6 +68,7 @@ function readPublicPicture(metadataPublic: unknown): string | null {
5868
// with an admin lookup when those are needed (e.g. the profile query).
5969
export function fromKratosSessionIdentity(identity: {
6070
id: string
71+
external_id?: string | null
6172
traits?: unknown
6273
metadata_public?: unknown
6374
}): AuthUser {
@@ -66,7 +77,8 @@ export function fromKratosSessionIdentity(identity: {
6677
source: 'kratos_session',
6778
})
6879
return {
69-
id: identity.id,
80+
id: requireExternalId(identity),
81+
identityId: identity.id,
7082
email: readString(traits, 'email'),
7183
name: readString(traits, 'name'),
7284
avatarUrl: readPublicPicture(identity.metadata_public),
@@ -79,10 +91,7 @@ export function fromKratosSessionIdentity(identity: {
7991
// Rich path: build the user from a full Kratos Identity (traits + credentials).
8092
// Used wherever we've fetched the identity via the admin API — admin lookups and
8193
// the live profile query.
82-
export function fromOryIdentity(
83-
identity: Identity,
84-
options: FromOryIdentityOptions = {}
85-
): AuthUser {
94+
export function fromOryIdentity(identity: Identity): AuthUser {
8695
const traits = parseOryTraits(identity.traits, {
8796
identityId: identity.id,
8897
source: 'admin_identity',
@@ -98,7 +107,8 @@ export function fromOryIdentity(
98107
const canChangePassword = hasPasswordCredential && !hasOidcCredential
99108

100109
return {
101-
id: options.userId ?? identity.id,
110+
id: requireExternalId(identity),
111+
identityId: identity.id,
102112
email,
103113
name,
104114
avatarUrl,

src/core/server/auth/ory/kratos-session-edge.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { APP_OWNED_COOKIES } from './session-cookie'
55
// reads next/headers and can't run in the edge runtime, so we hit Kratos
66
// directly with the request's cookies. This gates redirects only —
77
// authoritative enforcement happens server-side in getAuthContext.
8+
//
9+
// external_id is required so this gate agrees with getAuthContext: a session
10+
// without it is half-provisioned and getAuthContext rejects it, so we must too,
11+
// otherwise the user loops between /sign-in and /dashboard.
812
export async function isKratosSessionActive(
913
request: NextRequest
1014
): Promise<boolean> {
@@ -22,8 +26,11 @@ export async function isKratosSessionActive(
2226
{ headers: { cookie, accept: 'application/json' } }
2327
)
2428
if (!response.ok) return false
25-
const session = (await response.json()) as { active?: boolean }
26-
return session.active === true
29+
const session = (await response.json()) as {
30+
active?: boolean
31+
identity?: { external_id?: string | null }
32+
}
33+
return session.active === true && !!session.identity?.external_id
2734
} catch {
2835
return false
2936
}

src/core/server/auth/ory/session.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ export async function getAuthContext(): Promise<AuthContext | null> {
4646
const kratos = await readKratosSession()
4747
if (!kratos?.active || !kratos.identity) return null
4848

49+
// public.users.id lives only on the Kratos identity's external_id. Without it
50+
// the dashboard can't key the user to its own records (PostHog, telemetry,
51+
// team membership), so we refuse the half-provisioned session. The edge gate
52+
// (isKratosSessionActive) rejects it too, so the user is routed to /sign-in
53+
// where a fresh login re-runs bootstrap and backfills external_id.
54+
if (!kratos.identity.external_id) {
55+
l.error(
56+
{
57+
key: 'auth_provider:identity_missing_external_id',
58+
context: { identity_id: kratos.identity.id },
59+
},
60+
'Kratos identity has no external_id; treating the session as unauthenticated'
61+
)
62+
return null
63+
}
64+
4965
const tokens = await readOrySessionTokens()
5066
if (!tokens?.accessToken) return null
5167

@@ -65,7 +81,7 @@ export async function getUserProfile(): Promise<AuthUser | null> {
6581
includeCredential: ACCOUNT_IDENTITY_CREDENTIALS,
6682
})
6783

68-
return identity ? fromOryIdentity(identity, { userId: identityId }) : null
84+
return identity ? fromOryIdentity(identity) : null
6985
}
7086

7187
export async function signOut(
@@ -114,9 +130,7 @@ export async function updateUser(
114130
password: input.password,
115131
})
116132

117-
if (!result.ok) return result
118-
119-
return { ...result, user: { ...result.user, id: identityId } }
133+
return result
120134
}
121135

122136
export async function startReauthForAccountSettings(): Promise<ReauthDispatch> {

tests/integration/auth-ory-account-security.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ function kratosSession({
8787
authenticated_at: authenticatedAt,
8888
identity: {
8989
id: identityId,
90+
external_id: 'e2b-user-id',
9091
traits: { email: 'ada@example.test', name: 'Ada' },
9192
},
9293
}
@@ -119,7 +120,8 @@ describe('Ory account security (Kratos session + e2b_session)', () => {
119120

120121
expect(await getAuthContext()).toEqual({
121122
user: expect.objectContaining({
122-
id: 'kratos-uuid',
123+
id: 'e2b-user-id',
124+
identityId: 'kratos-uuid',
123125
email: 'ada@example.test',
124126
name: 'Ada',
125127
}),
@@ -133,6 +135,17 @@ describe('Ory account security (Kratos session + e2b_session)', () => {
133135
expect(await getAuthContext()).toBeNull()
134136
})
135137

138+
it('returns null when the Kratos identity has no external_id', async () => {
139+
getServerSessionMock.mockResolvedValue({
140+
id: 'kratos-session-id',
141+
active: true,
142+
authenticated_at: new Date(),
143+
identity: { id: 'kratos-uuid', traits: { email: 'ada@example.test' } },
144+
})
145+
146+
expect(await getAuthContext()).toBeNull()
147+
})
148+
136149
it('returns null when the Kratos session is active but no token is present', async () => {
137150
getServerSessionMock.mockResolvedValue(kratosSession())
138151
openOrySessionMock.mockResolvedValue(null)
@@ -168,7 +181,10 @@ describe('Ory account security (Kratos session + e2b_session)', () => {
168181
credentials: { password: { config: { password: 'new-secret' } } },
169182
}),
170183
})
171-
expect(result).toMatchObject({ ok: true, user: { id: 'kratos-uuid' } })
184+
expect(result).toMatchObject({
185+
ok: true,
186+
user: { id: 'e2b-user-id', identityId: 'kratos-uuid' },
187+
})
172188
})
173189

174190
it('revokes Ory + Kratos sessions and clears e2b_session after a credential change', async () => {

tests/unit/identity-traits.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,51 @@ function sessionIdentity(overrides: {
3232
traits?: unknown
3333
metadata_public?: unknown
3434
}) {
35-
return { id: 'identity-1', ...overrides }
35+
return { id: 'identity-1', external_id: 'e2b-user-1', ...overrides }
3636
}
3737

3838
function adminIdentity(overrides: {
3939
traits?: unknown
4040
metadata_public?: unknown
4141
}): Identity {
42-
return { id: 'identity-1', ...overrides } as Identity
42+
return {
43+
id: 'identity-1',
44+
external_id: 'e2b-user-1',
45+
...overrides,
46+
} as Identity
4347
}
4448

49+
describe('AuthUser id sourcing', () => {
50+
it('sets id from external_id and identityId from the Kratos id', () => {
51+
const fromSession = fromKratosSessionIdentity(
52+
sessionIdentity({ traits: { email: 'jane@e2b.dev' } })
53+
)
54+
const fromAdmin = fromOryIdentity(
55+
adminIdentity({ traits: { email: 'jane@e2b.dev' } })
56+
)
57+
58+
for (const user of [fromSession, fromAdmin]) {
59+
expect(user.id).toBe('e2b-user-1')
60+
expect(user.identityId).toBe('identity-1')
61+
}
62+
})
63+
64+
it('throws when the identity has no external_id', () => {
65+
expect(() =>
66+
fromKratosSessionIdentity({
67+
id: 'identity-1',
68+
traits: { email: 'jane@e2b.dev' },
69+
})
70+
).toThrow(/external_id/)
71+
expect(() =>
72+
fromOryIdentity({
73+
id: 'identity-1',
74+
traits: { email: 'jane@e2b.dev' },
75+
} as Identity)
76+
).toThrow(/external_id/)
77+
})
78+
})
79+
4580
describe('parseOryTraits via identity mappers', () => {
4681
it('accepts valid traits without logging drift', () => {
4782
const user = fromKratosSessionIdentity(

tests/unit/user-router.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const createCaller = createCallerFactory(userRouter)
2626

2727
const authUser = {
2828
id: 'user-1',
29+
identityId: 'identity-1',
2930
email: 'old@example.test',
3031
name: 'Ada',
3132
avatarUrl: null,

0 commit comments

Comments
 (0)