@@ -54,93 +54,114 @@ class SsoStrategy extends Strategy<AuthUser, SsoVerifyParams> {
5454 }
5555}
5656
57- export function addSsoStrategy ( authenticator : Authenticator < AuthUser > ) {
58- authenticator . use (
59- new SsoStrategy ( async ( { profile, flow } ) => {
60- const decision = await ssoController . resolveSsoIdentity ( { profile } ) ;
61- if ( decision . isErr ( ) ) {
62- // Surfaces "feature_disabled" in OSS deployments. The callback
63- // route's error path translates this into a generic
64- // sign-in-failed user-facing message.
65- throw new Error ( `SSO resolve failed: ${ decision . error } ` ) ;
66- }
57+ // Resolve the host User for a verified SSO profile, creating one on the
58+ // `create_new_user` decision. Throws on an errored decision — this surfaces
59+ // "feature_disabled" in OSS deployments, which the callback route's error
60+ // path translates into a generic sign-in-failed user-facing message.
61+ async function resolveSsoUserId (
62+ profile : SsoProfile
63+ ) : Promise < { userId : string ; isNewUser : boolean } > {
64+ const decision = await ssoController . resolveSsoIdentity ( { profile } ) ;
65+ if ( decision . isErr ( ) ) {
66+ throw new Error ( `SSO resolve failed: ${ decision . error } ` ) ;
67+ }
68+
69+ if ( decision . value . kind === "create_new_user" ) {
70+ const created = await findOrCreateSsoUser ( {
71+ authenticationMethod : "SSO" ,
72+ email : profile . email ,
73+ firstName : profile . firstName ,
74+ lastName : profile . lastName ,
75+ } ) ;
76+ return { userId : created . user . id , isNewUser : created . isNewUser } ;
77+ }
6778
68- const value = decision . value ;
79+ return { userId : decision . value . userId , isNewUser : false } ;
80+ }
6981
70- let userId : string ;
71- let isNewUser = false ;
82+ // Best-effort: attaching the IdP identity row is an optimisation for the
83+ // next login (it lets resolveSsoIdentity take the existing_user_by_idp fast
84+ // path instead of falling back to linked_by_email). The user is already
85+ // authenticated by this point, so we log and continue rather than failing
86+ // the sign-in; a later successful login will write the row.
87+ async function attachSsoIdentityBestEffort (
88+ userId : string ,
89+ profile : SsoProfile ,
90+ flow : SsoFlow
91+ ) : Promise < void > {
92+ const attach = await ssoController . attachSsoIdentity ( { userId, profile } ) ;
93+ if ( attach . isErr ( ) ) {
94+ logger . warn ( "SSO attachSsoIdentity failed" , { reason : attach . error , userId, flow } ) ;
95+ }
96+ }
7297
73- if ( value . kind === "create_new_user" ) {
74- const created = await findOrCreateSsoUser ( {
75- authenticationMethod : "SSO" ,
76- email : profile . email ,
77- firstName : profile . firstName ,
78- lastName : profile . lastName ,
79- } ) ;
80- userId = created . user . id ;
81- isNewUser = created . isNewUser ;
82- } else {
83- userId = value . userId ;
84- }
98+ // Best-effort JIT org provisioning. Like attachSsoIdentity above, a failure
99+ // must not block an otherwise-valid sign-in: the user simply isn't
100+ // provisioned this time and a later login retries. "feature_disabled" is the
101+ // expected OSS-fallback result, so it's swallowed silently.
102+ async function provisionJitMembershipBestEffort (
103+ userId : string ,
104+ profile : SsoProfile ,
105+ flow : SsoFlow
106+ ) : Promise < void > {
107+ const jit = await ssoController . evaluateJit ( { userId, idpOrgId : profile . idpOrgId } ) ;
108+ if ( jit . isErr ( ) ) {
109+ if ( jit . error !== "feature_disabled" ) {
110+ logger . warn ( "SSO evaluateJit failed" , { reason : jit . error , userId, flow } ) ;
111+ }
112+ return ;
113+ }
85114
86- // Best-effort: attaching the IdP identity row is an optimisation
87- // for the next login (it lets resolveSsoIdentity take the
88- // existing_user_by_idp fast path instead of falling back to
89- // linked_by_email). The user is already authenticated by this
90- // point, so we log and continue rather than failing the sign-in;
91- // a later successful login will write the row.
92- const attach = await ssoController . attachSsoIdentity ( { userId, profile } ) ;
93- if ( attach . isErr ( ) ) {
94- logger . warn ( "SSO attachSsoIdentity failed" , {
95- reason : attach . error ,
96- userId,
97- flow,
98- } ) ;
99- }
115+ if ( ! jit . value . shouldProvision ) return ;
100116
101- const jit = await ssoController . evaluateJit ( {
102- userId,
103- idpOrgId : profile . idpOrgId ,
104- } ) ;
105- if ( jit . isOk ( ) && jit . value . shouldProvision ) {
106- const [ provisionError , result ] = await tryCatch (
107- ensureOrgMember ( {
108- userId,
109- organizationId : jit . value . organizationId ,
110- roleId : jit . value . roleId ,
111- source : "sso_jit" ,
112- } )
113- ) ;
114- if ( provisionError ) {
115- // Best-effort, like attachSsoIdentity above: a provisioning failure
116- // (e.g. the RBAC role couldn't be applied, so ensureOrgMember rolled
117- // back the membership) must not block an otherwise-valid sign-in.
118- // The user simply isn't provisioned this time; a later login retries.
119- logger . warn ( "SSO JIT provisioning failed" , {
120- reason :
121- provisionError instanceof Error ? provisionError . message : String ( provisionError ) ,
122- userId,
123- organizationId : jit . value . organizationId ,
124- flow,
125- } ) ;
126- } else if ( ! result . created ) {
127- logger . info ( "SSO JIT skipped — membership already exists" , {
128- userId,
129- organizationId : jit . value . organizationId ,
130- } ) ;
131- }
132- } else if ( jit . isErr ( ) && jit . error !== "feature_disabled" ) {
133- logger . warn ( "SSO evaluateJit failed" , { reason : jit . error , userId, flow } ) ;
134- }
117+ const [ provisionError , result ] = await tryCatch (
118+ ensureOrgMember ( {
119+ userId,
120+ organizationId : jit . value . organizationId ,
121+ roleId : jit . value . roleId ,
122+ source : "sso_jit" ,
123+ } )
124+ ) ;
125+ if ( provisionError ) {
126+ // e.g. the RBAC role couldn't be applied, so ensureOrgMember rolled back
127+ // the membership.
128+ logger . warn ( "SSO JIT provisioning failed" , {
129+ reason : provisionError instanceof Error ? provisionError . message : String ( provisionError ) ,
130+ userId,
131+ organizationId : jit . value . organizationId ,
132+ flow,
133+ } ) ;
134+ return ;
135+ }
136+
137+ if ( ! result . created ) {
138+ logger . info ( "SSO JIT skipped — membership already exists" , {
139+ userId,
140+ organizationId : jit . value . organizationId ,
141+ } ) ;
142+ }
143+ }
144+
145+ async function runPostAuthentication ( userId : string , isNewUser : boolean ) : Promise < void > {
146+ const user = await prisma . user . findFirst ( { where : { id : userId } } ) ;
147+ if ( ! user ) {
148+ // The user was just resolved or created above, so a null here means it
149+ // was hard-deleted mid-flow (or a DB inconsistency). Fail closed — throw
150+ // rather than skipping postAuthentication and still returning a valid
151+ // AuthUser, which would mint a session for a user we can't confirm.
152+ throw new Error ( `SSO user not found after resolution: ${ userId } ` ) ;
153+ }
154+ await postAuthentication ( { user, isNewUser, loginMethod : "SSO" } ) ;
155+ }
156+
157+ export function addSsoStrategy ( authenticator : Authenticator < AuthUser > ) {
158+ authenticator . use (
159+ new SsoStrategy ( async ( { profile, flow } ) => {
160+ const { userId, isNewUser } = await resolveSsoUserId ( profile ) ;
135161
136- const user = await prisma . user . findFirst ( { where : { id : userId } } ) ;
137- if ( user ) {
138- await postAuthentication ( {
139- user,
140- isNewUser,
141- loginMethod : "SSO" ,
142- } ) ;
143- }
162+ await attachSsoIdentityBestEffort ( userId , profile , flow ) ;
163+ await provisionJitMembershipBestEffort ( userId , profile , flow ) ;
164+ await runPostAuthentication ( userId , isNewUser ) ;
144165
145166 // Carry the SSO marker on the returned AuthUser so the session is
146167 // self-describing — `revalidateSsoSession()` keys off `AuthUser.sso`,
0 commit comments