From 66e5edcfc0c116d3d8923addcac1dcf43402facc Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 17 Jun 2024 20:32:47 +0800 Subject: [PATCH] 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 + }); +};