From 66e5edcfc0c116d3d8923addcac1dcf43402facc Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 17 Jun 2024 20:32:47 +0800 Subject: [PATCH 01/38] feat: oidc poc --- backend/package-lock.json | 39 ++++ backend/package.json | 1 + backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 2 + .../20240617041053_add-oidc-auth.ts | 30 +++ backend/src/db/schemas/models.ts | 1 + backend/src/db/schemas/oidc-configs.ts | 31 +++ backend/src/server/routes/index.ts | 12 ++ backend/src/server/routes/v1/index.ts | 2 + backend/src/server/routes/v1/oidc-router.ts | 125 +++++++++++ .../src/services/auth/auth-login-service.ts | 5 +- backend/src/services/auth/auth-type.ts | 3 +- backend/src/services/oidc/oidc-config-dal.ts | 11 + .../src/services/oidc/oidc-config-service.ts | 200 ++++++++++++++++++ .../src/services/oidc/oidc-config-types.ts | 27 +++ .../services/user-alias/user-alias-types.ts | 3 +- frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/oidcConfig/index.tsx | 1 + frontend/src/hooks/api/oidcConfig/queries.tsx | 19 ++ 19 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 backend/src/db/migrations/20240617041053_add-oidc-auth.ts create mode 100644 backend/src/db/schemas/oidc-configs.ts create mode 100644 backend/src/server/routes/v1/oidc-router.ts create mode 100644 backend/src/services/oidc/oidc-config-dal.ts create mode 100644 backend/src/services/oidc/oidc-config-service.ts create mode 100644 backend/src/services/oidc/oidc-config-types.ts create mode 100644 frontend/src/hooks/api/oidcConfig/index.tsx create mode 100644 frontend/src/hooks/api/oidcConfig/queries.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index a2a4d0a6e..b0ff466c2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -55,6 +55,7 @@ "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", + "openid-client": "^5.6.5", "ora": "^7.0.1", "oracledb": "^6.4.0", "passport-github": "^1.1.0", @@ -9445,6 +9446,14 @@ "node": ">= 0.6.0" } }, + "node_modules/jose": { + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -10570,6 +10579,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -10693,6 +10710,14 @@ "@octokit/core": ">=5" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -10739,6 +10764,20 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" }, + "node_modules/openid-client": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", + "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", + "dependencies": { + "jose": "^4.15.5", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", diff --git a/backend/package.json b/backend/package.json index 66e720dcf..7aecc01bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -116,6 +116,7 @@ "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", + "openid-client": "^5.6.5", "ora": "^7.0.1", "oracledb": "^6.4.0", "passport-github": "^1.1.0", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 81fc0c541..06cca28b3 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -40,6 +40,7 @@ import { TIdentityProjectServiceFactory } from "@app/services/identity-project/i import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { TOidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; @@ -99,6 +100,7 @@ declare module "fastify" { permission: TPermissionServiceFactory; org: TOrgServiceFactory; orgRole: TOrgRoleServiceFactory; + oidc: TOidcConfigServiceFactory; superAdmin: TSuperAdminServiceFactory; user: TUserServiceFactory; group: TGroupServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index fbcaa3528..330b447b5 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -255,6 +255,7 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; +import { TOidcConfigs, TOidcConfigsInsert, TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; declare module "knex/types/tables" { interface Tables { @@ -497,6 +498,7 @@ declare module "knex/types/tables" { TDynamicSecretLeasesUpdate >; [TableName.SamlConfig]: Knex.CompositeTableType; + [TableName.OidcConfig]: Knex.CompositeTableType; [TableName.LdapConfig]: Knex.CompositeTableType; [TableName.LdapGroupMap]: Knex.CompositeTableType; [TableName.OrgBot]: Knex.CompositeTableType; diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts new file mode 100644 index 000000000..0b54064bc --- /dev/null +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.OidcConfig))) { + await knex.schema.createTable(TableName.OidcConfig, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("issuer"); + tb.string("authorizationEndpoint"); + tb.string("jwksUri"); + tb.string("tokenEndpoint"); + tb.string("userinfoEndpoint"); + tb.text("encryptedClientId"); + tb.string("clientIdIV"); + tb.string("clientIdTag"); + tb.text("encryptedClientSecret"); + tb.string("clientSecretIV"); + tb.string("clientSecretTag"); + tb.boolean("isActive").notNullable(); + tb.timestamps(true, true, true); + tb.uuid("orgId").notNullable().unique(); + tb.foreign("orgId").references("id").inTable(TableName.Organization); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.OidcConfig); +} diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 5d2213003..df15456fb 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -71,6 +71,7 @@ export enum TableName { SecretRotationOutput = "secret_rotation_outputs", SamlConfig = "saml_configs", LdapConfig = "ldap_configs", + OidcConfig = "oidc_configs", LdapGroupMap = "ldap_group_maps", AuditLog = "audit_logs", AuditLogStream = "audit_log_streams", diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts new file mode 100644 index 000000000..05dae70a3 --- /dev/null +++ b/backend/src/db/schemas/oidc-configs.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const OidcConfigsSchema = z.object({ + id: z.string().uuid(), + issuer: z.string().nullable().optional(), + authorizationEndpoint: z.string().nullable().optional(), + jwksUri: z.string().nullable().optional(), + tokenEndpoint: z.string().nullable().optional(), + userinfoEndpoint: z.string().nullable().optional(), + encryptedClientId: z.string().nullable().optional(), + clientIdIV: z.string().nullable().optional(), + clientIdTag: z.string().nullable().optional(), + encryptedClientSecret: z.string().nullable().optional(), + clientSecretIV: z.string().nullable().optional(), + clientSecretTag: z.string().nullable().optional(), + isActive: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid() +}); + +export type TOidcConfigs = z.infer; +export type TOidcConfigsInsert = Omit, TImmutableDBKeys>; +export type TOidcConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2f2de829f..7d3155bd6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -101,6 +101,7 @@ import { integrationAuthServiceFactory } from "@app/services/integration-auth/in import { kmsDALFactory } from "@app/services/kms/kms-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { oidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -838,6 +839,16 @@ export const registerRoutes = async ( secretSharingDAL }); + const oidcService = oidcConfigServiceFactory({ + orgDAL, + orgMembershipDAL, + userDAL, + userAliasDAL, + licenseService, + tokenService, + smtpService + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -858,6 +869,7 @@ export const registerRoutes = async ( permission: permissionService, org: orgService, orgRole: orgRoleService, + oidc: oidcService, apiKey: apiKeyService, authToken: tokenService, superAdmin: superAdminService, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index fd8255e63..4b8480cd5 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,6 +11,7 @@ import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; +import { registerOidcRouter } from "./oidc-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerProjectEnvRouter } from "./project-env-router"; @@ -29,6 +30,7 @@ import { registerWebhookRouter } from "./webhook-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); + await server.register(registerOidcRouter, { prefix: "/oidc" }); await server.register( async (authRouter) => { await authRouter.register(registerAuthRoutes); diff --git a/backend/src/server/routes/v1/oidc-router.ts b/backend/src/server/routes/v1/oidc-router.ts new file mode 100644 index 000000000..bccead8e9 --- /dev/null +++ b/backend/src/server/routes/v1/oidc-router.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +// All the any rules are disabled because passport typesense with fastify is really poor + +import { Authenticator, Strategy } from "@fastify/passport"; +import fastifySession from "@fastify/session"; +import { z } from "zod"; + +import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; +import { getConfig } from "@app/lib/config/env"; + +export const registerOidcRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" }); + await server.register(fastifySession, { + secret: appCfg.COOKIE_SECRET_SIGN_KEY, + cookie: { + secure: false // has to be set to false if testing locally + } + }); + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + // redirect to IDP for login + server.route({ + url: "/login", + method: "GET", + schema: { + params: z.object({ + orgSlug: z.string().trim() + }) + }, + handler: async (req, res) => { + // get params, save to session + const { orgSlug } = req.params; + req.session.set("oidcOrgSlug", orgSlug); + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug); + ( + passport.authenticate(oidcStrategy as Strategy, { + scope: "profile email openid" + }) as any + )(req, res); + } + }); + + // callback route after login from IDP + server.route({ + url: "/callback", + method: "GET", + handler: async (req, res) => { + const oidcOrgSlug = req.session.get("oidcOrgSlug"); + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug); + await ( + passport.authenticate(oidcStrategy as Strategy, { + failureRedirect: "/api/v1/oidc/login/error", + session: false, + failureMessage: true + }) as any + )(req, res); + + if (req.passportUser.isUserCompleted) { + return res.redirect( + `http://localhost:8080/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + ); + } + + // signup + return res.redirect( + `http://localhost:8080/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + ); + } + }); + + server.route({ + url: "/login/error", + method: "GET", + handler: (req, res) => { + return res.status(500).send({ + error: "Authentication error", + details: req.query + }); + } + }); + + server.route({ + url: "/config", + method: "GET", + schema: { + querystring: z.object({ + orgSlug: z.string().trim() + }), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true + }).extend({ + clientId: z.string(), + clientSecret: z.string() + }) + } + }, + handler: async (req) => { + const { orgSlug } = req.query; + const oidc = await server.services.oidc.getOidc({ + orgSlug, + type: "external", + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return oidc; + } + }); +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index a136508e7..d6d99f682 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -196,7 +196,10 @@ export const authLoginServiceFactory = ({ const decodedProviderToken = validateProviderAuthToken(providerAuthToken, email); authMethod = decodedProviderToken.authMethod; - if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && decodedProviderToken.orgId) { + if ( + (isAuthMethodSaml(authMethod) || [AuthMethod.LDAP, AuthMethod.OIDC].includes(authMethod)) && + decodedProviderToken.orgId + ) { organizationId = decodedProviderToken.orgId; } } diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 8e7b92253..9210093ab 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -8,7 +8,8 @@ export enum AuthMethod { JUMPCLOUD_SAML = "jumpcloud-saml", GOOGLE_SAML = "google-saml", KEYCLOAK_SAML = "keycloak-saml", - LDAP = "ldap" + LDAP = "ldap", + OIDC = "oidc" } export enum AuthTokenType { diff --git a/backend/src/services/oidc/oidc-config-dal.ts b/backend/src/services/oidc/oidc-config-dal.ts new file mode 100644 index 000000000..470916c61 --- /dev/null +++ b/backend/src/services/oidc/oidc-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOidcConfigDALFactory = ReturnType; + +export const oidcConfigDALFactory = (db: TDbClient) => { + const oidcCfgOrm = ormify(db, TableName.OidcConfig); + + return { ...oidcCfgOrm }; +}; diff --git a/backend/src/services/oidc/oidc-config-service.ts b/backend/src/services/oidc/oidc-config-service.ts new file mode 100644 index 000000000..9634a24cc --- /dev/null +++ b/backend/src/services/oidc/oidc-config-service.ts @@ -0,0 +1,200 @@ +import jwt from "jsonwebtoken"; + +import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; + +import { AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; +import { TokenType } from "../auth-token/auth-token-types"; +import { TOrgDALFactory } from "../org/org-dal"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TUserDALFactory } from "../user/user-dal"; +import { normalizeUsername } from "../user/user-fns"; +import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; +import { UserAliasType } from "../user-alias/user-alias-types"; +import { TOidcLoginDTO } from "./oidc-config-types"; + +type TOidcConfigServiceFactoryDep = { + userDAL: Pick; + userAliasDAL: Pick; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" + >; + orgMembershipDAL: Pick; + licenseService: Pick; + tokenService: Pick; + smtpService: Pick; +}; + +export type TOidcConfigServiceFactory = ReturnType; + +export const oidcConfigServiceFactory = ({ + orgDAL, + orgMembershipDAL, + userDAL, + userAliasDAL, + licenseService, + tokenService, + smtpService +}: TOidcConfigServiceFactoryDep) => { + const oidcLogin = async ({ externalId, email, firstName, lastName, orgId }: TOidcLoginDTO) => { + const appCfg = getConfig(); + const userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.OIDC + }); + + const organization = await orgDAL.findOrgById(orgId); + if (!organization) throw new BadRequestError({ message: "Org not found" }); + + let user: TUsers; + if (userAlias) { + user = await userDAL.transaction(async (tx) => { + const foundUser = await userDAL.findById(userAlias.userId, tx); + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: foundUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: userAlias.userId, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && foundUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + + return foundUser; + }); + } else { + user = await userDAL.transaction(async (tx) => { + let newUser: TUsers | undefined; + if (!newUser) { + const uniqueUsername = await normalizeUsername(externalId, userDAL); + newUser = await userDAL.create( + { + email, + firstName, + isEmailVerified: false, + username: uniqueUsername, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + await userAliasDAL.create( + { + userId: newUser.id, + aliasType: UserAliasType.OIDC, + externalId, + emails: email ? [email] : [], + orgId + }, + tx + ); + + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: newUser.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + + return newUser; + }); + } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); + + const isUserCompleted = Boolean(user.isAccepted); + const providerAuthToken = jwt.sign( + { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user.id, + username: user.username, + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + firstName, + lastName, + organizationName: organization.name, + organizationId: organization.id, + organizationSlug: organization.slug, + authMethod: AuthMethod.OIDC, + authType: UserAliasType.OIDC, + isUserCompleted + }, + appCfg.AUTH_SECRET, + { + expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME + } + ); + + // TODO: Sheen update oidc config + // await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); + + if (user.email && !user.isEmailVerified) { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + } + + return { isUserCompleted, providerAuthToken }; + }; + + return { oidcLogin }; +}; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/services/oidc/oidc-config-types.ts new file mode 100644 index 000000000..d7145f453 --- /dev/null +++ b/backend/src/services/oidc/oidc-config-types.ts @@ -0,0 +1,27 @@ +import { TGenericPermission } from "@app/lib/types"; + +export type TOidcLoginDTO = { + externalId: string; + email: string; + firstName: string; + lastName?: string; + orgId: string; +}; + +export type TGetOidcCfgDTO = { + type: "internal" | "external"; + orgSlug: string; +} & TGenericPermission; + +export type TUpdateOidcCfgDTO = Partial<{ + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; +}> & + TGenericPermission; diff --git a/backend/src/services/user-alias/user-alias-types.ts b/backend/src/services/user-alias/user-alias-types.ts index 09204644f..7207e8acf 100644 --- a/backend/src/services/user-alias/user-alias-types.ts +++ b/backend/src/services/user-alias/user-alias-types.ts @@ -1,4 +1,5 @@ export enum UserAliasType { LDAP = "ldap", - SAML = "saml" + SAML = "saml", + OIDC = "oidc" } diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 19fd5f594..b76aac166 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -15,6 +15,7 @@ export * from "./integrationAuth"; export * from "./integrations"; export * from "./keys"; export * from "./ldapConfig"; +export * from "./oidcConfig"; export * from "./organization"; export * from "./projectUserAdditionalPrivilege"; export * from "./rateLimit"; diff --git a/frontend/src/hooks/api/oidcConfig/index.tsx b/frontend/src/hooks/api/oidcConfig/index.tsx new file mode 100644 index 000000000..b69c25120 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/index.tsx @@ -0,0 +1 @@ +export * from "./queries"; diff --git a/frontend/src/hooks/api/oidcConfig/queries.tsx b/frontend/src/hooks/api/oidcConfig/queries.tsx new file mode 100644 index 000000000..4a9773fd5 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/queries.tsx @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +export const oidcConfigKeys = { + getOIDCConfig: (orgSlug: string) => [{ orgSlug }, "organization-oidc"] as const +}; + +export const useGetOIDCConfig = (orgSlug: string) => { + return useQuery({ + queryKey: oidcConfigKeys.getOIDCConfig(orgSlug), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/oidc/config?orgSlug=${orgSlug}`); + + return data; + }, + enabled: true + }); +}; From 61fcb2b605b80068069fc24ad1775771d7ca4114 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 17 Jun 2024 22:37:23 +0800 Subject: [PATCH 02/38] feat: finished oidc form functions --- backend/src/@types/knex.d.ts | 4 +- .../20240617041053_add-oidc-auth.ts | 22 +- backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/oidc-configs.ts | 22 +- backend/src/server/routes/index.ts | 7 +- backend/src/server/routes/v1/oidc-router.ts | 95 +++++++ .../src/services/oidc/oidc-config-types.ts | 12 + .../src/hooks/api/oidcConfig/mutations.tsx | 93 +++++++ .../components/OrgAuthTab/OIDCModal.tsx | 245 ++++++++++++++++++ .../components/OrgAuthTab/OrgAuthTab.tsx | 2 + .../components/OrgAuthTab/OrgOIDCSection.tsx | 101 ++++++++ 11 files changed, 580 insertions(+), 24 deletions(-) create mode 100644 frontend/src/hooks/api/oidcConfig/mutations.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 330b447b5..bdd1de711 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -113,6 +113,9 @@ import { TLdapGroupMaps, TLdapGroupMapsInsert, TLdapGroupMapsUpdate, + TOidcConfigs, + TOidcConfigsInsert, + TOidcConfigsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, @@ -255,7 +258,6 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; -import { TOidcConfigs, TOidcConfigsInsert, TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; declare module "knex/types/tables" { interface Tables { diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts index 0b54064bc..b11e6bbab 100644 --- a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -6,17 +6,17 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.OidcConfig))) { await knex.schema.createTable(TableName.OidcConfig, (tb) => { tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - tb.string("issuer"); - tb.string("authorizationEndpoint"); - tb.string("jwksUri"); - tb.string("tokenEndpoint"); - tb.string("userinfoEndpoint"); - tb.text("encryptedClientId"); - tb.string("clientIdIV"); - tb.string("clientIdTag"); - tb.text("encryptedClientSecret"); - tb.string("clientSecretIV"); - tb.string("clientSecretTag"); + tb.string("issuer").notNullable(); + tb.string("authorizationEndpoint").notNullable(); + tb.string("jwksUri").notNullable(); + tb.string("tokenEndpoint").notNullable(); + tb.string("userinfoEndpoint").notNullable(); + tb.text("encryptedClientId").notNullable(); + tb.string("clientIdIV").notNullable(); + tb.string("clientIdTag").notNullable(); + tb.text("encryptedClientSecret").notNullable(); + tb.string("clientSecretIV").notNullable(); + tb.string("clientSecretTag").notNullable(); tb.boolean("isActive").notNullable(); tb.timestamps(true, true, true); tb.uuid("orgId").notNullable().unique(); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 5771cb669..cc7cea33c 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -36,6 +36,7 @@ export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; export * from "./models"; +export * from "./oidc-configs"; export * from "./org-bots"; export * from "./org-memberships"; export * from "./org-roles"; diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 05dae70a3..b06114df6 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -9,17 +9,17 @@ import { TImmutableDBKeys } from "./models"; export const OidcConfigsSchema = z.object({ id: z.string().uuid(), - issuer: z.string().nullable().optional(), - authorizationEndpoint: z.string().nullable().optional(), - jwksUri: z.string().nullable().optional(), - tokenEndpoint: z.string().nullable().optional(), - userinfoEndpoint: z.string().nullable().optional(), - encryptedClientId: z.string().nullable().optional(), - clientIdIV: z.string().nullable().optional(), - clientIdTag: z.string().nullable().optional(), - encryptedClientSecret: z.string().nullable().optional(), - clientSecretIV: z.string().nullable().optional(), - clientSecretTag: z.string().nullable().optional(), + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + encryptedClientId: z.string(), + clientIdIV: z.string(), + clientIdTag: z.string(), + encryptedClientSecret: z.string(), + clientSecretIV: z.string(), + clientSecretTag: z.string(), isActive: z.boolean(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7d3155bd6..0bf4d0ae5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -101,6 +101,7 @@ import { integrationAuthServiceFactory } from "@app/services/integration-auth/in import { kmsDALFactory } from "@app/services/kms/kms-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { oidcConfigDALFactory } from "@app/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; @@ -241,6 +242,7 @@ export const registerRoutes = async ( const ldapConfigDAL = ldapConfigDALFactory(db); const ldapGroupMapDAL = ldapGroupMapDALFactory(db); + const oidcConfigDAL = oidcConfigDALFactory(db); const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); @@ -846,7 +848,10 @@ export const registerRoutes = async ( userAliasDAL, licenseService, tokenService, - smtpService + smtpService, + orgBotDAL, + permissionService, + oidcConfigDAL }); await superAdminService.initServerCfg(); diff --git a/backend/src/server/routes/v1/oidc-router.ts b/backend/src/server/routes/v1/oidc-router.ts index bccead8e9..05c2bd90e 100644 --- a/backend/src/server/routes/v1/oidc-router.ts +++ b/backend/src/server/routes/v1/oidc-router.ts @@ -12,6 +12,9 @@ import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; import { getConfig } from "@app/lib/config/env"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -101,6 +104,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { jwksUri: true, tokenEndpoint: true, userinfoEndpoint: true, + isActive: true, orgId: true }).extend({ clientId: z.string(), @@ -122,4 +126,95 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { return oidc; } }); + + server.route({ + method: "PATCH", + url: "/config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z + .object({ + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + clientId: z.string(), + clientSecret: z.string(), + isActive: z.boolean() + }) + .partial() + .merge(z.object({ orgSlug: z.string() })), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true, + isActive: true + }) + } + }, + handler: async (req) => { + const oidc = await server.services.oidc.updateOidcCfg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return oidc; + } + }); + + server.route({ + method: "POST", + url: "/config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + clientId: z.string(), + clientSecret: z.string(), + isActive: z.boolean(), + orgSlug: z.string() + }), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true, + isActive: true + }) + } + }, + + handler: async (req) => { + const oidc = await server.services.oidc.createOidcCfg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return oidc; + } + }); }; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/services/oidc/oidc-config-types.ts index d7145f453..24d3d0e4c 100644 --- a/backend/src/services/oidc/oidc-config-types.ts +++ b/backend/src/services/oidc/oidc-config-types.ts @@ -13,6 +13,18 @@ export type TGetOidcCfgDTO = { orgSlug: string; } & TGenericPermission; +export type TCreateOidcCfgDTO = { + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; +} & TGenericPermission; + export type TUpdateOidcCfgDTO = Partial<{ issuer: string; authorizationEndpoint: string; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx new file mode 100644 index 000000000..44d976170 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -0,0 +1,93 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { oidcConfigKeys } from "./queries"; + +export const useUpdateOIDCConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }: { + issuer?: string; + authorizationEndpoint?: string; + jwksUri?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + clientId?: string; + clientSecret?: string; + isActive?: boolean; + orgSlug: string; + }) => { + const { data } = await apiRequest.patch("/api/v1/oidc/config", { + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + orgSlug, + clientSecret, + isActive + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(oidcConfigKeys.getOIDCConfig(dto.orgSlug)); + } + }); +}; + +export const useCreateOIDCConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }: { + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; + }) => { + const { data } = await apiRequest.post("/api/v1/oidc/config", { + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(oidcConfigKeys.getOIDCConfig(dto.orgSlug)); + } + }); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx new file mode 100644 index 000000000..55b7924a0 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -0,0 +1,245 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useGetOIDCConfig } from "@app/hooks/api"; +import { useCreateOIDCConfig, useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["addOIDC"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["addOIDC"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addOIDC"]>, state?: boolean) => void; +}; + +const schema = z.object({ + issuer: z.string().min(1), + authorizationEndpoint: z.string().min(1), + jwksUri: z.string().min(1), + tokenEndpoint: z.string().min(1), + userinfoEndpoint: z.string().min(1), + clientId: z.string().min(1), + clientSecret: z.string().min(1) +}); + +export type OIDCFormData = z.infer; + +export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateOIDCConfig(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateOIDCConfig(); + const { data } = useGetOIDCConfig(currentOrg?.slug ?? ""); + + const { control, handleSubmit, reset, setValue } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (data) { + setValue("issuer", data.issuer); + setValue("authorizationEndpoint", data.authorizationEndpoint); + setValue("jwksUri", data.jwksUri); + setValue("tokenEndpoint", data.tokenEndpoint); + setValue("userinfoEndpoint", data.userinfoEndpoint); + setValue("clientId", data.clientId); + setValue("clientSecret", data.clientSecret); + } + }, [data]); + + const onOIDCModalSubmit = async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret + }: OIDCFormData) => { + try { + if (!currentOrg) return; + + if (!data) { + await createMutateAsync({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive: true, + orgSlug: currentOrg.slug + }); + } else { + await updateMutateAsync({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive: true, + orgSlug: currentOrg.slug + }); + } + + handlePopUpClose("addOIDC"); + + createNotification({ + text: `Successfully ${!data ? "added" : "updated"} OIDC SSO configuration`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${!data ? "add" : "update"} OIDC SSO configuration`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("addOIDC", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index 322798d16..31d6987bd 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -3,6 +3,7 @@ import { withPermission } from "@app/hoc"; import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection"; import { OrgLDAPSection } from "./OrgLDAPSection"; +import { OrgOIDCSection } from "./OrgOIDCSection"; import { OrgScimSection } from "./OrgSCIMSection"; import { OrgSSOSection } from "./OrgSSOSection"; @@ -12,6 +13,7 @@ export const OrgAuthTab = withPermission(
+
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx new file mode 100644 index 000000000..41d7b7416 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx @@ -0,0 +1,101 @@ +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, Switch } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useGetOIDCConfig } from "@app/hooks/api"; +import { useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { OIDCModal } from "./OIDCModal"; + +export const OrgOIDCSection = (): JSX.Element => { + const { currentOrg } = useOrganization(); + + const { data, isLoading } = useGetOIDCConfig(currentOrg?.slug ?? ""); + const { mutateAsync } = useUpdateOIDCConfig(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addOIDC" + ] as const); + + const handleOIDCToggle = async (value: boolean) => { + try { + if (!currentOrg?.id) return; + + await mutateAsync({ + orgSlug: currentOrg?.slug, + isActive: value + }); + + createNotification({ + text: `Successfully ${value ? "enabled" : "disabled"} OIDC SSO`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${value ? "enable" : "disable"} OIDC SSO`, + type: "error" + }); + } + }; + + const addOidcButtonClick = async () => { + try { + handlePopUpOpen("addOIDC"); + } catch (err) { + console.error(err); + } + }; + + return ( + <> +
+
+
+

OIDC

+ {!isLoading && ( + + {(isAllowed) => ( + + )} + + )} +
+

Manage OIDC authentication configuration

+
+ {data && ( +
+
+

Enable OIDC

+ {!isLoading && ( + + {(isAllowed) => ( + handleOIDCToggle(value)} + isChecked={data ? data.isActive : false} + isDisabled={!isAllowed} + /> + )} + + )} +
+

+ Allow members to authenticate into Infisical with OIDC +

+
+ )} + + + ); +}; From 56cc248425317de570e845006247411a17550144 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 17 Jun 2024 23:28:27 +0800 Subject: [PATCH 03/38] feature: finalized oidc core service methods --- .../src/services/oidc/oidc-config-service.ts | 356 +++++++++++++++++- .../src/services/oidc/oidc-config-types.ts | 13 +- frontend/src/hooks/api/oidcConfig/queries.tsx | 6 +- frontend/src/hooks/api/oidcConfig/types.ts | 12 + 4 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 frontend/src/hooks/api/oidcConfig/types.ts diff --git a/backend/src/services/oidc/oidc-config-service.ts b/backend/src/services/oidc/oidc-config-service.ts index 9634a24cc..d80b506d2 100644 --- a/backend/src/services/oidc/oidc-config-service.ts +++ b/backend/src/services/oidc/oidc-config-service.ts @@ -1,13 +1,28 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; +import { Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; -import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; +import { TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; +import { + decryptSymmetric, + encryptSymmetric, + generateAsymmetricKeyPair, + generateSymmetricKey, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TOrgBotDALFactory } from "../org/org-bot-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; @@ -15,7 +30,8 @@ import { TUserDALFactory } from "../user/user-dal"; import { normalizeUsername } from "../user/user-fns"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; -import { TOidcLoginDTO } from "./oidc-config-types"; +import { TOidcConfigDALFactory } from "./oidc-config-dal"; +import { TCreateOidcCfgDTO, TGetOidcCfgDTO, TOidcLoginDTO, TUpdateOidcCfgDTO } from "./oidc-config-types"; type TOidcConfigServiceFactoryDep = { userDAL: Pick; @@ -25,9 +41,12 @@ type TOidcConfigServiceFactoryDep = { "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; orgMembershipDAL: Pick; + orgBotDAL: Pick; licenseService: Pick; tokenService: Pick; smtpService: Pick; + permissionService: Pick; + oidcConfigDAL: Pick; }; export type TOidcConfigServiceFactory = ReturnType; @@ -38,8 +57,11 @@ export const oidcConfigServiceFactory = ({ userDAL, userAliasDAL, licenseService, + permissionService, tokenService, - smtpService + orgBotDAL, + smtpService, + oidcConfigDAL }: TOidcConfigServiceFactoryDep) => { const oidcLogin = async ({ externalId, email, firstName, lastName, orgId }: TOidcLoginDTO) => { const appCfg = getConfig(); @@ -196,5 +218,331 @@ export const oidcConfigServiceFactory = ({ return { isUserCompleted, providerAuthToken }; }; - return { oidcLogin }; + const getOidc = async (dto: TGetOidcCfgDTO) => { + const org = await orgDAL.findOne({ slug: dto.orgSlug }); + if (!org) { + throw new BadRequestError({ + message: "Organization not found", + name: "OrgNotFound" + }); + } + if (dto.type === "external") { + const { permission } = await permissionService.getOrgPermission( + dto.actor, + dto.actorId, + org.id, + dto.actorAuthMethod, + dto.actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + } + + const oidcCfg = await oidcConfigDAL.findOne({ + orgId: org.id + }); + + if (!oidcCfg) { + throw new BadRequestError({ + message: "Failed to find organization OIDC configuration" + }); + } + + // decrypt and return cfg + const orgBot = await orgBotDAL.findOne({ orgId: oidcCfg.orgId }); + if (!orgBot) { + throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + } + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedClientId, clientIdIV, clientIdTag, encryptedClientSecret, clientSecretIV, clientSecretTag } = + oidcCfg; + + let clientId = ""; + if (encryptedClientId && clientIdIV && clientIdTag) { + clientId = decryptSymmetric({ + ciphertext: encryptedClientId, + key, + tag: clientIdTag, + iv: clientIdIV + }); + } + + let clientSecret = ""; + if (encryptedClientSecret && clientSecretIV && clientSecretTag) { + clientSecret = decryptSymmetric({ + key, + tag: clientSecretTag, + iv: clientSecretIV, + ciphertext: encryptedClientSecret + }); + } + + return { + id: oidcCfg.id, + issuer: oidcCfg.issuer, + authorizationEndpoint: oidcCfg.authorizationEndpoint, + jwksUri: oidcCfg.jwksUri, + tokenEndpoint: oidcCfg.tokenEndpoint, + userinfoEndpoint: oidcCfg.userinfoEndpoint, + orgId: oidcCfg.orgId, + isActive: oidcCfg.isActive, + clientId, + clientSecret + }; + }; + + const updateOidcCfg = async ({ + orgSlug, + actor, + actorOrgId, + actorAuthMethod, + actorId, + issuer, + isActive, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret + }: TUpdateOidcCfgDTO) => { + const org = await orgDAL.findOne({ + slug: orgSlug + }); + if (!org) { + throw new BadRequestError({ + message: "Organization not found" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + org.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + + const orgBot = await orgBotDAL.findOne({ orgId: org.id }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const updateQuery: TOidcConfigsUpdate = { + issuer, + authorizationEndpoint, + tokenEndpoint, + userinfoEndpoint, + jwksUri, + isActive + }; + + if (clientId !== undefined) { + const { ciphertext: encryptedClientId, iv: clientIdIV, tag: clientIdTag } = encryptSymmetric(clientId, key); + updateQuery.encryptedClientId = encryptedClientId; + updateQuery.clientIdIV = clientIdIV; + updateQuery.clientIdTag = clientIdTag; + } + + if (clientSecret !== undefined) { + const { + ciphertext: encryptedClientSecret, + iv: clientSecretIV, + tag: clientSecretTag + } = encryptSymmetric(clientSecret, key); + + updateQuery.encryptedClientSecret = encryptedClientSecret; + updateQuery.clientSecretIV = clientSecretIV; + updateQuery.clientSecretTag = clientSecretTag; + } + + const [ssoConfig] = await oidcConfigDAL.update({ orgId: org.id }, updateQuery); + + return ssoConfig; + }; + + const createOidcCfg = async ({ + orgSlug, + actor, + actorOrgId, + actorAuthMethod, + actorId, + issuer, + isActive, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret + }: TCreateOidcCfgDTO) => { + const org = await orgDAL.findOne({ + slug: orgSlug + }); + if (!org) { + throw new BadRequestError({ + message: "Organization not found" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + org.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); + + const orgBot = await orgBotDAL.transaction(async (tx) => { + const doc = await orgBotDAL.findOne({ orgId: org.id }, tx); + if (doc) return doc; + + const { privateKey, publicKey } = generateAsymmetricKeyPair(); + const key = generateSymmetricKey(); + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag, + encoding: privateKeyKeyEncoding, + algorithm: privateKeyAlgorithm + } = infisicalSymmetricEncypt(privateKey); + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag, + encoding: symmetricKeyKeyEncoding, + algorithm: symmetricKeyAlgorithm + } = infisicalSymmetricEncypt(key); + + return orgBotDAL.create( + { + name: "Infisical org bot", + publicKey, + privateKeyIV, + encryptedPrivateKey, + symmetricKeyIV, + symmetricKeyTag, + encryptedSymmetricKey, + symmetricKeyAlgorithm, + orgId: org.id, + privateKeyTag, + privateKeyAlgorithm, + privateKeyKeyEncoding, + symmetricKeyKeyEncoding + }, + tx + ); + }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { ciphertext: encryptedClientId, iv: clientIdIV, tag: clientIdTag } = encryptSymmetric(clientId, key); + const { + ciphertext: encryptedClientSecret, + iv: clientSecretIV, + tag: clientSecretTag + } = encryptSymmetric(clientSecret, key); + + const oidcCfg = await oidcConfigDAL.create({ + issuer, + isActive, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + orgId: org.id, + encryptedClientId, + clientIdIV, + clientIdTag, + encryptedClientSecret, + clientSecretIV, + clientSecretTag + }); + + return oidcCfg; + }; + + const getOrgAuthStrategy = async (orgSlug: string) => { + const appCfg = getConfig(); + + const org = await orgDAL.findOne({ + slug: orgSlug + }); + + if (!org) { + throw new BadRequestError({ + message: "Organization not found." + }); + } + + const oidcCfg = await getOidc({ + type: "internal", + orgSlug + }); + + const openIdIssuer = new OpenIdIssuer({ + issuer: oidcCfg.issuer, + authorization_endpoint: oidcCfg.authorizationEndpoint, + jwks_uri: oidcCfg.jwksUri, + token_endpoint: oidcCfg.tokenEndpoint, + userinfo_endpoint: oidcCfg.userinfoEndpoint + }); + + const client = new openIdIssuer.Client({ + client_id: oidcCfg.clientId, + client_secret: oidcCfg.clientSecret, + redirect_uris: [`${appCfg.SITE_URL}/api/v1/oidc/callback`] + }); + + const strategy = new OpenIdStrategy( + { + client, + passReqToCallback: true + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_req: any, tokenSet: TokenSet, cb: any) => { + const claims = tokenSet.claims(); + if (!claims.email || !claims.given_name) { + throw new BadRequestError({ + message: "Invalid request. Missing email or first name" + }); + } + + oidcLogin({ + email: claims.email, + externalId: claims.sub, + firstName: claims.given_name ?? "", + lastName: claims.family_name ?? "", + orgId: org.id + }) + .then(({ isUserCompleted, providerAuthToken }) => { + cb(null, { isUserCompleted, providerAuthToken }); + }) + .catch((error) => { + cb(error); + }); + } + ); + + return strategy; + }; + + return { oidcLogin, getOrgAuthStrategy, getOidc, updateOidcCfg, createOidcCfg }; }; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/services/oidc/oidc-config-types.ts index 24d3d0e4c..a160b272a 100644 --- a/backend/src/services/oidc/oidc-config-types.ts +++ b/backend/src/services/oidc/oidc-config-types.ts @@ -8,10 +8,15 @@ export type TOidcLoginDTO = { orgId: string; }; -export type TGetOidcCfgDTO = { - type: "internal" | "external"; - orgSlug: string; -} & TGenericPermission; +export type TGetOidcCfgDTO = + | ({ + type: "external"; + orgSlug: string; + } & TGenericPermission) + | { + type: "internal"; + orgSlug: string; + }; export type TCreateOidcCfgDTO = { issuer: string; diff --git a/frontend/src/hooks/api/oidcConfig/queries.tsx b/frontend/src/hooks/api/oidcConfig/queries.tsx index 4a9773fd5..590c39848 100644 --- a/frontend/src/hooks/api/oidcConfig/queries.tsx +++ b/frontend/src/hooks/api/oidcConfig/queries.tsx @@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { OIDCConfigData } from "./types"; + export const oidcConfigKeys = { getOIDCConfig: (orgSlug: string) => [{ orgSlug }, "organization-oidc"] as const }; @@ -10,7 +12,9 @@ export const useGetOIDCConfig = (orgSlug: string) => { return useQuery({ queryKey: oidcConfigKeys.getOIDCConfig(orgSlug), queryFn: async () => { - const { data } = await apiRequest.get(`/api/v1/oidc/config?orgSlug=${orgSlug}`); + const { data } = await apiRequest.get( + `/api/v1/oidc/config?orgSlug=${orgSlug}` + ); return data; }, diff --git a/frontend/src/hooks/api/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts new file mode 100644 index 000000000..579590e09 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -0,0 +1,12 @@ +export type OIDCConfigData = { + id: string; + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + isActive: boolean; + orgId: string; + clientId: string; + clientSecret: string; +}; From 2c237ee2775796c634397336b836d4eca194c591 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 00:53:56 +0800 Subject: [PATCH 04/38] feat: moved oidc to ee directory --- backend/src/@types/fastify.d.ts | 2 +- backend/src/ee/routes/v1/index.ts | 2 ++ .../{server => ee}/routes/v1/oidc-router.ts | 4 ++-- .../{ => ee}/services/oidc/oidc-config-dal.ts | 0 .../services/oidc/oidc-config-service.ts | 22 +++++++++---------- .../services/oidc/oidc-config-types.ts | 0 backend/src/server/routes/index.ts | 4 ++-- backend/src/server/routes/v1/index.ts | 2 -- 8 files changed, 18 insertions(+), 18 deletions(-) rename backend/src/{server => ee}/routes/v1/oidc-router.ts (98%) rename backend/src/{ => ee}/services/oidc/oidc-config-dal.ts (100%) rename backend/src/{ => ee}/services/oidc/oidc-config-service.ts (95%) rename backend/src/{ => ee}/services/oidc/oidc-config-types.ts (100%) diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 06cca28b3..80080f6c5 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -12,6 +12,7 @@ import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; @@ -40,7 +41,6 @@ import { TIdentityProjectServiceFactory } from "@app/services/identity-project/i import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; -import { TOidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 16e23eb88..de59c22aa 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -7,6 +7,7 @@ import { registerGroupRouter } from "./group-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; +import { registerOidcRouter } from "./oidc-router"; import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; @@ -55,6 +56,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { ); await server.register(registerSamlRouter, { prefix: "/sso" }); + await server.register(registerOidcRouter, { prefix: "/oidc" }); await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerLdapRouter, { prefix: "/ldap" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); diff --git a/backend/src/server/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts similarity index 98% rename from backend/src/server/routes/v1/oidc-router.ts rename to backend/src/ee/routes/v1/oidc-router.ts index 05c2bd90e..49d1d5223 100644 --- a/backend/src/server/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -33,13 +33,13 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { url: "/login", method: "GET", schema: { - params: z.object({ + querystring: z.object({ orgSlug: z.string().trim() }) }, handler: async (req, res) => { // get params, save to session - const { orgSlug } = req.params; + const { orgSlug } = req.query; req.session.set("oidcOrgSlug", orgSlug); const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug); ( diff --git a/backend/src/services/oidc/oidc-config-dal.ts b/backend/src/ee/services/oidc/oidc-config-dal.ts similarity index 100% rename from backend/src/services/oidc/oidc-config-dal.ts rename to backend/src/ee/services/oidc/oidc-config-dal.ts diff --git a/backend/src/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts similarity index 95% rename from backend/src/services/oidc/oidc-config-service.ts rename to backend/src/ee/services/oidc/oidc-config-service.ts index d80b506d2..049dee52b 100644 --- a/backend/src/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -18,18 +18,18 @@ import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; +import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; -import { AuthMethod, AuthTokenType } from "../auth/auth-type"; -import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenType } from "../auth-token/auth-token-types"; -import { TOrgBotDALFactory } from "../org/org-bot-dal"; -import { TOrgDALFactory } from "../org/org-dal"; -import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; -import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; -import { TUserDALFactory } from "../user/user-dal"; -import { normalizeUsername } from "../user/user-fns"; -import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; -import { UserAliasType } from "../user-alias/user-alias-types"; import { TOidcConfigDALFactory } from "./oidc-config-dal"; import { TCreateOidcCfgDTO, TGetOidcCfgDTO, TOidcLoginDTO, TUpdateOidcCfgDTO } from "./oidc-config-types"; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts similarity index 100% rename from backend/src/services/oidc/oidc-config-types.ts rename to backend/src/ee/services/oidc/oidc-config-types.ts diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0bf4d0ae5..925ae5f39 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -30,6 +30,8 @@ import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-conf import { ldapGroupMapDALFactory } from "@app/ee/services/ldap-config/ldap-group-map-dal"; import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; +import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; +import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; @@ -101,8 +103,6 @@ import { integrationAuthServiceFactory } from "@app/services/integration-auth/in import { kmsDALFactory } from "@app/services/kms/kms-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; -import { oidcConfigDALFactory } from "@app/services/oidc/oidc-config-dal"; -import { oidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 4b8480cd5..fd8255e63 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,7 +11,6 @@ import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; -import { registerOidcRouter } from "./oidc-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerProjectEnvRouter } from "./project-env-router"; @@ -30,7 +29,6 @@ import { registerWebhookRouter } from "./webhook-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); - await server.register(registerOidcRouter, { prefix: "/oidc" }); await server.register( async (authRouter) => { await authRouter.register(registerAuthRoutes); From d79ffbe37e53358374b729c2ecbe0d61ad5f2bda Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 01:11:57 +0800 Subject: [PATCH 05/38] misc: added license checks for oidc sso --- .../src/ee/services/license/licence-fns.ts | 1 + .../src/ee/services/license/license-types.ts | 1 + .../ee/services/oidc/oidc-config-service.ts | 18 ++++++++++-- frontend/src/hooks/api/subscriptions/types.ts | 17 ++++++----- .../components/OrgAuthTab/OrgOIDCSection.tsx | 29 +++++++++++++++---- 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 189a3c4e0..c9a002dd5 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -27,6 +27,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, + oidcSSO: false, scim: false, ldap: false, groups: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 0c8fdc197..6d8ac6a64 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -44,6 +44,7 @@ export type TFeatureSet = { auditLogStreams: false; auditLogStreamLimit: 3; samlSSO: false; + oidcSSO: false; scim: false; ldap: false; groups: false; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 049dee52b..323ccb20a 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -196,9 +196,6 @@ export const oidcConfigServiceFactory = ({ } ); - // TODO: Sheen update oidc config - // await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, @@ -315,12 +312,20 @@ export const oidcConfigServiceFactory = ({ const org = await orgDAL.findOne({ slug: orgSlug }); + if (!org) { throw new BadRequestError({ message: "Organization not found" }); } + const plan = await licenseService.getPlan(org.id); + if (!plan.oidcSSO) + throw new BadRequestError({ + message: + "Failed to update OIDC SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); + const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -396,6 +401,13 @@ export const oidcConfigServiceFactory = ({ }); } + const plan = await licenseService.getPlan(org.id); + if (!plan.oidcSSO) + throw new BadRequestError({ + message: + "Failed to create OIDC SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); + const { permission } = await permissionService.getOrgPermission( actor, actorId, diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 45414292d..7ce074344 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -21,18 +21,19 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + oidcSSO: boolean; scim: boolean; ldap: boolean; groups: boolean; status: - | "incomplete" - | "incomplete_expired" - | "trialing" - | "active" - | "past_due" - | "canceled" - | "unpaid" - | null; + | "incomplete" + | "incomplete_expired" + | "trialing" + | "active" + | "past_due" + | "canceled" + | "unpaid" + | null; trial_end: number | null; has_used_trial: boolean; }; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx index 41d7b7416..ee09e3fe9 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx @@ -1,7 +1,12 @@ import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, Switch } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { Button, Switch, UpgradePlanModal } from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; import { useGetOIDCConfig } from "@app/hooks/api"; import { useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -10,17 +15,24 @@ import { OIDCModal } from "./OIDCModal"; export const OrgOIDCSection = (): JSX.Element => { const { currentOrg } = useOrganization(); + const { subscription } = useSubscription(); const { data, isLoading } = useGetOIDCConfig(currentOrg?.slug ?? ""); const { mutateAsync } = useUpdateOIDCConfig(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "addOIDC" + "addOIDC", + "upgradePlan" ] as const); const handleOIDCToggle = async (value: boolean) => { try { if (!currentOrg?.id) return; + if (!subscription?.oidcSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + await mutateAsync({ orgSlug: currentOrg?.slug, isActive: value @@ -40,10 +52,10 @@ export const OrgOIDCSection = (): JSX.Element => { }; const addOidcButtonClick = async () => { - try { + if (subscription?.oidcSSO && currentOrg) { handlePopUpOpen("addOIDC"); - } catch (err) { - console.error(err); + } else { + handlePopUpOpen("upgradePlan"); } }; @@ -96,6 +108,11 @@ export const OrgOIDCSection = (): JSX.Element => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use OIDC SSO if you switch to Infisical's Pro plan." + /> ); }; From 4f2f7b2f70a37735369f0e4a7b379b97f5c37fbe Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 01:21:42 +0800 Subject: [PATCH 06/38] misc: moved oidc endpoints to /sso --- backend/src/ee/routes/v1/index.ts | 9 +++++++-- frontend/src/hooks/api/oidcConfig/mutations.tsx | 4 ++-- frontend/src/hooks/api/oidcConfig/queries.tsx | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index de59c22aa..7431ec609 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -55,8 +55,13 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/dynamic-secrets" } ); - await server.register(registerSamlRouter, { prefix: "/sso" }); - await server.register(registerOidcRouter, { prefix: "/oidc" }); + await server.register( + async (ssoRouter) => { + await ssoRouter.register(registerSamlRouter); + await ssoRouter.register(registerOidcRouter, { prefix: "/oidc" }); + }, + { prefix: "/sso" } + ); await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerLdapRouter, { prefix: "/ldap" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx index 44d976170..bfcf92d1f 100644 --- a/frontend/src/hooks/api/oidcConfig/mutations.tsx +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -28,7 +28,7 @@ export const useUpdateOIDCConfig = () => { isActive?: boolean; orgSlug: string; }) => { - const { data } = await apiRequest.patch("/api/v1/oidc/config", { + const { data } = await apiRequest.patch("/api/v1/sso/oidc/config", { issuer, authorizationEndpoint, jwksUri, @@ -72,7 +72,7 @@ export const useCreateOIDCConfig = () => { isActive: boolean; orgSlug: string; }) => { - const { data } = await apiRequest.post("/api/v1/oidc/config", { + const { data } = await apiRequest.post("/api/v1/sso/oidc/config", { issuer, authorizationEndpoint, jwksUri, diff --git a/frontend/src/hooks/api/oidcConfig/queries.tsx b/frontend/src/hooks/api/oidcConfig/queries.tsx index 590c39848..08b38cbb8 100644 --- a/frontend/src/hooks/api/oidcConfig/queries.tsx +++ b/frontend/src/hooks/api/oidcConfig/queries.tsx @@ -13,7 +13,7 @@ export const useGetOIDCConfig = (orgSlug: string) => { queryKey: oidcConfigKeys.getOIDCConfig(orgSlug), queryFn: async () => { const { data } = await apiRequest.get( - `/api/v1/oidc/config?orgSlug=${orgSlug}` + `/api/v1/sso/oidc/config?orgSlug=${orgSlug}` ); return data; From df51d05c46b40047297ef84bb403930f65305ec9 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 02:10:08 +0800 Subject: [PATCH 07/38] feat: integrated oidc with sso login --- backend/src/ee/routes/v1/oidc-router.ts | 32 +++++++++---------- .../ee/services/oidc/oidc-config-service.ts | 2 +- frontend/src/views/Login/Login.tsx | 6 ++-- .../components/InitialStep/InitialStep.tsx | 25 ++++++++++++--- .../Login/components/SAMLSSOStep/index.tsx | 1 - .../SAMLSSOStep.tsx => SSOStep/SSOStep.tsx} | 27 ++++++++++------ .../views/Login/components/SSOStep/index.tsx | 1 + frontend/src/views/Login/components/index.tsx | 2 +- 8 files changed, 60 insertions(+), 36 deletions(-) delete mode 100644 frontend/src/views/Login/components/SAMLSSOStep/index.tsx rename frontend/src/views/Login/components/{SAMLSSOStep/SAMLSSOStep.tsx => SSOStep/SSOStep.tsx} (65%) create mode 100644 frontend/src/views/Login/components/SSOStep/index.tsx diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 49d1d5223..00844309d 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -59,7 +59,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug); await ( passport.authenticate(oidcStrategy as Strategy, { - failureRedirect: "/api/v1/oidc/login/error", + failureRedirect: "/api/v1/sso/oidc/login/error", session: false, failureMessage: true }) as any @@ -137,13 +137,13 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { schema: { body: z .object({ - issuer: z.string(), - authorizationEndpoint: z.string(), - jwksUri: z.string(), - tokenEndpoint: z.string(), - userinfoEndpoint: z.string(), - clientId: z.string(), - clientSecret: z.string(), + issuer: z.string().trim(), + authorizationEndpoint: z.string().trim(), + jwksUri: z.string().trim(), + tokenEndpoint: z.string().trim(), + userinfoEndpoint: z.string().trim(), + clientId: z.string().trim(), + clientSecret: z.string().trim(), isActive: z.boolean() }) .partial() @@ -182,15 +182,15 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), schema: { body: z.object({ - issuer: z.string(), - authorizationEndpoint: z.string(), - jwksUri: z.string(), - tokenEndpoint: z.string(), - userinfoEndpoint: z.string(), - clientId: z.string(), - clientSecret: z.string(), + issuer: z.string().trim(), + authorizationEndpoint: z.string().trim(), + jwksUri: z.string().trim(), + tokenEndpoint: z.string().trim(), + userinfoEndpoint: z.string().trim(), + clientId: z.string().trim(), + clientSecret: z.string().trim(), isActive: z.boolean(), - orgSlug: z.string() + orgSlug: z.string().trim() }), response: { 200: OidcConfigsSchema.pick({ diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 323ccb20a..03d74ea16 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -520,7 +520,7 @@ export const oidcConfigServiceFactory = ({ const client = new openIdIssuer.Client({ client_id: oidcCfg.clientId, client_secret: oidcCfg.clientSecret, - redirect_uris: [`${appCfg.SITE_URL}/api/v1/oidc/callback`] + redirect_uris: [`${appCfg.SITE_URL}/api/v1/sso/oidc/callback`] }); const strategy = new OpenIdStrategy( diff --git a/frontend/src/views/Login/Login.tsx b/frontend/src/views/Login/Login.tsx index 04a24d233..cad207aa8 100644 --- a/frontend/src/views/Login/Login.tsx +++ b/frontend/src/views/Login/Login.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { isLoggedIn } from "@app/reactQuery"; -import { InitialStep, MFAStep, SAMLSSOStep } from "./components"; +import { InitialStep, MFAStep, SSOStep } from "./components"; import { navigateUserToSelectOrg } from "./Login.utils"; export const Login = () => { @@ -57,7 +57,9 @@ export const Login = () => { /> ); case 2: - return ; + return ; + case 3: + return ; default: return
; } diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index 6e2c788ae..82bee7eb4 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -40,11 +40,13 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: const { data: serverDetails } = useFetchServerStatus(); useEffect(() => { - if (serverDetails?.samlDefaultOrgSlug){ - const callbackPort = queryParams.get("callback_port"); - const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${serverDetails?.samlDefaultOrgSlug}${callbackPort ? `?callback_port=${callbackPort}` : ""}` - router.push(redirectUrl); - } + if (serverDetails?.samlDefaultOrgSlug) { + const callbackPort = queryParams.get("callback_port"); + const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${ + serverDetails?.samlDefaultOrgSlug + }${callbackPort ? `?callback_port=${callbackPort}` : ""}`; + router.push(redirectUrl); + } }, [serverDetails?.samlDefaultOrgSlug]); const handleLogin = async (e: FormEvent) => { @@ -217,6 +219,19 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Continue with SAML
+
+ +
diff --git a/frontend/src/views/Login/components/SSOStep/index.tsx b/frontend/src/views/Login/components/SSOStep/index.tsx new file mode 100644 index 000000000..e7d80b2c0 --- /dev/null +++ b/frontend/src/views/Login/components/SSOStep/index.tsx @@ -0,0 +1 @@ +export { SSOStep } from "./SSOStep"; diff --git a/frontend/src/views/Login/components/index.tsx b/frontend/src/views/Login/components/index.tsx index 7c55c4acf..296b0503e 100644 --- a/frontend/src/views/Login/components/index.tsx +++ b/frontend/src/views/Login/components/index.tsx @@ -1,6 +1,6 @@ export { InitialStep } from "./InitialStep"; export { MFAStep } from "./MFAStep"; -export { SAMLSSOStep } from "./SAMLSSOStep"; +export { SSOStep } from "./SSOStep"; // SSO-specific step export { PasswordStep } from "./PasswordStep"; From 96ad3b02643ece9313038c346d649c639ccf206c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 14:20:04 +0800 Subject: [PATCH 08/38] misc: used redis for oic session managemen --- backend/package-lock.json | 91 +++++++++++++++++++++++++ backend/package.json | 1 + backend/src/ee/routes/v1/oidc-router.ts | 13 +++- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index b0ff466c2..4e22ed5a1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -36,6 +36,7 @@ "bcrypt": "^5.1.1", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", + "connect-redis": "^7.1.1", "cron": "^3.1.7", "dotenv": "^16.4.1", "fastify": "^4.26.0", @@ -6633,6 +6634,17 @@ "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", "dev": true }, + "node_modules/connect-redis": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-7.1.1.tgz", + "integrity": "sha512-M+z7alnCJiuzKa8/1qAYdGUXHYfDnLolOGAUjOioB07pP39qxjG+X9ibsud7qUBc4jMV5Mcy3ugGv8eFcgamJQ==", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "express-session": ">=1" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -7739,6 +7751,55 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "peer": true, + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "peer": true + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "peer": true + }, "node_modules/express/node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -10737,6 +10798,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11813,6 +11883,15 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -13871,6 +13950,18 @@ "node": ">=0.8.0" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "peer": true, + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uid2": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", diff --git a/backend/package.json b/backend/package.json index 7aecc01bb..dbd55705e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -97,6 +97,7 @@ "bcrypt": "^5.1.1", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", + "connect-redis": "^7.1.1", "cron": "^3.1.7", "dotenv": "^16.4.1", "fastify": "^4.26.0", diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 00844309d..c5433c008 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -8,6 +8,8 @@ import { Authenticator, Strategy } from "@fastify/passport"; import fastifySession from "@fastify/session"; +import RedisStore from "connect-redis"; +import { Redis } from "ioredis"; import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; @@ -18,13 +20,22 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); + const redis = new Redis(appCfg.REDIS_URL); const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" }); + const redisStore = new RedisStore({ + client: redis, + prefix: "oidc-session:", + ttl: 600 // 10 minutes + }); + await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY, + store: redisStore, cookie: { - secure: false // has to be set to false if testing locally + secure: false // set to true in production } }); + await server.register(passport.initialize()); await server.register(passport.secureSession()); From 8de4443be172111eaadeee70a7f6a978c49fc4b6 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 15:26:08 +0800 Subject: [PATCH 09/38] feat: added support for login via cli --- backend/src/ee/routes/v1/oidc-router.ts | 22 ++++++++++++++----- .../ee/services/oidc/oidc-config-service.ts | 10 +++++---- .../src/ee/services/oidc/oidc-config-types.ts | 1 + .../Login/components/SSOStep/SSOStep.tsx | 7 ++++-- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index c5433c008..55d3be19d 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -45,14 +45,20 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { querystring: z.object({ - orgSlug: z.string().trim() + orgSlug: z.string().trim(), + callbackPort: z.string().trim().optional() }) }, handler: async (req, res) => { // get params, save to session - const { orgSlug } = req.query; + const { orgSlug, callbackPort } = req.query; req.session.set("oidcOrgSlug", orgSlug); - const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug); + + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug, callbackPort); ( passport.authenticate(oidcStrategy as Strategy, { scope: "profile email openid" @@ -67,7 +73,9 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { method: "GET", handler: async (req, res) => { const oidcOrgSlug = req.session.get("oidcOrgSlug"); - const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug); + const callbackPort = req.session.get("callbackPort"); + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug, callbackPort); + await ( passport.authenticate(oidcStrategy as Strategy, { failureRedirect: "/api/v1/sso/oidc/login/error", @@ -76,6 +84,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { }) as any )(req, res); + await req.session.destroy(); + if (req.passportUser.isUserCompleted) { return res.redirect( `http://localhost:8080/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` @@ -92,7 +102,9 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { server.route({ url: "/login/error", method: "GET", - handler: (req, res) => { + handler: async (req, res) => { + await req.session.destroy(); + return res.status(500).send({ error: "Authentication error", details: req.query diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 03d74ea16..b880c8664 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -63,7 +63,7 @@ export const oidcConfigServiceFactory = ({ smtpService, oidcConfigDAL }: TOidcConfigServiceFactoryDep) => { - const oidcLogin = async ({ externalId, email, firstName, lastName, orgId }: TOidcLoginDTO) => { + const oidcLogin = async ({ externalId, email, firstName, lastName, orgId, callbackPort }: TOidcLoginDTO) => { const appCfg = getConfig(); const userAlias = await userAliasDAL.findOne({ externalId, @@ -188,7 +188,8 @@ export const oidcConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: AuthMethod.OIDC, authType: UserAliasType.OIDC, - isUserCompleted + isUserCompleted, + ...(callbackPort && { callbackPort }) }, appCfg.AUTH_SECRET, { @@ -491,7 +492,7 @@ export const oidcConfigServiceFactory = ({ return oidcCfg; }; - const getOrgAuthStrategy = async (orgSlug: string) => { + const getOrgAuthStrategy = async (orgSlug: string, callbackPort?: string) => { const appCfg = getConfig(); const org = await orgDAL.findOne({ @@ -542,7 +543,8 @@ export const oidcConfigServiceFactory = ({ externalId: claims.sub, firstName: claims.given_name ?? "", lastName: claims.family_name ?? "", - orgId: org.id + orgId: org.id, + callbackPort }) .then(({ isUserCompleted, providerAuthToken }) => { cb(null, { isUserCompleted, providerAuthToken }); diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index a160b272a..4c729ceea 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -6,6 +6,7 @@ export type TOidcLoginDTO = { firstName: string; lastName?: string; orgId: string; + callbackPort?: string; }; export type TGetOidcCfgDTO = diff --git a/frontend/src/views/Login/components/SSOStep/SSOStep.tsx b/frontend/src/views/Login/components/SSOStep/SSOStep.tsx index 5e70cf1a7..9663c04a9 100644 --- a/frontend/src/views/Login/components/SSOStep/SSOStep.tsx +++ b/frontend/src/views/Login/components/SSOStep/SSOStep.tsx @@ -24,8 +24,11 @@ export const SSOStep = ({ setStep, type }: Props) => { }` ); } else { - // TODO: Sheen - Add callback support for CLI login - window.open(`/api/v1/sso/oidc/login?orgSlug=${ssoIdentifier}`); + window.open( + `/api/v1/sso/oidc/login?orgSlug=${ssoIdentifier}${ + callbackPort ? `&callbackPort=${callbackPort}` : "" + }` + ); } window.close(); From c5c00b520c7378887fa0111b5cbb8da57d6fe226 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 15:35:18 +0800 Subject: [PATCH 10/38] misc: added session regenerate for fresh state --- backend/src/ee/routes/v1/oidc-router.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 55d3be19d..36ad8a085 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -50,8 +50,11 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { }) }, handler: async (req, res) => { - // get params, save to session const { orgSlug, callbackPort } = req.query; + + // ensure fresh session state per login attempt + await req.session.regenerate(); + req.session.set("oidcOrgSlug", orgSlug); if (callbackPort) { From 371b96a13af8683bb22994876d494e22c87a3ced Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 15:36:49 +0800 Subject: [PATCH 11/38] misc: removed cookie path proxy for dev envs --- nginx/default.dev.conf | 2 -- 1 file changed, 2 deletions(-) diff --git a/nginx/default.dev.conf b/nginx/default.dev.conf index 94c5d7b67..0bdb335ac 100644 --- a/nginx/default.dev.conf +++ b/nginx/default.dev.conf @@ -10,8 +10,6 @@ server { proxy_pass http://backend:4000; proxy_redirect off; - - proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; } location / { From bcd65333c0a032a05f7200767b8f2cd02f322caa Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 19:17:54 +0800 Subject: [PATCH 12/38] misc: added handling of inactive and undefined oidc config --- backend/src/ee/services/oidc/oidc-config-service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index b880c8664..7c4b7e87e 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -510,6 +510,12 @@ export const oidcConfigServiceFactory = ({ orgSlug }); + if (!oidcCfg || !oidcCfg.isActive) { + throw new BadRequestError({ + message: "Failed to authenticate with OIDC SSO" + }); + } + const openIdIssuer = new OpenIdIssuer({ issuer: oidcCfg.issuer, authorization_endpoint: oidcCfg.authorizationEndpoint, From bdc7c018eb5e38b4085689943cd46ccdc7e916df Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 20:36:09 +0800 Subject: [PATCH 13/38] misc: added comment regarding session and redis usage --- backend/src/ee/routes/v1/oidc-router.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 36ad8a085..8b68a8aef 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -22,6 +22,12 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); const redis = new Redis(appCfg.REDIS_URL); const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" }); + + /* + - OIDC protocol cannot work without sessions: https://github.com/panva/node-openid-client/issues/190 + - Current redis usage is not ideal and will eventually have to be refactored to use a better structure + - Fastify session <> Redis structure is based on the ff: https://github.com/fastify/session/blob/master/examples/redis.js + */ const redisStore = new RedisStore({ client: redis, prefix: "oidc-session:", From 18e69578f03240fd83043d850d9d862270b2caa3 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 19 Jun 2024 01:29:26 +0800 Subject: [PATCH 14/38] feat: added support for limiting email domains --- .../20240617041053_add-oidc-auth.ts | 1 + backend/src/db/schemas/oidc-configs.ts | 1 + backend/src/ee/routes/v1/oidc-router.ts | 33 +++- .../ee/services/oidc/oidc-config-service.ts | 173 ++++++++++-------- .../src/ee/services/oidc/oidc-config-types.ts | 2 + .../src/hooks/api/oidcConfig/mutations.tsx | 6 + frontend/src/hooks/api/oidcConfig/types.ts | 1 + .../components/OrgAuthTab/OIDCModal.tsx | 20 +- 8 files changed, 154 insertions(+), 83 deletions(-) diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts index b11e6bbab..8ef34854b 100644 --- a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -17,6 +17,7 @@ export async function up(knex: Knex): Promise { tb.text("encryptedClientSecret").notNullable(); tb.string("clientSecretIV").notNullable(); tb.string("clientSecretTag").notNullable(); + tb.string("allowedEmailDomains").nullable(); tb.boolean("isActive").notNullable(); tb.timestamps(true, true, true); tb.uuid("orgId").notNullable().unique(); diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index b06114df6..3de22d1d0 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -20,6 +20,7 @@ export const OidcConfigsSchema = z.object({ encryptedClientSecret: z.string(), clientSecretIV: z.string(), clientSecretTag: z.string(), + allowedEmailDomains: z.string().nullable().optional(), isActive: z.boolean(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 8b68a8aef..d64704dce 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -137,7 +137,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { tokenEndpoint: true, userinfoEndpoint: true, isActive: true, - orgId: true + orgId: true, + allowedEmailDomains: true }).extend({ clientId: z.string(), clientSecret: z.string() @@ -169,6 +170,19 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { schema: { body: z .object({ + allowedEmailDomains: z + .string() + .trim() + .optional() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }), issuer: z.string().trim(), authorizationEndpoint: z.string().trim(), jwksUri: z.string().trim(), @@ -189,6 +203,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { tokenEndpoint: true, userinfoEndpoint: true, orgId: true, + allowedEmailDomains: true, isActive: true }) } @@ -215,6 +230,19 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ issuer: z.string().trim(), + allowedEmailDomains: z + .string() + .trim() + .optional() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }), authorizationEndpoint: z.string().trim(), jwksUri: z.string().trim(), tokenEndpoint: z.string().trim(), @@ -233,7 +261,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { tokenEndpoint: true, userinfoEndpoint: true, orgId: true, - isActive: true + isActive: true, + allowedEmailDomains: true }) } }, diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 7c4b7e87e..3f58036b6 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -63,6 +63,86 @@ export const oidcConfigServiceFactory = ({ smtpService, oidcConfigDAL }: TOidcConfigServiceFactoryDep) => { + const getOidc = async (dto: TGetOidcCfgDTO) => { + const org = await orgDAL.findOne({ slug: dto.orgSlug }); + if (!org) { + throw new BadRequestError({ + message: "Organization not found", + name: "OrgNotFound" + }); + } + if (dto.type === "external") { + const { permission } = await permissionService.getOrgPermission( + dto.actor, + dto.actorId, + org.id, + dto.actorAuthMethod, + dto.actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + } + + const oidcCfg = await oidcConfigDAL.findOne({ + orgId: org.id + }); + + if (!oidcCfg) { + throw new BadRequestError({ + message: "Failed to find organization OIDC configuration" + }); + } + + // decrypt and return cfg + const orgBot = await orgBotDAL.findOne({ orgId: oidcCfg.orgId }); + if (!orgBot) { + throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + } + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedClientId, clientIdIV, clientIdTag, encryptedClientSecret, clientSecretIV, clientSecretTag } = + oidcCfg; + + let clientId = ""; + if (encryptedClientId && clientIdIV && clientIdTag) { + clientId = decryptSymmetric({ + ciphertext: encryptedClientId, + key, + tag: clientIdTag, + iv: clientIdIV + }); + } + + let clientSecret = ""; + if (encryptedClientSecret && clientSecretIV && clientSecretTag) { + clientSecret = decryptSymmetric({ + key, + tag: clientSecretTag, + iv: clientSecretIV, + ciphertext: encryptedClientSecret + }); + } + + return { + id: oidcCfg.id, + issuer: oidcCfg.issuer, + authorizationEndpoint: oidcCfg.authorizationEndpoint, + jwksUri: oidcCfg.jwksUri, + tokenEndpoint: oidcCfg.tokenEndpoint, + userinfoEndpoint: oidcCfg.userinfoEndpoint, + orgId: oidcCfg.orgId, + isActive: oidcCfg.isActive, + allowedEmailDomains: oidcCfg.allowedEmailDomains, + clientId, + clientSecret + }; + }; + const oidcLogin = async ({ externalId, email, firstName, lastName, orgId, callbackPort }: TOidcLoginDTO) => { const appCfg = getConfig(); const userAlias = await userAliasDAL.findOne({ @@ -216,87 +296,9 @@ export const oidcConfigServiceFactory = ({ return { isUserCompleted, providerAuthToken }; }; - const getOidc = async (dto: TGetOidcCfgDTO) => { - const org = await orgDAL.findOne({ slug: dto.orgSlug }); - if (!org) { - throw new BadRequestError({ - message: "Organization not found", - name: "OrgNotFound" - }); - } - if (dto.type === "external") { - const { permission } = await permissionService.getOrgPermission( - dto.actor, - dto.actorId, - org.id, - dto.actorAuthMethod, - dto.actorOrgId - ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); - } - - const oidcCfg = await oidcConfigDAL.findOne({ - orgId: org.id - }); - - if (!oidcCfg) { - throw new BadRequestError({ - message: "Failed to find organization OIDC configuration" - }); - } - - // decrypt and return cfg - const orgBot = await orgBotDAL.findOne({ orgId: oidcCfg.orgId }); - if (!orgBot) { - throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); - } - - const key = infisicalSymmetricDecrypt({ - ciphertext: orgBot.encryptedSymmetricKey, - iv: orgBot.symmetricKeyIV, - tag: orgBot.symmetricKeyTag, - keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding - }); - - const { encryptedClientId, clientIdIV, clientIdTag, encryptedClientSecret, clientSecretIV, clientSecretTag } = - oidcCfg; - - let clientId = ""; - if (encryptedClientId && clientIdIV && clientIdTag) { - clientId = decryptSymmetric({ - ciphertext: encryptedClientId, - key, - tag: clientIdTag, - iv: clientIdIV - }); - } - - let clientSecret = ""; - if (encryptedClientSecret && clientSecretIV && clientSecretTag) { - clientSecret = decryptSymmetric({ - key, - tag: clientSecretTag, - iv: clientSecretIV, - ciphertext: encryptedClientSecret - }); - } - - return { - id: oidcCfg.id, - issuer: oidcCfg.issuer, - authorizationEndpoint: oidcCfg.authorizationEndpoint, - jwksUri: oidcCfg.jwksUri, - tokenEndpoint: oidcCfg.tokenEndpoint, - userinfoEndpoint: oidcCfg.userinfoEndpoint, - orgId: oidcCfg.orgId, - isActive: oidcCfg.isActive, - clientId, - clientSecret - }; - }; - const updateOidcCfg = async ({ orgSlug, + allowedEmailDomains, actor, actorOrgId, actorAuthMethod, @@ -346,6 +348,7 @@ export const oidcConfigServiceFactory = ({ }); const updateQuery: TOidcConfigsUpdate = { + allowedEmailDomains, issuer, authorizationEndpoint, tokenEndpoint, @@ -374,12 +377,12 @@ export const oidcConfigServiceFactory = ({ } const [ssoConfig] = await oidcConfigDAL.update({ orgId: org.id }, updateQuery); - return ssoConfig; }; const createOidcCfg = async ({ orgSlug, + allowedEmailDomains, actor, actorOrgId, actorAuthMethod, @@ -477,6 +480,7 @@ export const oidcConfigServiceFactory = ({ issuer, isActive, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -544,6 +548,15 @@ export const oidcConfigServiceFactory = ({ }); } + if (oidcCfg.allowedEmailDomains) { + const allowedDomains = oidcCfg.allowedEmailDomains.split(", "); + if (!allowedDomains.includes(claims.email.split("@")[1])) { + throw new BadRequestError({ + message: "Email not allowed." + }); + } + } + oidcLogin({ email: claims.email, externalId: claims.sub, diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index 4c729ceea..f773bbe92 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -22,6 +22,7 @@ export type TGetOidcCfgDTO = export type TCreateOidcCfgDTO = { issuer: string; authorizationEndpoint: string; + allowedEmailDomains: string; jwksUri: string; tokenEndpoint: string; userinfoEndpoint: string; @@ -34,6 +35,7 @@ export type TCreateOidcCfgDTO = { export type TUpdateOidcCfgDTO = Partial<{ issuer: string; authorizationEndpoint: string; + allowedEmailDomains: string; jwksUri: string; tokenEndpoint: string; userinfoEndpoint: string; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx index bfcf92d1f..39467e84c 100644 --- a/frontend/src/hooks/api/oidcConfig/mutations.tsx +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -13,11 +13,13 @@ export const useUpdateOIDCConfig = () => { jwksUri, tokenEndpoint, userinfoEndpoint, + allowedEmailDomains, clientId, clientSecret, isActive, orgSlug }: { + allowedEmailDomains?: string; issuer?: string; authorizationEndpoint?: string; jwksUri?: string; @@ -30,6 +32,7 @@ export const useUpdateOIDCConfig = () => { }) => { const { data } = await apiRequest.patch("/api/v1/sso/oidc/config", { issuer, + allowedEmailDomains, authorizationEndpoint, jwksUri, tokenEndpoint, @@ -54,6 +57,7 @@ export const useCreateOIDCConfig = () => { mutationFn: async ({ issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -71,10 +75,12 @@ export const useCreateOIDCConfig = () => { clientSecret: string; isActive: boolean; orgSlug: string; + allowedEmailDomains?: string; }) => { const { data } = await apiRequest.post("/api/v1/sso/oidc/config", { issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, diff --git a/frontend/src/hooks/api/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts index 579590e09..457a86cb7 100644 --- a/frontend/src/hooks/api/oidcConfig/types.ts +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -9,4 +9,5 @@ export type OIDCConfigData = { orgId: string; clientId: string; clientSecret: string; + allowedEmailDomains?: string; }; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx index 55b7924a0..229f437f9 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -23,7 +23,8 @@ const schema = z.object({ tokenEndpoint: z.string().min(1), userinfoEndpoint: z.string().min(1), clientId: z.string().min(1), - clientSecret: z.string().min(1) + clientSecret: z.string().min(1), + allowedEmailDomains: z.string().optional() }); export type OIDCFormData = z.infer; @@ -48,12 +49,14 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) setValue("userinfoEndpoint", data.userinfoEndpoint); setValue("clientId", data.clientId); setValue("clientSecret", data.clientSecret); + setValue("allowedEmailDomains", data.allowedEmailDomains); } }, [data]); const onOIDCModalSubmit = async ({ issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -67,6 +70,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) await createMutateAsync({ issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -79,6 +83,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) await updateMutateAsync({ issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -187,6 +192,19 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) )} /> + ( + + + + )} + /> Date: Wed, 19 Jun 2024 13:46:17 +0800 Subject: [PATCH 15/38] misc: added option for trusting OIDC emails by default --- .../20240617041053_add-oidc-auth.ts | 16 +++++++++ backend/src/db/schemas/super-admin.ts | 3 +- .../ee/services/oidc/oidc-config-service.ts | 18 ++++++++-- backend/src/server/routes/v1/admin-router.ts | 3 +- frontend/src/hooks/api/admin/types.ts | 1 + .../admin/DashboardPage/DashboardPage.tsx | 35 +++++++++++++++---- 6 files changed, 65 insertions(+), 11 deletions(-) diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts index 8ef34854b..b74f0040a 100644 --- a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -24,8 +24,24 @@ export async function up(knex: Knex): Promise { tb.foreign("orgId").references("id").inTable(TableName.Organization); }); } + + if (await knex.schema.hasTable(TableName.SuperAdmin)) { + if (!(await knex.schema.hasColumn(TableName.SuperAdmin, "trustOidcEmails"))) { + await knex.schema.alterTable(TableName.SuperAdmin, (tb) => { + tb.boolean("trustOidcEmails").defaultTo(false); + }); + } + } } export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.OidcConfig); + + if (await knex.schema.hasTable(TableName.SuperAdmin)) { + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustOidcEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustOidcEmails"); + }); + } + } } diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 417d4e05e..87ba35c83 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -16,7 +16,8 @@ export const SuperAdminSchema = z.object({ allowedSignUpDomain: z.string().nullable().optional(), instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000"), trustSamlEmails: z.boolean().default(false).nullable().optional(), - trustLdapEmails: z.boolean().default(false).nullable().optional() + trustLdapEmails: z.boolean().default(false).nullable().optional(), + trustOidcEmails: z.boolean().default(false).nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 3f58036b6..546de7d9d 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -25,6 +25,7 @@ import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -144,6 +145,7 @@ export const oidcConfigServiceFactory = ({ }; const oidcLogin = async ({ externalId, email, firstName, lastName, orgId, callbackPort }: TOidcLoginDTO) => { + const serverCfg = await getServerCfg(); const appCfg = getConfig(); const userAlias = await userAliasDAL.findOne({ externalId, @@ -192,14 +194,25 @@ export const oidcConfigServiceFactory = ({ } else { user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; + + if (serverCfg.trustOidcEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + if (!newUser) { const uniqueUsername = await normalizeUsername(externalId, userDAL); newUser = await userDAL.create( { email, firstName, - isEmailVerified: false, - username: uniqueUsername, + isEmailVerified: serverCfg.trustOidcEmails, + username: serverCfg.trustOidcEmails ? email : uniqueUsername, lastName, authMethods: [], isGhost: false @@ -252,6 +265,7 @@ export const oidcConfigServiceFactory = ({ return newUser; }); } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); const isUserCompleted = Boolean(user.isAccepted); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 572409d9b..d8f64b86f 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -51,7 +51,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { allowSignUp: z.boolean().optional(), allowedSignUpDomain: z.string().optional().nullable(), trustSamlEmails: z.boolean().optional(), - trustLdapEmails: z.boolean().optional() + trustLdapEmails: z.boolean().optional(), + trustOidcEmails: z.boolean().optional() }), response: { 200: z.object({ diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 6a42e6ed0..80025ab8f 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -5,6 +5,7 @@ export type TServerConfig = { isMigrationModeOn?: boolean; trustSamlEmails: boolean; trustLdapEmails: boolean; + trustOidcEmails: boolean; isSecretScanningDisabled: boolean; }; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 52fddb22c..c5f7db906 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -39,7 +39,8 @@ const formSchema = z.object({ signUpMode: z.nativeEnum(SignUpModes), allowedSignUpDomain: z.string().optional().nullable(), trustSamlEmails: z.boolean(), - trustLdapEmails: z.boolean() + trustLdapEmails: z.boolean(), + trustOidcEmails: z.boolean() }); type TDashboardForm = z.infer; @@ -60,7 +61,8 @@ export const AdminDashboardPage = () => { signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled, allowedSignUpDomain: config.allowedSignUpDomain, trustSamlEmails: config.trustSamlEmails, - trustLdapEmails: config.trustLdapEmails + trustLdapEmails: config.trustLdapEmails, + trustOidcEmails: config.trustOidcEmails } }); @@ -84,13 +86,15 @@ export const AdminDashboardPage = () => { const onFormSubmit = async (formData: TDashboardForm) => { try { - const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails } = formData; + const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails, trustOidcEmails } = + formData; await updateServerConfig({ allowSignUp: signUpMode !== SignUpModes.Disabled, allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, trustSamlEmails, - trustLdapEmails + trustLdapEmails, + trustOidcEmails }); createNotification({ text: "Successfully changed sign up setting.", @@ -190,9 +194,9 @@ export const AdminDashboardPage = () => {
Trust emails
- Select if you want Infisical to trust external emails from SAML/LDAP identity - providers. If set to false, then Infisical will prompt SAML/LDAP provisioned - users to verify their email upon their first login. + Select if you want Infisical to trust external emails from SAML/LDAP/OIDC + identity providers. If set to false, then Infisical will prompt SAML/LDAP + provisioned users to verify their email upon their first login.
{ ); }} /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust OIDC emails

+
+
+ ); + }} + />