Skip to content

Commit 2d1527e

Browse files
committed
feat(identities): callback exchanges code and upserts identity
1 parent dddb5f9 commit 2d1527e

2 files changed

Lines changed: 114 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { defineEventHandler, createError, getQuery, sendRedirect } from "h3"
2+
import { verifyIdentityState } from "../../../../lib/identity-oauth-state"
3+
import { upsertGithubIdentity } from "../../../../lib/github-identities"
4+
import { getGithubAppCredentials } from "../../../../lib/github-app-credentials"
5+
import {
6+
exchangeGithubCodeDefault,
7+
fetchGithubUserDefault,
8+
__getOauthOverride,
9+
} from "../../../../lib/github-oauth-link"
10+
import { requireSession } from "../../../../lib/permissions"
11+
12+
export default defineEventHandler(async (event) => {
13+
const session = await requireSession(event)
14+
const query = getQuery(event)
15+
const code = typeof query.code === "string" ? query.code : null
16+
const state = typeof query.state === "string" ? query.state : null
17+
if (!code || !state) {
18+
throw createError({ statusCode: 400, statusMessage: "Missing code/state" })
19+
}
20+
21+
const authSecret = process.env.BETTER_AUTH_SECRET
22+
if (!authSecret) throw createError({ statusCode: 500, statusMessage: "Missing auth secret" })
23+
24+
let stateClaim: { userId: string }
25+
try {
26+
stateClaim = verifyIdentityState({ state, secret: authSecret })
27+
} catch {
28+
throw createError({ statusCode: 400, statusMessage: "Invalid or expired state" })
29+
}
30+
if (stateClaim.userId !== session.userId) {
31+
throw createError({ statusCode: 403, statusMessage: "State does not match session" })
32+
}
33+
34+
const creds = await getGithubAppCredentials()
35+
if (!creds?.clientId || !creds.clientSecret) {
36+
throw createError({ statusCode: 400, statusMessage: "GitHub App is not configured" })
37+
}
38+
39+
const override = __getOauthOverride()
40+
const deps = override ?? { clientId: creds.clientId, clientSecret: creds.clientSecret }
41+
42+
const token = await exchangeGithubCodeDefault(deps, code)
43+
const ghUser = await fetchGithubUserDefault(deps, token)
44+
45+
try {
46+
await upsertGithubIdentity(session.userId, {
47+
externalId: String(ghUser.id),
48+
externalHandle: ghUser.login,
49+
externalAvatarUrl: ghUser.avatar_url,
50+
externalName: ghUser.name,
51+
externalEmail: ghUser.email,
52+
})
53+
} catch (e) {
54+
const message = e instanceof Error ? e.message : "Link failed"
55+
return sendRedirect(event, `/settings/identities?error=${encodeURIComponent(message)}`)
56+
}
57+
58+
return sendRedirect(event, "/settings/identities?linked=github")
59+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export type GithubOauthUser = {
2+
id: number
3+
login: string
4+
name: string | null
5+
email: string | null
6+
avatar_url: string | null
7+
}
8+
9+
export type GithubOauthLinkDeps = {
10+
clientId: string
11+
clientSecret: string
12+
exchangeCode?: (code: string) => Promise<string>
13+
fetchUser?: (accessToken: string) => Promise<GithubOauthUser>
14+
}
15+
16+
export async function exchangeGithubCodeDefault(
17+
deps: GithubOauthLinkDeps,
18+
code: string,
19+
): Promise<string> {
20+
if (deps.exchangeCode) return deps.exchangeCode(code)
21+
const res = await $fetch<{ access_token?: string }>(
22+
"https://github.com/login/oauth/access_token",
23+
{
24+
method: "POST",
25+
headers: { accept: "application/json", "content-type": "application/json" },
26+
body: { client_id: deps.clientId, client_secret: deps.clientSecret, code },
27+
},
28+
)
29+
if (!res.access_token) throw new Error("No access token")
30+
return res.access_token
31+
}
32+
33+
export async function fetchGithubUserDefault(
34+
deps: GithubOauthLinkDeps,
35+
token: string,
36+
): Promise<GithubOauthUser> {
37+
if (deps.fetchUser) return deps.fetchUser(token)
38+
return await $fetch<GithubOauthUser>("https://api.github.com/user", {
39+
headers: {
40+
accept: "application/vnd.github+json",
41+
"user-agent": "Repro-Dashboard",
42+
authorization: `Bearer ${token}`,
43+
},
44+
})
45+
}
46+
47+
let __testOverride: GithubOauthLinkDeps | null = null
48+
49+
export function __setOauthOverride(deps: GithubOauthLinkDeps | null) {
50+
__testOverride = deps
51+
}
52+
53+
export function __getOauthOverride() {
54+
return __testOverride
55+
}

0 commit comments

Comments
 (0)