@@ -26,7 +26,9 @@ export interface Env {
2626// Manipulation returned to ZITADEL. `{}` means "no change".
2727export interface Manipulation {
2828 append_claims ?: Array < { key : string ; value : unknown } > ;
29- append_attribute ?: Array < { name : string ; name_format : string ; value : string [ ] } > ;
29+ append_attribute ?: Array <
30+ { name : string ; name_format : string ; value : string [ ] }
31+ > ;
3032}
3133
3234type Handler = ( body : unknown ) => Manipulation | Promise < Manipulation > ;
@@ -41,9 +43,17 @@ export default {
4143
4244 switch ( url . pathname ) {
4345 case "/idp-intent" :
44- return handleVerified ( req , env . IDP_INTENT_SIGNING_KEY , ( body ) => handleIdpIntent ( body as IdpIntentBody , env ) ) ;
46+ return await handleVerified (
47+ req ,
48+ env . IDP_INTENT_SIGNING_KEY ,
49+ ( body ) => handleIdpIntent ( body as IdpIntentBody , env ) ,
50+ ) ;
4551 case "/token" :
46- return handleVerified ( req , env . TOKEN_SIGNING_KEY , ( body ) => handleToken ( body as TokenBody , env ) ) ;
52+ return await handleVerified (
53+ req ,
54+ env . TOKEN_SIGNING_KEY ,
55+ ( body ) => handleToken ( body as TokenBody , env ) ,
56+ ) ;
4757 default :
4858 return new Response ( "Not found" , { status : 404 } ) ;
4959 }
@@ -54,7 +64,11 @@ export default {
5464// return value into a JSON 200. A thrown handler error becomes a 500 — with
5565// interrupt_on_error=false on the target, ZITADEL ignores it and the login
5666// proceeds unchanged, so a worker fault can never break authentication.
57- async function handleVerified ( req : Request , signingKey : string , handler : Handler ) : Promise < Response > {
67+ async function handleVerified (
68+ req : Request ,
69+ signingKey : string ,
70+ handler : Handler ,
71+ ) : Promise < Response > {
5872 if ( ! signingKey ) {
5973 console . error ( "[init] missing signing key binding" ) ;
6074 return new Response ( "Missing configuration" , { status : 500 } ) ;
@@ -98,7 +112,10 @@ interface IdpIntentBody {
98112 response ?: { userId ?: string ; idpInformation ?: IdpInformation } ;
99113}
100114
101- async function handleIdpIntent ( body : IdpIntentBody , env : Env ) : Promise < Manipulation > {
115+ async function handleIdpIntent (
116+ body : IdpIntentBody ,
117+ env : Env ,
118+ ) : Promise < Manipulation > {
102119 const idp = body ?. response ?. idpInformation ;
103120 const userId = body ?. response ?. userId ;
104121 if ( ! idp || ! userId ) {
@@ -114,12 +131,16 @@ async function handleIdpIntent(body: IdpIntentBody, env: Env): Promise<Manipulat
114131 if ( idp . idpId === env . GITHUB_IDP_ID ) {
115132 // GitHub: primary email comes from /user/emails (profile email may be null
116133 // when the user keeps it private); name split mirrors the old v1 action.
117- email = ( await githubPrimaryEmail ( idp ?. oauth ?. accessToken ) ) ?? raw . email ?? null ;
134+ email = ( await githubPrimaryEmail ( idp ?. oauth ?. accessToken ) ) ?? raw . email ??
135+ null ;
118136 ( { firstName, lastName } = splitName ( raw . login ?? idp . userName , raw . name ) ) ;
119137 } else if ( idp . idpId === env . GITLAB_IDP_ID ) {
120138 // GitLab is OIDC — email + name are already in the userinfo.
121139 email = raw . email ?? null ;
122- ( { firstName, lastName } = splitName ( raw . nickname ?? raw . preferred_username ?? idp . userName , raw . name ) ) ;
140+ ( { firstName, lastName } = splitName (
141+ raw . nickname ?? raw . preferred_username ?? idp . userName ,
142+ raw . name ,
143+ ) ) ;
123144 } else {
124145 console . log ( `[idp-intent] idpId ${ idp . idpId } not handled — skipping` ) ;
125146 return { } ;
@@ -135,7 +156,9 @@ async function handleIdpIntent(body: IdpIntentBody, env: Env): Promise<Manipulat
135156 return { } ;
136157}
137158
138- async function githubPrimaryEmail ( accessToken : string | undefined ) : Promise < string | null > {
159+ async function githubPrimaryEmail (
160+ accessToken : string | undefined ,
161+ ) : Promise < string | null > {
139162 if ( ! accessToken ) return null ;
140163 const res = await fetch ( "https://api.github.com/user/emails" , {
141164 headers : {
@@ -148,13 +171,18 @@ async function githubPrimaryEmail(accessToken: string | undefined): Promise<stri
148171 console . warn ( `[github] /user/emails -> ${ res . status } ` ) ;
149172 return null ;
150173 }
151- const emails = ( await res . json ( ) ) as Array < { email : string ; primary : boolean } > ;
174+ const emails = ( await res . json ( ) ) as Array <
175+ { email : string ; primary : boolean }
176+ > ;
152177 return emails . find ( ( e ) => e . primary ) ?. email ?? null ;
153178}
154179
155180// Mirror the old v1 action's name split: first word is given name, the rest is
156181// family name (defaulting to a single space, which ZITADEL requires non-empty).
157- export function splitName ( login : string | undefined , name : unknown ) : { firstName : string ; lastName : string } {
182+ export function splitName (
183+ login : string | undefined ,
184+ name : unknown ,
185+ ) : { firstName : string ; lastName : string } {
158186 let firstName = login ?? "" ;
159187 let lastName = " " ;
160188 const parts = String ( name ?? "" ) . trim ( ) . split ( " " ) ;
@@ -170,27 +198,32 @@ async function updateHumanUser(
170198 firstName : string ,
171199 lastName : string ,
172200) : Promise < void > {
173- const res = await fetch ( `https://${ env . ZITADEL_DOMAIN } /v2/users/human/${ userId } ` , {
174- method : "PUT" ,
175- headers : {
176- authorization : `Bearer ${ env . ZITADEL_TOKEN } ` ,
177- "content-type" : "application/json" ,
178- } ,
179- body : JSON . stringify ( {
180- // displayName isn't auto-derived on update (only at creation), so set it.
181- profile : {
182- givenName : firstName ,
183- familyName : lastName ,
184- displayName : `${ firstName } ${ lastName } ` ,
201+ const res = await fetch (
202+ `https://${ env . ZITADEL_DOMAIN } /v2/users/human/${ userId } ` ,
203+ {
204+ method : "PUT" ,
205+ headers : {
206+ authorization : `Bearer ${ env . ZITADEL_TOKEN } ` ,
207+ "content-type" : "application/json" ,
185208 } ,
186- email : { email, isVerified : true } ,
187- } ) ,
188- } ) ;
209+ body : JSON . stringify ( {
210+ // displayName isn't auto-derived on update (only at creation), so set it.
211+ profile : {
212+ givenName : firstName ,
213+ familyName : lastName ,
214+ displayName : `${ firstName } ${ lastName } ` ,
215+ } ,
216+ email : { email, isVerified : true } ,
217+ } ) ,
218+ } ,
219+ ) ;
189220 const text = await res . text ( ) ;
190221 if ( ! res . ok ) {
191222 throw new Error ( `UpdateHumanUser ${ userId } -> ${ res . status } ${ text } ` ) ;
192223 }
193- console . log ( `[idp-intent] updated ${ userId } email=${ email } name="${ firstName } ${ lastName } "` ) ;
224+ console . log (
225+ `[idp-intent] updated ${ userId } email=${ email } name="${ firstName } ${ lastName } "` ,
226+ ) ;
194227}
195228
196229// --- /token : preuserinfo (roles) + presamlresponse (saml) ---------------
@@ -214,7 +247,7 @@ async function handleToken(body: TokenBody, env: Env): Promise<Manipulation> {
214247 case "function/preuserinfo" :
215248 return mapRoles ( body ) ;
216249 case "function/presamlresponse" :
217- return mapSamlRoles ( body , env ) ;
250+ return await mapSamlRoles ( body , env ) ;
218251 default :
219252 console . log ( `[token] unhandled function ${ body ?. function } ` ) ;
220253 return { } ;
@@ -240,7 +273,9 @@ export function mapRoles(body: TokenBody): Manipulation {
240273 . filter ( Boolean ) [ 0 ] ;
241274 if ( ! projectId ) return { } ;
242275
243- const roles = grants . filter ( ( g ) => g . project_id === projectId ) . map ( ( g ) => g . roles ) ;
276+ const roles = grants . filter ( ( g ) => g . project_id === projectId ) . map ( ( g ) =>
277+ g . roles
278+ ) ;
244279 if ( roles . length === 1 ) {
245280 return { append_claims : [ { key : "role" , value : String ( roles [ 0 ] ) } ] } ;
246281 }
@@ -254,7 +289,10 @@ export function mapRoles(body: TokenBody): Manipulation {
254289// SAML attribute — matching the v1 action. Unlike preuserinfo, the
255290// presamlresponse payload carries no grants (only `user`), so fetch them from
256291// the management API by user id.
257- export async function mapSamlRoles ( body : TokenBody , env : Env ) : Promise < Manipulation > {
292+ export async function mapSamlRoles (
293+ body : TokenBody ,
294+ env : Env ,
295+ ) : Promise < Manipulation > {
258296 const userId = body ?. user ?. id ;
259297 if ( ! userId ) return { } ;
260298
@@ -272,12 +310,18 @@ export async function mapSamlRoles(body: TokenBody, env: Env): Promise<Manipulat
272310 if ( ! res . ok ) {
273311 // Loud: a failure here means the SAML assertion ships with no Roles, which
274312 // can silently drop SP-side access (e.g. OVHCloud admin).
275- console . error ( `[saml] grants search failed for ${ userId } : ${ res . status } — assertion will have no Roles` ) ;
313+ console . error (
314+ `[saml] grants search failed for ${ userId } : ${ res . status } — assertion will have no Roles` ,
315+ ) ;
276316 return { } ;
277317 }
278318
279- const data = ( await res . json ( ) ) as { result ?: Array < { roleKeys ?: string [ ] } > } ;
280- const roles = [ ...new Set ( ( data . result ?? [ ] ) . flatMap ( ( g ) => g . roleKeys ?? [ ] ) ) ] ;
319+ const data = ( await res . json ( ) ) as {
320+ result ?: Array < { roleKeys ?: string [ ] } > ;
321+ } ;
322+ const roles = [
323+ ...new Set ( ( data . result ?? [ ] ) . flatMap ( ( g ) => g . roleKeys ?? [ ] ) ) ,
324+ ] ;
281325 if ( roles . length === 0 ) return { } ;
282326
283327 return {
@@ -297,14 +341,21 @@ export async function mapSamlRoles(body: TokenBody, env: Env): Promise<Manipulat
297341// outside this window so a captured request can't be replayed indefinitely.
298342const SIGNATURE_TOLERANCE_SECONDS = 300 ;
299343
300- export async function verifySignature ( signatureHeader : string , rawBody : string , signingKey : string ) : Promise < boolean > {
344+ export async function verifySignature (
345+ signatureHeader : string ,
346+ rawBody : string ,
347+ signingKey : string ,
348+ ) : Promise < boolean > {
301349 const parts = signatureHeader . split ( "," ) ;
302350 const timestamp = parts . find ( ( e ) => e . startsWith ( "t=" ) ) ?. slice ( 2 ) ;
303351 const signature = parts . find ( ( e ) => e . startsWith ( "v1=" ) ) ?. slice ( 3 ) ;
304352 if ( ! timestamp || ! signature ) return false ;
305353
306354 const ts = Number ( timestamp ) ;
307- if ( ! Number . isFinite ( ts ) || Math . abs ( Date . now ( ) / 1000 - ts ) > SIGNATURE_TOLERANCE_SECONDS ) {
355+ if (
356+ ! Number . isFinite ( ts ) ||
357+ Math . abs ( Date . now ( ) / 1000 - ts ) > SIGNATURE_TOLERANCE_SECONDS
358+ ) {
308359 console . warn ( `[verify] timestamp outside tolerance (t=${ timestamp } )` ) ;
309360 return false ;
310361 }
@@ -317,7 +368,11 @@ export async function verifySignature(signatureHeader: string, rawBody: string,
317368 false ,
318369 [ "sign" ] ,
319370 ) ;
320- const sig = await crypto . subtle . sign ( "HMAC" , key , encoder . encode ( `${ timestamp } .${ rawBody } ` ) ) ;
371+ const sig = await crypto . subtle . sign (
372+ "HMAC" ,
373+ key ,
374+ encoder . encode ( `${ timestamp } .${ rawBody } ` ) ,
375+ ) ;
321376 const computed = Array . from ( new Uint8Array ( sig ) )
322377 . map ( ( b ) => b . toString ( 16 ) . padStart ( 2 , "0" ) )
323378 . join ( "" ) ;
0 commit comments