Skip to content

Commit 41e2b61

Browse files
steebchenclaude
andcommitted
fix(auth): correct SCIM email lookup, keep SSO redirect
Address CodeRabbit review on the follow-up PR: - SCIM `GET /Users?filter=userName eq "…"` resolved an arbitrary org member and compared its email, so in multi-member orgs an existing user could be missed and the IdP would retry against the 409 create path. Resolve the user by email first, then confirm org membership. externalId lookups keep their DB-level filter. - Login: carry the validated `?redirect=` target through `errorCallbackURL` so a failed SSO attempt returns to /login with the intended destination. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3a20c62 commit 41e2b61

2 files changed

Lines changed: 35 additions & 15 deletions

File tree

apps/api/src/routes/scim.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -303,27 +303,40 @@ scim.get("/Users", async (c) => {
303303
// IdPs poll with an `eq` filter to check whether a specific user already
304304
// exists; resolve that with a single targeted lookup instead of scanning the
305305
// whole directory.
306-
if (emailFilter || externalIdFilter) {
306+
if (emailFilter) {
307+
// Resolve the user by email (provisioned emails are stored lowercased),
308+
// then confirm they belong to this org — filtering must key off the email,
309+
// not an arbitrary member row.
310+
const user = await db.query.user.findFirst({
311+
where: { email: { eq: emailFilter.toLowerCase() } },
312+
columns: { id: true, email: true, name: true },
313+
});
314+
const membership = user ? await getMembership(user.id, orgId) : null;
315+
const resources =
316+
user && membership
317+
? [toScimUser(user, true, membership.scimExternalId)]
318+
: [];
319+
return scimJson({
320+
schemas: [SCHEMA_LIST],
321+
totalResults: resources.length,
322+
startIndex,
323+
itemsPerPage: resources.length,
324+
Resources: resources,
325+
});
326+
}
327+
328+
if (externalIdFilter) {
307329
const membership = await db.query.userOrganization.findFirst({
308330
where: {
309331
organizationId: { eq: orgId },
310-
...(externalIdFilter
311-
? { scimExternalId: { eq: externalIdFilter } }
312-
: {}),
332+
scimExternalId: { eq: externalIdFilter },
313333
},
314334
columns: { scimExternalId: true },
315335
with: { user: { columns: { id: true, email: true, name: true } } },
316336
});
317-
const match =
318-
membership?.user &&
319-
(!emailFilter ||
320-
membership.user.email.toLowerCase() === emailFilter.toLowerCase())
321-
? membership
322-
: null;
323-
const resources =
324-
match && match.user
325-
? [toScimUser(match.user, true, match.scimExternalId)]
326-
: [];
337+
const resources = membership?.user
338+
? [toScimUser(membership.user, true, membership.scimExternalId)]
339+
: [];
327340
return scimJson({
328341
schemas: [SCHEMA_LIST],
329342
totalResults: resources.length,

apps/ui/src/app/login/page.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,19 @@ export default function Login() {
218218
WebAuthnAbortService.cancelCeremony();
219219
try {
220220
const origin = location.protocol + "//" + location.host;
221+
// Carry the validated `?redirect=` target through the error path too, so a
222+
// failed SSO attempt returns to /login with the intended destination and a
223+
// retry still lands the user there.
224+
const errorUrl = new URL("/login", origin);
225+
if (redirectTarget !== "/dashboard") {
226+
errorUrl.searchParams.set("redirect", redirectTarget);
227+
}
221228
const res = await signIn.sso({
222229
email: parsed.data,
223230
// Honor the same validated `?redirect=` target as the other sign-in
224231
// methods so SSO users also land on their intended post-login route.
225232
callbackURL: origin + redirectTarget,
226-
errorCallbackURL: origin + "/login",
233+
errorCallbackURL: errorUrl.toString(),
227234
});
228235
if (res?.error) {
229236
toast({

0 commit comments

Comments
 (0)