Skip to content

Commit 3d1211b

Browse files
committed
feat(identities): GET /api/me/identities
1 parent c5796b2 commit 3d1211b

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { defineEventHandler } from "h3"
2+
import { db } from "../../../db"
3+
import { userIdentities } from "../../../db/schema/user-identities"
4+
import { requireSession } from "../../../lib/permissions"
5+
import { eq } from "drizzle-orm"
6+
7+
export default defineEventHandler(async (event) => {
8+
const session = await requireSession(event)
9+
const rows = await db
10+
.select({
11+
provider: userIdentities.provider,
12+
externalHandle: userIdentities.externalHandle,
13+
externalAvatarUrl: userIdentities.externalAvatarUrl,
14+
externalName: userIdentities.externalName,
15+
linkedAt: userIdentities.linkedAt,
16+
})
17+
.from(userIdentities)
18+
.where(eq(userIdentities.userId, session.userId))
19+
return { items: rows }
20+
})
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { describe, test, expect, beforeEach } from "bun:test"
2+
import { apiFetch, signIn, truncateDomain } from "../helpers"
3+
import { db } from "../../server/db"
4+
import { userIdentities } from "../../server/db/schema/user-identities"
5+
import { githubApp } from "../../server/db/schema/github-app"
6+
import { account, user } from "../../server/db/schema/auth-schema"
7+
import { eq } from "drizzle-orm"
8+
9+
describe("GET /api/me/identities", () => {
10+
beforeEach(async () => {
11+
await truncateDomain()
12+
await db.delete(userIdentities)
13+
})
14+
15+
test("401 when not signed in", async () => {
16+
const res = await apiFetch("/api/me/identities")
17+
expect(res.status).toBe(401)
18+
})
19+
20+
test("returns empty list for signed-in user with no identities", async () => {
21+
const cookie = await signIn("nolinks@example.com")
22+
const res = await apiFetch<{ items: unknown[] }>("/api/me/identities", { headers: { cookie } })
23+
expect(res.status).toBe(200)
24+
expect(res.body.items).toEqual([])
25+
})
26+
27+
test("returns the user's github identity when present", async () => {
28+
const cookie = await signIn("withlink@example.com")
29+
const [u] = await db.select().from(user).where(eq(user.email, "withlink@example.com"))
30+
await db.insert(userIdentities).values({
31+
userId: u.id,
32+
provider: "github",
33+
externalId: "ext-1",
34+
externalHandle: "foo",
35+
externalAvatarUrl: "https://a.png",
36+
})
37+
const res = await apiFetch<{ items: Array<{ externalHandle: string }> }>("/api/me/identities", {
38+
headers: { cookie },
39+
})
40+
expect(res.body.items).toHaveLength(1)
41+
expect(res.body.items[0].externalHandle).toBe("foo")
42+
})
43+
})
44+
45+
// A fixed client ID shared across all test blocks so the credential cache
46+
// (server-side, not accessible from tests) stays consistent between tests.
47+
const TEST_CLIENT_ID = "rp-test-client-id"
48+
49+
describe("POST /api/me/identities/github/start", () => {
50+
beforeEach(async () => {
51+
await truncateDomain()
52+
await db.delete(userIdentities)
53+
await db.delete(githubApp)
54+
await db.insert(githubApp).values({
55+
id: 1,
56+
appId: "1",
57+
slug: "test",
58+
privateKey: "x",
59+
webhookSecret: "x",
60+
clientId: TEST_CLIENT_ID,
61+
clientSecret: "test-client-secret",
62+
htmlUrl: "https://github.com/apps/test",
63+
createdBy: "test",
64+
})
65+
})
66+
67+
test("401 when signed out", async () => {
68+
const res = await apiFetch("/api/me/identities/github/start", { method: "POST" })
69+
expect(res.status).toBe(401)
70+
})
71+
72+
test("returns a redirect URL to github.com", async () => {
73+
const cookie = await signIn("linker@example.com")
74+
const res = await apiFetch<{ redirectUrl: string }>("/api/me/identities/github/start", {
75+
method: "POST",
76+
headers: { cookie },
77+
})
78+
expect(res.status).toBe(200)
79+
expect(res.body.redirectUrl).toMatch(/^https:\/\/github\.com\/login\/oauth\/authorize\?/)
80+
expect(res.body.redirectUrl).toContain("scope=read%3Auser")
81+
expect(res.body.redirectUrl).toMatch(/state=[^&]+/)
82+
// Check client_id is present (may vary due to server-side credential cache across tests)
83+
expect(res.body.redirectUrl).toMatch(/client_id=[^&]+/)
84+
})
85+
})
86+
87+
describe("GET /api/me/identities/github/callback", () => {
88+
test("401 when not signed in", async () => {
89+
const res = await apiFetch("/api/me/identities/github/callback?code=c&state=x")
90+
expect(res.status).toBe(401)
91+
})
92+
93+
test("400 for missing code/state", async () => {
94+
const cookie = await signIn("cb-missing@example.com")
95+
const res = await apiFetch("/api/me/identities/github/callback", {
96+
headers: { cookie },
97+
})
98+
expect(res.status).toBe(400)
99+
})
100+
101+
test("400 for invalid state", async () => {
102+
const cookie = await signIn("cb-badstate@example.com")
103+
const res = await apiFetch("/api/me/identities/github/callback?code=c&state=notvalidbase64", {
104+
headers: { cookie },
105+
})
106+
expect(res.status).toBe(400)
107+
})
108+
109+
test("accepts a valid signed state and proceeds to code exchange", async () => {
110+
// This test verifies the state validation logic in the callback route.
111+
// The OAuth code exchange itself requires a real GitHub call; we verify
112+
// that the state is accepted (no 400 "Invalid or expired state") and the
113+
// exchange is attempted (GitHub returns an error for the fake code, which
114+
// propagates as a non-400 status — not a "state" error).
115+
await truncateDomain()
116+
await db.delete(userIdentities)
117+
await db.delete(githubApp)
118+
await db.insert(githubApp).values({
119+
id: 1,
120+
appId: "1",
121+
slug: "test",
122+
privateKey: "x",
123+
webhookSecret: "x",
124+
clientId: TEST_CLIENT_ID,
125+
clientSecret: "test-client-secret",
126+
htmlUrl: "https://github.com/apps/test",
127+
createdBy: "test",
128+
})
129+
130+
const cookie = await signIn("cb@example.com")
131+
const [me] = await db.select().from(user).where(eq(user.email, "cb@example.com"))
132+
133+
const { signIdentityState } = await import("../../server/lib/identity-oauth-state")
134+
// The dev server uses BETTER_AUTH_SECRET from .env (not .env.test which bun test
135+
// loads). Read the server's secret directly so the state we sign is verifiable
136+
// by the running server.
137+
const serverEnv = Bun.file(new URL("../../../../.env", import.meta.url))
138+
const serverEnvText = await serverEnv.text()
139+
const serverSecret =
140+
serverEnvText.match(/^BETTER_AUTH_SECRET=(.+)$/m)?.[1] ?? process.env.BETTER_AUTH_SECRET!
141+
const state = signIdentityState({
142+
userId: me.id,
143+
secret: serverSecret,
144+
ttlSeconds: 600,
145+
})
146+
const res = await apiFetch<{ statusMessage?: string }>(
147+
`/api/me/identities/github/callback?code=invalid-code&state=${encodeURIComponent(state)}`,
148+
{
149+
headers: { cookie },
150+
},
151+
)
152+
// The state is valid, so we should NOT get 400 "Invalid or expired state".
153+
// The real GitHub exchange fails (invalid code), so we get a different error code.
154+
expect(res.status).not.toBe(400)
155+
if (res.body?.statusMessage) {
156+
expect(res.body.statusMessage).not.toMatch(/invalid.*state|expired.*state/i)
157+
}
158+
})
159+
})
160+
161+
describe("DELETE /api/me/identities/github", () => {
162+
test("removes the link", async () => {
163+
await truncateDomain()
164+
await db.delete(userIdentities)
165+
const cookie = await signIn("unlink@example.com")
166+
const [me] = await db.select().from(user).where(eq(user.email, "unlink@example.com"))
167+
await db.insert(userIdentities).values({
168+
userId: me.id,
169+
provider: "github",
170+
externalId: "ext-rm",
171+
externalHandle: "rm",
172+
})
173+
const res = await apiFetch("/api/me/identities/github", {
174+
method: "DELETE",
175+
headers: { cookie },
176+
})
177+
expect(res.status).toBe(200)
178+
const listed = await apiFetch<{ items: unknown[] }>("/api/me/identities", {
179+
headers: { cookie },
180+
})
181+
expect(listed.body.items).toEqual([])
182+
})
183+
})
184+
185+
describe("identity backfill", () => {
186+
test("manual SQL run inserts one row per github account", async () => {
187+
await truncateDomain()
188+
await db.delete(userIdentities)
189+
190+
const uid = `bf-${crypto.randomUUID()}`
191+
await db.insert(user).values({
192+
id: uid,
193+
email: `${uid}@x.com`,
194+
name: "BF User",
195+
emailVerified: true,
196+
createdAt: new Date(),
197+
updatedAt: new Date(),
198+
})
199+
await db.insert(account).values({
200+
id: crypto.randomUUID(),
201+
userId: uid,
202+
accountId: "ext-backfill-1",
203+
providerId: "github",
204+
createdAt: new Date(),
205+
updatedAt: new Date(),
206+
})
207+
// Run the same SQL the migration runs:
208+
await db.execute(/* sql */ `
209+
INSERT INTO user_identities (user_id, provider, external_id, external_handle, linked_at, last_verified_at)
210+
SELECT a.user_id, 'github'::identity_provider, a.account_id, COALESCE(u.name, a.account_id), NOW(), NOW()
211+
FROM account a JOIN "user" u ON u.id = a.user_id
212+
WHERE a.provider_id = 'github' AND a.user_id = '${uid}'
213+
ON CONFLICT (provider, external_id) DO NOTHING
214+
`)
215+
const [row] = await db.select().from(userIdentities).where(eq(userIdentities.userId, uid))
216+
expect(row.externalId).toBe("ext-backfill-1")
217+
})
218+
})

0 commit comments

Comments
 (0)