Skip to content

Commit 77add31

Browse files
committed
Merge remote-tracking branch 'origin/main' into enforce-key-limits-under-user-limits
2 parents 7999fd3 + 6a6b1c6 commit 77add31

40 files changed

Lines changed: 28397 additions & 268 deletions

apps/api/src/auth/config.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { sendTransactionalEmail } from "@/utils/email.js";
1414
import { resolveSignupName } from "@/utils/infer-name.js";
1515
import { getOrCreatePersonalOrg } from "@/utils/personal-org.js";
1616

17+
import { logAuditEvent } from "@llmgateway/audit";
1718
import { db, eq, tables } from "@llmgateway/db";
1819
import { logger } from "@llmgateway/logger";
1920
import { 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
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { and, db, eq, inArray, ne, tables } from "@llmgateway/db";
2+
3+
/**
4+
* Revoke the API keys a member created within an organization.
5+
*
6+
* API keys are scoped to a project (which belongs to an org) and record their
7+
* creator in `createdBy`, but the gateway authenticates a request purely on
8+
* `apiKey.status`/project status — it never re-checks that the creator is still
9+
* a member. So when a member is removed (via the dashboard) or deprovisioned
10+
* (via SCIM), their `userOrganization` row is deleted but their keys keep
11+
* working. Call this at every membership-removal chokepoint to soft-delete those
12+
* keys so access is actually revoked.
13+
*
14+
* Scoped to `keyType: "user"` (personal developer keys) on purpose: platform and
15+
* end-user keys are shared org infrastructure that must not break because the
16+
* individual who happened to create them left. Setting `status: "deleted"`
17+
* auto-invalidates the gateway's `apiKey` cache, so revocation takes effect on
18+
* the next request.
19+
*
20+
* @returns the number of keys revoked.
21+
*/
22+
export async function revokeMemberApiKeys(
23+
userId: string,
24+
organizationId: string,
25+
): Promise<number> {
26+
const projects = await db.query.project.findMany({
27+
where: { organizationId: { eq: organizationId } },
28+
columns: { id: true },
29+
});
30+
const projectIds = projects.map((p) => p.id);
31+
if (projectIds.length === 0) {
32+
return 0;
33+
}
34+
35+
const revoked = await db
36+
.update(tables.apiKey)
37+
.set({ status: "deleted" })
38+
.where(
39+
and(
40+
eq(tables.apiKey.createdBy, userId),
41+
inArray(tables.apiKey.projectId, projectIds),
42+
eq(tables.apiKey.keyType, "user"),
43+
ne(tables.apiKey.status, "deleted"),
44+
),
45+
)
46+
.returning({ id: tables.apiKey.id });
47+
48+
return revoked.length;
49+
}

0 commit comments

Comments
 (0)