mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
225 lines
7.3 KiB
TypeScript
225 lines
7.3 KiB
TypeScript
import { Request, Response, NextFunction } from "express";
|
|
import { z } from "zod";
|
|
import { db } from "@server/db";
|
|
import {
|
|
users,
|
|
userOrgs,
|
|
orgs,
|
|
idpOrg,
|
|
idp,
|
|
idpOidcConfig
|
|
} from "@server/db";
|
|
import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm";
|
|
import response from "@server/lib/response";
|
|
import HttpCode from "@server/types/HttpCode";
|
|
import createHttpError from "http-errors";
|
|
import logger from "@server/logger";
|
|
import { fromError } from "zod-validation-error";
|
|
import { OpenAPITags, registry } from "@server/openApi";
|
|
import { UserType } from "@server/types/UserTypes";
|
|
|
|
const lookupBodySchema = z.strictObject({
|
|
identifier: z.string().min(1).toLowerCase()
|
|
});
|
|
|
|
export type LookupUserResponse = {
|
|
found: boolean;
|
|
identifier: string;
|
|
accounts: Array<{
|
|
userId: string;
|
|
email: string | null;
|
|
username: string;
|
|
hasInternalAuth: boolean;
|
|
orgs: Array<{
|
|
orgId: string;
|
|
orgName: string;
|
|
idps: Array<{
|
|
idpId: number;
|
|
name: string;
|
|
variant: string | null;
|
|
}>;
|
|
hasInternalAuth: boolean;
|
|
}>;
|
|
}>;
|
|
};
|
|
|
|
// registry.registerPath({
|
|
// method: "post",
|
|
// path: "/auth/lookup-user",
|
|
// description: "Lookup user accounts by username or email and return available authentication methods.",
|
|
// tags: [OpenAPITags.Auth],
|
|
// request: {
|
|
// body: lookupBodySchema
|
|
// },
|
|
// responses: {}
|
|
// });
|
|
|
|
export async function lookupUser(
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
): Promise<any> {
|
|
try {
|
|
const parsedBody = lookupBodySchema.safeParse(req.body);
|
|
if (!parsedBody.success) {
|
|
return next(
|
|
createHttpError(
|
|
HttpCode.BAD_REQUEST,
|
|
fromError(parsedBody.error).toString()
|
|
)
|
|
);
|
|
}
|
|
|
|
const { identifier } = parsedBody.data;
|
|
|
|
// Query users matching identifier (case-insensitive)
|
|
// Match by username OR email
|
|
const matchingUsers = await db
|
|
.select({
|
|
userId: users.userId,
|
|
email: users.email,
|
|
username: users.username,
|
|
type: users.type,
|
|
passwordHash: users.passwordHash,
|
|
idpId: users.idpId
|
|
})
|
|
.from(users)
|
|
.where(
|
|
or(
|
|
sql`LOWER(${users.username}) = ${identifier}`,
|
|
sql`LOWER(${users.email}) = ${identifier}`
|
|
)
|
|
);
|
|
|
|
if (!matchingUsers || matchingUsers.length === 0) {
|
|
return response<LookupUserResponse>(res, {
|
|
data: {
|
|
found: false,
|
|
identifier,
|
|
accounts: []
|
|
},
|
|
success: true,
|
|
error: false,
|
|
message: "No accounts found",
|
|
status: HttpCode.OK
|
|
});
|
|
}
|
|
|
|
// Get unique user IDs
|
|
const userIds = [...new Set(matchingUsers.map((u) => u.userId))];
|
|
|
|
// Get all org memberships for these users
|
|
const orgMemberships = await db
|
|
.select({
|
|
userId: userOrgs.userId,
|
|
orgId: userOrgs.orgId,
|
|
orgName: orgs.name
|
|
})
|
|
.from(userOrgs)
|
|
.innerJoin(orgs, eq(orgs.orgId, userOrgs.orgId))
|
|
.where(inArray(userOrgs.userId, userIds));
|
|
|
|
// Get unique org IDs
|
|
const orgIds = [...new Set(orgMemberships.map((m) => m.orgId))];
|
|
|
|
// Get all IdPs for these orgs
|
|
const orgIdps =
|
|
orgIds.length > 0
|
|
? await db
|
|
.select({
|
|
orgId: idpOrg.orgId,
|
|
idpId: idp.idpId,
|
|
idpName: idp.name,
|
|
variant: idpOidcConfig.variant
|
|
})
|
|
.from(idpOrg)
|
|
.innerJoin(idp, eq(idp.idpId, idpOrg.idpId))
|
|
.innerJoin(
|
|
idpOidcConfig,
|
|
eq(idpOidcConfig.idpId, idp.idpId)
|
|
)
|
|
.where(inArray(idpOrg.orgId, orgIds))
|
|
: [];
|
|
|
|
// Build response structure
|
|
const accounts: LookupUserResponse["accounts"] = [];
|
|
|
|
for (const user of matchingUsers) {
|
|
const hasInternalAuth =
|
|
user.type === UserType.Internal && user.passwordHash !== null;
|
|
|
|
// Get orgs for this user
|
|
const userOrgMemberships = orgMemberships.filter(
|
|
(m) => m.userId === user.userId
|
|
);
|
|
|
|
// Deduplicate orgs (user might have multiple memberships in same org)
|
|
const uniqueOrgs = new Map<string, typeof userOrgMemberships[0]>();
|
|
for (const membership of userOrgMemberships) {
|
|
if (!uniqueOrgs.has(membership.orgId)) {
|
|
uniqueOrgs.set(membership.orgId, membership);
|
|
}
|
|
}
|
|
|
|
const orgsData = Array.from(uniqueOrgs.values()).map((membership) => {
|
|
// Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP
|
|
// Only show IdPs where the user's idpId matches
|
|
// Internal users don't have an idpId, so they won't see any IdPs
|
|
const orgIdpsList = orgIdps
|
|
.filter((idp) => {
|
|
if (idp.orgId !== membership.orgId) {
|
|
return false;
|
|
}
|
|
// Only show IdPs where the user (with exact identifier) is authenticated via that IdP
|
|
// This means user.idpId must match idp.idpId
|
|
if (user.idpId !== null && user.idpId === idp.idpId) {
|
|
return true;
|
|
}
|
|
return false;
|
|
})
|
|
.map((idp) => ({
|
|
idpId: idp.idpId,
|
|
name: idp.idpName,
|
|
variant: idp.variant
|
|
}));
|
|
|
|
// Check if user has internal auth for this org
|
|
// User has internal auth if they have an internal account type
|
|
const orgHasInternalAuth = hasInternalAuth;
|
|
|
|
return {
|
|
orgId: membership.orgId,
|
|
orgName: membership.orgName,
|
|
idps: orgIdpsList,
|
|
hasInternalAuth: orgHasInternalAuth
|
|
};
|
|
});
|
|
|
|
accounts.push({
|
|
userId: user.userId,
|
|
email: user.email,
|
|
username: user.username,
|
|
hasInternalAuth,
|
|
orgs: orgsData
|
|
});
|
|
}
|
|
|
|
return response<LookupUserResponse>(res, {
|
|
data: {
|
|
found: true,
|
|
identifier,
|
|
accounts
|
|
},
|
|
success: true,
|
|
error: false,
|
|
message: "User lookup completed",
|
|
status: HttpCode.OK
|
|
});
|
|
} catch (error) {
|
|
logger.error(error);
|
|
return next(
|
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
);
|
|
}
|
|
}
|