@@ -14,6 +14,7 @@ import { sendTransactionalEmail } from "@/utils/email.js";
1414import { resolveSignupName } from "@/utils/infer-name.js" ;
1515import { getOrCreatePersonalOrg } from "@/utils/personal-org.js" ;
1616
17+ import { logAuditEvent } from "@llmgateway/audit" ;
1718import { db , eq , tables } from "@llmgateway/db" ;
1819import { logger } from "@llmgateway/logger" ;
1920import { getResendClient , resendAudienceId } from "@llmgateway/shared/email" ;
@@ -637,6 +638,15 @@ export const apiAuth: ReturnType<typeof instrumentBetterAuth> =
637638 // ssoProvider/user/account models. Org membership is provisioned
638639 // out-of-band via SCIM (see routes/scim.ts).
639640 organizationProvisioning : { disabled : true } ,
641+ // Adds `domainVerified` to the plugin's ssoProvider model. The SAML
642+ // callback treats a provider as trusted for implicit account linking
643+ // only when `domainVerified` is true and the asserted email is on
644+ // the connection's domain — without this, SCIM-provisioned users can
645+ // never complete their first SAML login (`account_not_linked`). It
646+ // also makes SAML sign-in reject unverified providers outright, so
647+ // registration stamps `domainVerified: true` (see routes/sso.ts)
648+ // instead of using the plugin's DNS-TXT verification flow.
649+ domainVerification : { enabled : true } ,
640650 } ) ,
641651 ] ,
642652 emailAndPassword : {
@@ -937,6 +947,32 @@ The LLM Gateway Team`.trim();
937947 return ;
938948 } ) ,
939949 after : createAuthMiddleware ( async ( ctx ) => {
950+ // Prefill the username on the IdP sign-in page for SP-initiated SAML.
951+ // The SSO plugin only forwards `loginHint` on its OIDC path; for SAML
952+ // it returns the redirect URL untouched. Microsoft Entra ID (and other
953+ // IdPs that support it) honor a `login_hint` query param on the SAML2
954+ // SSO URL, so append the email the user already typed. Scoped to the
955+ // SAML redirect binding via the `SAMLRequest` param; a stray
956+ // `login_hint` is ignored by IdPs that don't support it.
957+ if ( ctx . path === "/sign-in/sso" ) {
958+ const returned = ctx . context . returned ;
959+ const email = ( ctx . body as { email ?: string } | undefined ) ?. email
960+ ?. trim ( )
961+ . toLowerCase ( ) ;
962+ if (
963+ email &&
964+ returned &&
965+ typeof returned === "object" &&
966+ "url" in returned &&
967+ typeof returned . url === "string" &&
968+ returned . url . includes ( "SAMLRequest" )
969+ ) {
970+ const url = new URL ( returned . url ) ;
971+ url . searchParams . set ( "login_hint" , email ) ;
972+ ctx . context . returned = { ...returned , url : url . toString ( ) } ;
973+ }
974+ }
975+
940976 // Create default org/project for first-time sessions (email signup or first social sign-in)
941977 const newSession = ctx . context . newSession ;
942978 if ( ! newSession ?. user ) {
@@ -978,6 +1014,43 @@ The LLM Gateway Team`.trim();
9781014 ) ;
9791015 }
9801016
1017+ // Audit enterprise SSO sign-ins. These arrive on the plugin's
1018+ // `/sso/...` callback paths; resolve the org from the SSO provider
1019+ // whose slug matches one of the user's linked accounts (the same
1020+ // derivation used for `isSsoUser`). Logged before the org
1021+ // auto-creation early-returns below so every SSO login is recorded.
1022+ if ( ctx . path . startsWith ( "/sso/" ) ) {
1023+ const linkedAccounts = await db . query . account . findMany ( {
1024+ where : { userId : { eq : userId } } ,
1025+ columns : { providerId : true } ,
1026+ } ) ;
1027+ const providerIds = linkedAccounts . map ( ( a ) => a . providerId ) ;
1028+ const provider = providerIds . length
1029+ ? await db . query . ssoProvider . findFirst ( {
1030+ where : { providerId : { in : providerIds } } ,
1031+ columns : {
1032+ id : true ,
1033+ providerId : true ,
1034+ organizationId : true ,
1035+ } ,
1036+ } )
1037+ : null ;
1038+ if ( provider ?. organizationId ) {
1039+ await logAuditEvent ( {
1040+ organizationId : provider . organizationId ,
1041+ userId,
1042+ action : "sso.sign_in" ,
1043+ resourceType : "sso_session" ,
1044+ resourceId : provider . id ,
1045+ metadata : {
1046+ resourceName : provider . providerId ,
1047+ targetUserId : userId ,
1048+ targetUserEmail : newSession . user . email ,
1049+ } ,
1050+ } ) ;
1051+ }
1052+ }
1053+
9811054 // SSO-only enforcement catch-all: reject any session created through
9821055 // a non-SSO flow (password, social, passkey) for an enforced domain.
9831056 // SSO logins arrive on the plugin's `/sso/...` callback paths, which
0 commit comments