Skip to content

Commit 4f71f17

Browse files
committed
feat(oauth): add google github discord OAuth integrations
- Integrates: provider token/profile client in app/server/src/modules/oauth/provider-client.ts and full callback completion in app/server/src/modules/oauth/handlers.ts with account linking and session issuance. - Security/Behavior: callback now exchanges authorization codes server-side, validates provider profile identity, links/creates users per project, sets secure refresh cookies, and returns access token via URL fragment to reduce query leakage. - Validation: expanded OAuth coverage in app/server/test/oauth-framework-route.test.ts and app/server/test/oauth-provider-client.test.ts; execution remains pending in this environment because dependencies are not installed.
1 parent 3d83cec commit 4f71f17

4 files changed

Lines changed: 744 additions & 21 deletions

File tree

app/server/src/modules/oauth/handlers.ts

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
1+
import { createPrivateKey } from 'node:crypto'
2+
13
import type { FastifyReply, FastifyRequest } from 'fastify'
24
import { z } from 'zod'
35

46
import { config } from '../../config'
5-
import { requireProjectIdHeader } from '../management/auth'
7+
import { Errors } from '../../utils/errors'
68
import {
7-
buildAuthorizationUrl,
8-
getOAuthProviderConfig,
9-
} from './providers'
9+
createTokenFamilyId,
10+
issueAccessToken,
11+
issueRefreshToken,
12+
setRefreshTokenCookie,
13+
} from '../../utils/tokens'
14+
import { requireProjectIdHeader } from '../management/auth'
15+
import { exchangeOAuthCode, fetchOAuthProfile } from './provider-client'
16+
import { buildAuthorizationUrl, getOAuthProviderConfig } from './providers'
1017
import {
1118
consumeOAuthState,
1219
createOAuthState,
1320
resolveOAuthRedirectUrl,
1421
} from './state'
15-
import { Errors } from '../../utils/errors'
1622

1723
const oauthParamsSchema = z.object({
1824
provider: z.string().min(1),
@@ -41,6 +47,91 @@ function appendQuery(url: string, params: Record<string, string | undefined>): s
4147
return target.toString()
4248
}
4349

50+
function appendHash(url: string, params: Record<string, string | undefined>): string {
51+
const target = new URL(url)
52+
const hash = target.hash.startsWith('#') ? target.hash.slice(1) : target.hash
53+
const hashParams = new URLSearchParams(hash)
54+
55+
for (const [key, value] of Object.entries(params)) {
56+
if (value) {
57+
hashParams.set(key, value)
58+
}
59+
}
60+
61+
target.hash = hashParams.toString()
62+
63+
return target.toString()
64+
}
65+
66+
async function resolveOAuthUser(
67+
request: FastifyRequest,
68+
data: {
69+
projectId: string
70+
provider: string
71+
providerUserId: string
72+
email: string | null
73+
emailVerified: boolean
74+
displayName: string | null
75+
avatarUrl: string | null
76+
rawProfile: Record<string, unknown>
77+
},
78+
) {
79+
const existingAccount = await request.server.dbAdapter.getOAuthAccount(
80+
data.projectId,
81+
data.provider,
82+
data.providerUserId,
83+
)
84+
85+
if (existingAccount) {
86+
const existingUser = await request.server.dbAdapter.getUserById(data.projectId, existingAccount.userId)
87+
88+
if (!existingUser || existingUser.deletedAt) {
89+
throw Errors.UNAUTHORIZED()
90+
}
91+
92+
return existingUser
93+
}
94+
95+
if (!data.email) {
96+
throw Errors.OAUTH_AUTHORIZATION_FAILED()
97+
}
98+
99+
const normalizedEmail = data.email.trim().toLowerCase()
100+
let user = await request.server.dbAdapter.getUserByEmail(data.projectId, normalizedEmail)
101+
102+
if (!user) {
103+
user = await request.server.dbAdapter.createUser(data.projectId, {
104+
email: normalizedEmail,
105+
emailVerified: data.emailVerified,
106+
displayName: data.displayName ?? undefined,
107+
avatarUrl: data.avatarUrl ?? undefined,
108+
metadata: {},
109+
})
110+
}
111+
112+
try {
113+
await request.server.dbAdapter.createOAuthAccount({
114+
userId: user.id,
115+
projectId: data.projectId,
116+
provider: data.provider,
117+
providerUserId: data.providerUserId,
118+
rawProfile: data.rawProfile,
119+
})
120+
} catch {
121+
const account = await request.server.dbAdapter.getOAuthAccount(
122+
data.projectId,
123+
data.provider,
124+
data.providerUserId,
125+
)
126+
127+
if (!account || account.userId !== user.id) {
128+
throw Errors.OAUTH_AUTHORIZATION_FAILED()
129+
}
130+
}
131+
132+
return user
133+
}
134+
44135
export async function oauthBeginHandler(
45136
request: FastifyRequest,
46137
reply: FastifyReply,
@@ -50,10 +141,7 @@ export async function oauthBeginHandler(
50141
const query = oauthBeginQuerySchema.parse(request.query)
51142

52143
const providerConfig = getOAuthProviderConfig(provider)
53-
const redirectUrl = resolveOAuthRedirectUrl(
54-
query.redirectUrl,
55-
config.oauthRedirectAllowlist,
56-
)
144+
const redirectUrl = resolveOAuthRedirectUrl(query.redirectUrl, config.oauthRedirectAllowlist)
57145

58146
const state = await createOAuthState(request.server.cache, {
59147
projectId,
@@ -96,11 +184,60 @@ export async function oauthCallbackHandler(
96184
throw Errors.OAUTH_AUTHORIZATION_FAILED()
97185
}
98186

99-
const location = appendQuery(state.redirectUrl, {
187+
const tokenSet = await exchangeOAuthCode(providerConfig, query.code)
188+
const profile = await fetchOAuthProfile(providerConfig.id, providerConfig.userInfoUrl, tokenSet.accessToken)
189+
190+
const user = await resolveOAuthUser(request, {
191+
projectId: state.projectId,
100192
provider: providerConfig.id,
101-
oauth_code: query.code,
102-
state: state.clientState ?? undefined,
193+
providerUserId: profile.providerUserId,
194+
email: profile.email,
195+
emailVerified: profile.emailVerified,
196+
displayName: profile.displayName,
197+
avatarUrl: profile.avatarUrl,
198+
rawProfile: profile.rawProfile,
199+
})
200+
201+
if (user.bannedAt) {
202+
throw Errors.ACCOUNT_BANNED()
203+
}
204+
205+
const refresh = issueRefreshToken(32)
206+
const tokenFamily = createTokenFamilyId()
207+
const expiresAt = new Date(Date.now() + config.sessionDurationSeconds * 1000)
208+
209+
const session = await request.server.dbAdapter.createSession({
210+
userId: user.id,
211+
projectId: state.projectId,
212+
tokenHash: refresh.tokenHash,
213+
tokenFamily,
214+
ipAddress: request.ip,
215+
userAgent: request.headers['user-agent'] as string | undefined,
216+
expiresAt,
217+
})
218+
219+
const accessToken = await issueAccessToken({
220+
context: {
221+
userId: user.id,
222+
sessionId: session.id,
223+
projectId: state.projectId,
224+
email: user.email,
225+
emailVerified: user.emailVerified,
226+
issuer: config.baseUrl,
227+
},
228+
ttlSeconds: config.accessTokenTtlSeconds,
229+
privateKey: createPrivateKey(config.jwtPrivateKey),
103230
})
104231

232+
setRefreshTokenCookie(reply, refresh.token, config.nodeEnv === 'production')
233+
234+
const location = appendHash(
235+
appendQuery(state.redirectUrl, {
236+
provider: providerConfig.id,
237+
state: state.clientState ?? undefined,
238+
}),
239+
{ accessToken },
240+
)
241+
105242
reply.redirect(location)
106243
}

0 commit comments

Comments
 (0)