1+ import { createPrivateKey } from 'node:crypto'
2+
13import type { FastifyReply , FastifyRequest } from 'fastify'
24import { z } from 'zod'
35
46import { config } from '../../config'
5- import { requireProjectIdHeader } from '../management/auth '
7+ import { Errors } from '../../utils/errors '
68import {
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'
1017import {
1118 consumeOAuthState ,
1219 createOAuthState ,
1320 resolveOAuthRedirectUrl ,
1421} from './state'
15- import { Errors } from '../../utils/errors'
1622
1723const 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+
44135export 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