successful log in loop poc

This commit is contained in:
miloschwartz
2025-04-13 17:57:27 -04:00
parent 7556a59e11
commit 53be2739bb
37 changed files with 789 additions and 474 deletions

View File

@@ -1,126 +0,0 @@
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import {
IdpSession,
idpSessions,
IdpUser,
idpUser,
resourceSessions
} from "@server/db/schemas";
import db from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import cookie from "cookie";
const SESSION_COOKIE_EXPIRES =
1000 *
60 *
60 *
config.getRawConfig().server.dashboard_session_length_hours;
const COOKIE_DOMAIN =
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
export async function createIdpSession(
token: string,
idpUserId: string
): Promise<IdpSession> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token))
);
const session: IdpSession = {
idpSessionId: sessionId,
idpUserId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime()
};
await db.insert(idpSessions).values(session);
return session;
}
export async function validateIdpSessionToken(
token: string
): Promise<IdpSessionValidationResult> {
const idpSessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token))
);
const result = await db
.select({ idpUser: idpUser, idpSession: idpSessions })
.from(idpSessions)
.innerJoin(idpUser, eq(idpSessions.idpUserId, idpUser.idpUserId))
.where(eq(idpSessions.idpSessionId, idpSessionId));
if (result.length < 1) {
return { session: null, user: null };
}
const { idpUser: idpUserRes, idpSession: idpSessionRes } = result[0];
if (Date.now() >= idpSessionRes.expiresAt) {
await db
.delete(idpSessions)
.where(eq(idpSessions.idpSessionId, idpSessionRes.idpSessionId));
return { session: null, user: null };
}
if (Date.now() >= idpSessionRes.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
idpSessionRes.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime();
await db.transaction(async (trx) => {
await trx
.update(idpSessions)
.set({
expiresAt: idpSessionRes.expiresAt
})
.where(
eq(idpSessions.idpSessionId, idpSessionRes.idpSessionId)
);
await trx
.update(resourceSessions)
.set({
expiresAt: idpSessionRes.expiresAt
})
.where(
eq(
resourceSessions.idpSessionId,
idpSessionRes.idpSessionId
)
);
});
}
return { session: idpSessionRes, user: idpUserRes };
}
export async function invalidateIdpSession(
idpSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
await trx
.delete(resourceSessions)
.where(eq(resourceSessions.idpSessionId, idpSessionId));
await trx
.delete(idpSessions)
.where(eq(idpSessions.idpSessionId, idpSessionId));
});
} catch (e) {
logger.error("Failed to invalidate session", e);
}
}
export function serializeIdpSessionCookie(
cookieName: string,
token: string,
isSecure: boolean,
expiresAt: Date
): string {
return cookie.serialize(cookieName, token, {
httpOnly: true,
sameSite: "lax",
expires: expiresAt,
path: "/",
secure: isSecure,
domain: COOKIE_DOMAIN
});
}
export type IdpSessionValidationResult =
| { session: IdpSession; user: IdpUser }
| { session: null; user: null };

View File

@@ -106,8 +106,14 @@ export const exitNodes = sqliteTable("exitNodes", {
export const users = sqliteTable("user", {
userId: text("id").primaryKey(),
email: text("email").notNull().unique(),
passwordHash: text("passwordHash").notNull(),
email: text("email"),
username: text("username").notNull(),
name: text("name"),
type: text("type").notNull(), // "internal", "oidc"
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "cascade"
}),
passwordHash: text("passwordHash"),
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
.notNull()
.default(false),
@@ -340,12 +346,6 @@ export const resourceSessions = sqliteTable("resourceSessions", {
.notNull()
.default(false),
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
idpSessionId: text("idpSessionId").references(
() => idpSessions.idpSessionId,
{
onDelete: "cascade"
}
),
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
onDelete: "cascade"
}),
@@ -424,6 +424,7 @@ export const supporterKey = sqliteTable("supporterKey", {
// Identity Providers
export const idp = sqliteTable("idp", {
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull()
});
@@ -445,9 +446,8 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
.notNull()
.default(false),
identifierPath: text("identifierPath").notNull(),
emailPath: text("emailPath"), // by default, this is "email"
namePath: text("namePath"), // by default, this is "name"
roleMapping: text("roleMapping"),
emailPath: text("emailPath"),
namePath: text("namePath"),
scopes: text("scopes").notNull()
});
@@ -455,41 +455,11 @@ export const idpOrg = sqliteTable("idpOrg", {
idpId: integer("idpId")
.notNull()
.references(() => idp.idpId, { onDelete: "cascade" }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
// IDP User
export const idpUser = sqliteTable("idpUser", {
idpUserId: text("idpUserId").primaryKey(),
identifier: text("identifier").notNull(),
idpId: integer("idpId")
.notNull()
.references(() => idp.idpId, { onDelete: "cascade" }),
email: text("email"),
name: text("name")
});
// IDP User Organization Link
export const idpUserOrg = sqliteTable("idpUserOrg", {
idpUserId: text("idpUserId")
.notNull()
.references(() => idpUser.idpUserId, { onDelete: "cascade" }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" })
});
export const idpSessions = sqliteTable("idpSessions", {
idpSessionId: text("idpSessionId").primaryKey(),
idpUserId: text("idpUserId")
.notNull()
.references(() => idpUser.idpUserId, { onDelete: "cascade" }),
expiresAt: integer("expiresAt").notNull()
roleMapping: text("roleMapping"),
orgMapping: text("orgMapping")
});
export type Org = InferSelectModel<typeof orgs>;
@@ -528,7 +498,4 @@ export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>;
export type IdpUser = InferSelectModel<typeof idpUser>;
export type IdpOrg = InferSelectModel<typeof idpOrg>;
export type IdpUserOrg = InferSelectModel<typeof idpUserOrg>;
export type IdpSession = InferSelectModel<typeof idpSessions>;

View File

@@ -91,7 +91,8 @@ const configSchema = z.object({
credentials: z.boolean().optional()
})
.optional(),
trust_proxy: z.boolean().optional().default(true)
trust_proxy: z.boolean().optional().default(true),
secret: z.string()
}),
traefik: z.object({
http_entrypoint: z.string(),

View File

@@ -1,8 +1,8 @@
import config from "@server/lib/config";
export function generateOidcRedirectUrl(orgId: string, idpId: number) {
export function generateOidcRedirectUrl(idpId: number) {
const dashboardUrl = config.getRawConfig().app.dashboard_url;
const redirectPath = `/auth/org/${orgId}/idp/${idpId}/oidc/callback`;
const redirectPath = `/auth/idp/${idpId}/oidc/callback`;
const redirectUrl = new URL(redirectPath, dashboardUrl).toString();
return redirectUrl;
}

View File

@@ -16,6 +16,7 @@ import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export const changePasswordBody = z
.object({
@@ -50,6 +51,15 @@ export async function changePassword(
const { newPassword, oldPassword, code } = parsedBody.data;
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
if (newPassword === oldPassword) {
return next(
@@ -62,7 +72,7 @@ export async function changePassword(
const validPassword = await verifyPassword(
oldPassword,
user.passwordHash
user.passwordHash!
);
if (!validPassword) {
return next(unauthorized());

View File

@@ -14,6 +14,7 @@ import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes";
export const disable2faBody = z
.object({
@@ -47,8 +48,17 @@ export async function disable2fa(
const { password, code } = parsedBody.data;
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
const validPassword = await verifyPassword(password, user.passwordHash);
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(unauthorized());
}
@@ -99,11 +109,11 @@ export async function disable2fa(
sendEmail(
TwoFactorAuthNotification({
email: user.email,
email: user.email!, // email is not null because we are checking user.type
enabled: false
}),
{
to: user.email,
to: user.email!,
from: config.getRawConfig().email?.no_reply,
subject: "Two-factor authentication disabled"
}

View File

@@ -7,7 +7,7 @@ import db from "@server/db";
import { users } from "@server/db/schemas";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -17,6 +17,7 @@ import config from "@server/lib/config";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { verifySession } from "@server/auth/sessions/verifySession";
import { UserType } from "@server/types/UserTypes";
export const loginBodySchema = z
.object({
@@ -69,7 +70,9 @@ export async function login(
const existingUserRes = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(eq(users.type, UserType.Internal), eq(users.email, email))
);
if (!existingUserRes || !existingUserRes.length) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
@@ -88,7 +91,7 @@ export async function login(
const validPassword = await verifyPassword(
password,
existingUser.passwordHash
existingUser.passwordHash!
);
if (!validPassword) {
if (config.getRawConfig().app.log_failed_attempts) {

View File

@@ -6,6 +6,7 @@ import { User } from "@server/db/schemas";
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
import config from "@server/lib/config";
import logger from "@server/logger";
import { UserType } from "@server/types/UserTypes";
export type RequestEmailVerificationCodeResponse = {
codeSent: boolean;
@@ -28,6 +29,15 @@ export async function requestEmailVerificationCode(
try {
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email verification is not supported for external users"
)
);
}
if (user.emailVerified) {
return next(
createHttpError(
@@ -37,7 +47,7 @@ export async function requestEmailVerificationCode(
);
}
await sendEmailVerificationCode(user.email, user.userId);
await sendEmailVerificationCode(user.email!, user.userId);
return response<RequestEmailVerificationCodeResponse>(res, {
data: {

View File

@@ -74,7 +74,7 @@ export async function requestPasswordReset(
await trx.insert(passwordResetTokens).values({
userId: existingUser[0].userId,
email: existingUser[0].email,
email: existingUser[0].email!,
tokenHash,
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
});

View File

@@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes";
export const requestTotpSecretBody = z
.object({
@@ -46,8 +47,17 @@ export async function requestTotpSecret(
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
const validPassword = await verifyPassword(password, user.passwordHash);
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(unauthorized());
}
@@ -63,7 +73,7 @@ export async function requestTotpSecret(
const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex);
const uri = createTOTPKeyURI("Pangolin", user.email, hex);
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
await db
.update(users)

View File

@@ -8,7 +8,7 @@ import createHttpError from "http-errors";
import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3";
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import moment from "moment";
import {
createSession,
@@ -21,6 +21,7 @@ import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export const signupBodySchema = z.object({
email: z
@@ -110,7 +111,9 @@ export async function signup(
const existing = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(eq(users.email, email), eq(users.type, UserType.Internal))
);
if (existing && existing.length > 0) {
if (!config.getRawConfig().flags?.require_email_verification) {
@@ -157,6 +160,8 @@ export async function signup(
await db.insert(users).values({
userId: userId,
type: UserType.Internal,
username: email,
email: email,
passwordHash,
dateCreated: moment().toISOString()

View File

@@ -14,6 +14,7 @@ import logger from "@server/logger";
import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
export const verifyTotpBody = z
.object({
@@ -48,6 +49,15 @@ export async function verifyTotp(
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
if (user.twoFactorEnabled) {
return next(
createHttpError(
@@ -111,11 +121,11 @@ export async function verifyTotp(
sendEmail(
TwoFactorAuthNotification({
email: user.email,
email: user.email!,
enabled: true
}),
{
to: user.email,
to: user.email!,
from: config.getRawConfig().email?.no_reply,
subject: "Two-factor authentication enabled"
}

View File

@@ -495,9 +495,9 @@ authenticated.delete(
// );
authenticated.put(
"/org/:orgId/idp/oidc",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createIdp),
"/idp/oidc",
verifyUserIsServerAdmin,
// verifyUserHasAction(ActionsEnum.createIdp),
idp.createOidcIdp
)
@@ -595,11 +595,11 @@ authRouter.post(
);
authRouter.post(
"/org/:orgId/idp/:idpId/oidc/generate-url",
"/idp/:idpId/oidc/generate-url",
idp.generateOidcUrl
)
authRouter.post(
"/org/:orgId/idp/:idpId/oidc/validate-callback",
"/idp/:idpId/oidc/validate-callback",
idp.validateOidcCallback
)

View File

@@ -8,27 +8,20 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import { generateOidcUrl } from "./generateOidcUrl";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
const paramsSchema = z.object({}).strict();
const bodySchema = z
.object({
name: z.string().nonempty(),
clientId: z.string().nonempty(),
clientSecret: z.string().nonempty(),
authUrl: z.string().url(),
tokenUrl: z.string().url(),
autoProvision: z.boolean(),
identifierPath: z.string().nonempty(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
roleMapping: z.string().optional(),
scopes: z.array(z.string().nonempty())
})
.strict();
@@ -44,7 +37,6 @@ registry.registerPath({
description: "Create an OIDC IdP for an organization.",
tags: [OpenAPITags.Org, OpenAPITags.Idp],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
@@ -62,16 +54,6 @@ export async function createOidcIdp(
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
@@ -82,8 +64,6 @@ export async function createOidcIdp(
);
}
const { orgId } = parsedParams.data;
const {
clientId,
clientSecret,
@@ -93,24 +73,15 @@ export async function createOidcIdp(
identifierPath,
emailPath,
namePath,
roleMapping,
autoProvision
name
} = parsedBody.data;
// Check if the org exists
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
if (!org) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
);
}
let idpId: number | undefined;
await db.transaction(async (trx) => {
const [idpRes] = await trx
.insert(idp)
.values({
name,
type: "oidc"
})
.returning();
@@ -123,21 +94,15 @@ export async function createOidcIdp(
clientSecret,
authUrl,
tokenUrl,
autoProvision,
autoProvision: true,
scopes: JSON.stringify(scopes),
identifierPath,
emailPath,
namePath,
roleMapping
});
await trx.insert(idpOrg).values({
idpId: idpRes.idpId,
orgId
namePath
});
});
const redirectUrl = generateOidcRedirectUrl(orgId, idpId as number);
const redirectUrl = generateOidcRedirectUrl(idpId as number);
return response<CreateIdpResponse>(res, {
data: {

View File

@@ -11,14 +11,21 @@ import { and, eq } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import cookie from "cookie";
import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
const paramsSchema = z
.object({
orgId: z.string(),
idpId: z.coerce.number()
})
.strict();
const bodySchema = z
.object({
redirectUrl: z.string()
})
.strict();
export type GenerateOidcUrlResponse = {
redirectUrl: string;
};
@@ -39,20 +46,25 @@ export async function generateOidcUrl(
);
}
const { orgId, idpId } = parsedParams.data;
const { idpId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { redirectUrl: postAuthRedirectUrl } = parsedBody.data;
const [existingIdp] = await db
.select()
.from(idp)
.innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId))
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(
and(
eq(idpOrg.orgId, orgId),
eq(idp.type, "oidc"),
eq(idp.idpId, idpId)
)
);
.where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
if (!existingIdp) {
return next(
@@ -65,7 +77,7 @@ export async function generateOidcUrl(
const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes);
const redirectUrl = generateOidcRedirectUrl(orgId, idpId);
const redirectUrl = generateOidcRedirectUrl(idpId);
const client = new arctic.OAuth2Client(
existingIdp.idpOidcConfig.clientId,
existingIdp.idpOidcConfig.clientSecret,
@@ -82,15 +94,16 @@ export async function generateOidcUrl(
parsedScopes
);
res.cookie("oidc_state", state, {
path: "/",
httpOnly: true,
secure: req.protocol === "https",
expires: new Date(Date.now() + 60 * 10 * 1000),
sameSite: "lax"
});
const stateJwt = jsonwebtoken.sign(
{
redirectUrl: postAuthRedirectUrl, // TODO: validate that this is safe
state,
codeVerifier
},
config.getRawConfig().server.secret
);
res.cookie(`oidc_code_verifier`, codeVerifier, {
res.cookie("p_oidc_state", stateJwt, {
path: "/",
httpOnly: true,
secure: req.protocol === "https",

View File

@@ -10,34 +10,40 @@ import {
idp,
idpOidcConfig,
idpOrg,
idpUser,
idpUserOrg,
Role,
roles
roles,
userOrgs,
users
} from "@server/db/schemas";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import jmespath from "jmespath";
import { generateId, generateSessionToken } from "@server/auth/sessions/app";
import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
import {
createIdpSession,
serializeIdpSessionCookie
} from "@server/auth/sessions/orgIdp";
createSession,
generateId,
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
const paramsSchema = z
.object({
orgId: z.string(),
idpId: z.coerce.number()
})
.strict();
const bodySchema = z.object({
code: z.string().nonempty(),
codeVerifier: z.string().nonempty()
state: z.string().nonempty(),
storedState: z.string().nonempty()
});
export type ValidateOidcUrlCallbackResponse = {};
export type ValidateOidcUrlCallbackResponse = {
redirectUrl: string;
};
export async function validateOidcCallback(
req: Request,
@@ -55,7 +61,7 @@ export async function validateOidcCallback(
);
}
const { orgId, idpId } = parsedParams.data;
const { idpId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
@@ -67,20 +73,13 @@ export async function validateOidcCallback(
);
}
const { code, codeVerifier } = parsedBody.data;
const { storedState, code, state: expectedState } = parsedBody.data;
const [existingIdp] = await db
.select()
.from(idp)
.innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId))
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(
and(
eq(idpOrg.orgId, orgId),
eq(idp.type, "oidc"),
eq(idp.idpId, idpId)
)
);
.where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
if (!existingIdp) {
return next(
@@ -91,16 +90,61 @@ export async function validateOidcCallback(
);
}
const redirectUrl = generateOidcRedirectUrl(
orgId,
existingIdp.idp.idpId
);
const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId);
const client = new arctic.OAuth2Client(
existingIdp.idpOidcConfig.clientId,
existingIdp.idpOidcConfig.clientSecret,
redirectUrl
);
const statePayload = jsonwebtoken.verify(
storedState,
config.getRawConfig().server.secret,
function (err, decoded) {
if (err) {
logger.error("Error verifying state JWT", { err });
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid state JWT"
)
);
}
return decoded;
}
);
const stateObj = z
.object({
redirectUrl: z.string(),
state: z.string(),
codeVerifier: z.string()
})
.safeParse(statePayload);
if (!stateObj.success) {
logger.error("Error parsing state JWT");
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(stateObj.error).toString()
)
);
}
const {
codeVerifier,
state,
redirectUrl: postAuthRedirectUrl
} = stateObj.data;
if (state !== expectedState) {
logger.error("State mismatch", { expectedState, state });
return next(
createHttpError(HttpCode.BAD_REQUEST, "State mismatch")
);
}
const tokens = await client.validateAuthorizationCode(
existingIdp.idpOidcConfig.tokenUrl,
code,
@@ -126,116 +170,214 @@ export async function validateOidcCallback(
logger.debug("User identifier", { userIdentifier });
const email = jmespath.search(
claims,
existingIdp.idpOidcConfig.emailPath || "email"
);
const name = jmespath.search(
claims,
existingIdp.idpOidcConfig.namePath || "name"
);
let email = null;
let name = null;
try {
if (existingIdp.idpOidcConfig.emailPath) {
email = jmespath.search(
claims,
existingIdp.idpOidcConfig.emailPath
);
}
if (existingIdp.idpOidcConfig.namePath) {
name = jmespath.search(
claims,
existingIdp.idpOidcConfig.namePath || ""
);
}
} catch (error) {}
logger.debug("User email", { email });
logger.debug("User name", { name });
const [existingIdpUser] = await db
const [existingUser] = await db
.select()
.from(idpUser)
.innerJoin(idpUserOrg, eq(idpUserOrg.idpUserId, idpUser.idpUserId))
.from(users)
.where(
and(
eq(idpUserOrg.orgId, orgId),
eq(idpUser.idpId, existingIdp.idp.idpId)
eq(users.username, userIdentifier),
eq(users.idpId, existingIdp.idp.idpId)
)
);
let userRole: Role | undefined;
if (existingIdp.idpOidcConfig.roleMapping) {
const roleName = jmespath.search(
claims,
existingIdp.idpOidcConfig.roleMapping
);
const idpOrgs = await db
.select()
.from(idpOrg)
.where(eq(idpOrg.idpId, existingIdp.idp.idpId));
if (!roleName) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Role mapping not found in the ID token"
)
);
let userOrgInfo: { orgId: string; roleId: number }[] = [];
for (const idpOrg of idpOrgs) {
let roleId: number | undefined = undefined;
if (idpOrg.orgMapping) {
const orgId = jmespath.search(claims, idpOrg.orgMapping);
if (!orgId) {
continue;
}
}
const [roleRes] = await db
.select()
.from(roles)
.where(and(eq(roles.orgId, orgId), eq(roles.name, roleName)));
if (idpOrg.roleMapping) {
const roleName = jmespath.search(claims, idpOrg.roleMapping);
userRole = roleRes;
} else {
// TODO: Get the default role for this IDP?
}
logger.debug("User role", { userRole });
if (!userRole) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Role not found for the user"
)
);
}
let userId: string | undefined = existingIdpUser?.idpUser.idpUserId;
if (!existingIdpUser) {
if (existingIdp.idpOidcConfig.autoProvision) {
// TODO: Create the user and automatically assign roles
await db.transaction(async (trx) => {
const idpUserId = generateId(15);
const [idpUserRes] = await trx
.insert(idpUser)
.values({
idpUserId,
idpId: existingIdp.idp.idpId,
identifier: userIdentifier,
email,
name
})
.returning();
await trx.insert(idpUserOrg).values({
idpUserId: idpUserRes.idpUserId,
orgId,
roleId: userRole.roleId
if (!roleName) {
logger.error("Role name not found in the ID token", {
roleName
});
continue;
}
userId = idpUserRes.idpUserId;
const [roleRes] = await db
.select()
.from(roles)
.where(
and(
eq(roles.orgId, idpOrg.orgId),
eq(roles.name, roleName)
)
);
if (!roleRes) {
logger.error("Role not found", {
orgId: idpOrg.orgId,
roleName
});
continue;
}
roleId = roleRes.roleId;
userOrgInfo.push({
orgId: idpOrg.orgId,
roleId
});
}
}
logger.debug("User org info", { userOrgInfo });
let existingUserId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists
if (!existingUser) {
userId = generateId(15);
await trx.insert(users).values({
userId,
username: userIdentifier,
email: email || null,
name: name || null,
type: UserType.OIDC,
idpId: existingIdp.idp.idpId,
emailVerified: true, // OIDC users are always verified
dateCreated: new Date().toISOString()
});
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User not found and auto-provisioning is disabled"
// set the name and email
await trx
.update(users)
.set({
username: userIdentifier,
email: email || null,
name: name || null
})
.where(eq(users.userId, userId));
}
existingUserId = userId;
// get all current user orgs
const currentUserOrgs = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.userId, userId));
// Delete orgs that are no longer valid
const orgsToDelete = currentUserOrgs.filter(
(currentOrg) =>
!userOrgInfo.some(
(newOrg) => newOrg.orgId === currentOrg.orgId
)
);
if (orgsToDelete.length > 0) {
await trx.delete(userOrgs).where(
and(
eq(userOrgs.userId, userId),
inArray(
userOrgs.orgId,
orgsToDelete.map((org) => org.orgId)
)
)
);
}
}
// Update roles for existing orgs where the role has changed
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
const newOrg = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
return newOrg && newOrg.roleId !== currentOrg.roleId;
});
if (orgsToUpdate.length > 0) {
for (const org of orgsToUpdate) {
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === org.orgId
);
if (newRole) {
await trx
.update(userOrgs)
.set({ roleId: newRole.roleId })
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, org.orgId)
)
);
}
}
}
// Add new orgs that don't exist yet
const orgsToAdd = userOrgInfo.filter(
(newOrg) =>
!currentUserOrgs.some(
(currentOrg) => currentOrg.orgId === newOrg.orgId
)
);
if (orgsToAdd.length > 0) {
await trx.insert(userOrgs).values(
orgsToAdd.map((org) => ({
userId,
orgId: org.orgId,
roleId: org.roleId,
dateCreated: new Date().toISOString()
}))
);
}
});
const token = generateSessionToken();
const sess = await createIdpSession(token, userId);
const cookie = serializeIdpSessionCookie(
`p_idp_${orgId}.${idpId}`,
sess.idpSessionId,
req.protocol === "https",
const sess = await createSession(token, existingUserId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.setHeader("Set-Cookie", cookie);
res.appendHeader("Set-Cookie", cookie);
return response<ValidateOidcUrlCallbackResponse>(res, {
data: {},
data: {
redirectUrl: postAuthRedirectUrl
},
success: true,
error: false,
message: "OIDC callback validated successfully",

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
import { idp, userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -23,10 +23,15 @@ async function queryUsers(resourceId: number) {
return await db
.select({
userId: userResources.userId,
username: users.username,
type: users.type,
idpName: idp.name,
idpId: users.idpId,
email: users.email
})
.from(userResources)
.innerJoin(users, eq(userResources.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userResources.resourceId, resourceId));
}

View File

@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, eq } from "drizzle-orm";
import logger from "@server/logger";
import { users } from "@server/db/schemas";
import { idp, users } from "@server/db/schemas";
import { fromZodError } from "zod-validation-error";
const listUsersSchema = z
@@ -31,10 +31,16 @@ async function queryUsers(limit: number, offset: number) {
.select({
id: users.userId,
email: users.email,
username: users.username,
name: users.name,
dateCreated: users.dateCreated,
serverAdmin: users.serverAdmin
serverAdmin: users.serverAdmin,
type: users.type,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(users.serverAdmin, false))
.limit(limit)
.offset(offset);

View File

@@ -17,6 +17,9 @@ async function queryUser(orgId: string, userId: string) {
orgId: userOrgs.orgId,
userId: users.userId,
email: users.email,
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { users } from "@server/db/schemas";
import { idp, users } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -13,11 +13,17 @@ async function queryUser(userId: string) {
.select({
userId: users.userId,
email: users.email,
username: users.username,
name: users.name,
type: users.type,
twoFactorEnabled: users.twoFactorEnabled,
emailVerified: users.emailVerified,
serverAdmin: users.serverAdmin
serverAdmin: users.serverAdmin,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(users.userId, userId))
.limit(1);
return user;

View File

@@ -16,6 +16,7 @@ import { fromError } from "zod-validation-error";
import { sendEmail } from "@server/emails";
import SendInviteLink from "@server/emails/templates/SendInviteLink";
import { OpenAPITags, registry } from "@server/openApi";
import { UserType } from "@server/types/UserTypes";
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
@@ -115,7 +116,13 @@ export async function inviteUser(
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
.where(
and(
eq(users.email, email),
eq(userOrgs.orgId, orgId),
eq(users.type, UserType.Internal)
)
)
.limit(1);
if (existingUser.length) {
@@ -190,7 +197,7 @@ export async function inviteUser(
inviteLink,
expiresInDays: (validHours / 24).toString(),
orgName: org[0].name || orgId,
inviterName: req.user?.email
inviterName: req.user?.email || req.user?.username
}),
{
to: email,
@@ -242,7 +249,7 @@ export async function inviteUser(
inviteLink,
expiresInDays: (validHours / 24).toString(),
orgName: org[0].name || orgId,
inviterName: req.user?.email
inviterName: req.user?.email || req.user?.username
}),
{
to: email,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roles, userOrgs, users } from "@server/db/schemas";
import { idp, roles, userOrgs, users } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -9,6 +9,7 @@ import { sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const listUsersParamsSchema = z
.object({
@@ -41,14 +42,20 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
emailVerified: users.emailVerified,
dateCreated: users.dateCreated,
orgId: userOrgs.orgId,
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner
isOwner: userOrgs.isOwner,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`)
.leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`)
.where(sql`${userOrgs.orgId} = ${orgId}`)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId))
.limit(limit)
.offset(offset);
}

View File

@@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
import moment from "moment";
import { fromError } from "zod-validation-error";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export async function setupServerAdmin() {
const {
@@ -34,7 +35,7 @@ export async function setupServerAdmin() {
if (existing) {
const passwordChanged = !(await verifyPassword(
password,
existing.passwordHash
existing.passwordHash!
));
if (passwordChanged) {
@@ -65,6 +66,8 @@ export async function setupServerAdmin() {
await db.insert(users).values({
userId: userId,
email: email,
type: UserType.Internal,
username: email,
passwordHash,
dateCreated: moment().toISOString(),
serverAdmin: true,

View File

@@ -0,0 +1,4 @@
export enum UserType {
Internal = "internal",
OIDC = "oidc"
}