From 2153dd94eb1a3a04c2ab5f1d11836676e0e96645 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Jul 2024 23:48:31 +0800 Subject: [PATCH] feat: finished up login with identity oidc --- backend/package-lock.json | 57 ++++++++ backend/package.json | 1 + .../routes/v1/identity-oidc-auth-router.ts | 58 +++++++- .../identity-oidc-auth-service.ts | 136 +++++++++++++++++- .../identity-oidc-auth-types.ts | 5 + .../identity-oidc-auth-validators.ts | 12 ++ frontend/src/hooks/api/identities/index.tsx | 3 + .../IdentitySection/IdentityOidcAuthForm.tsx | 17 ++- 8 files changed, 277 insertions(+), 12 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 220d4305ca..92382047ad 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -51,6 +51,7 @@ "jmespath": "^0.16.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", + "jwks-rsa": "^3.1.0", "knex": "^3.0.1", "ldapjs": "^3.0.7", "libsodium-wrappers": "^0.7.13", @@ -10890,6 +10891,43 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.1.0.tgz", + "integrity": "sha512-v7nqlfezb9YfHHzYII3ef2a2j1XnGeSE/bK3WfumaYCqONAIstJbrEGapz4kadScZzEt7zYCN7bucj8C0Mv/Rg==", + "dependencies": { + "@types/express": "^4.17.17", + "@types/jsonwebtoken": "^9.0.2", + "debug": "^4.3.4", + "jose": "^4.14.6", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/jws": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", @@ -11117,6 +11155,11 @@ "node": ">=14" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -11191,6 +11234,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -11298,6 +11346,15 @@ "node": ">=10" } }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, "node_modules/luxon": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", diff --git a/backend/package.json b/backend/package.json index 6ef9add0df..14ed8b47b9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -112,6 +112,7 @@ "jmespath": "^0.16.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", + "jwks-rsa": "^3.1.0", "knex": "^3.0.1", "ldapjs": "^3.0.7", "libsodium-wrappers": "^0.7.13", diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index d5f64148b1..61a74aa735 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -6,7 +6,10 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; -import { validateOidcAuthAudiencesField } from "@app/services/identity-oidc-auth/identity-oidc-auth-validators"; +import { + validateOidcAuthAudiencesField, + validateOidcBoundClaimsField +} from "@app/services/identity-oidc-auth/identity-oidc-auth-validators"; const IdentityOidcAuthResponseSchema = IdentityOidcAuthsSchema.omit({ encryptedCaCert: true, @@ -17,6 +20,55 @@ const IdentityOidcAuthResponseSchema = IdentityOidcAuthsSchema.omit({ }); export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/oidc-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with OIDC Auth", + body: z.object({ + identityId: z.string().trim(), + jwt: z.string().trim() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityOidcAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityOidcAuth.login({ + identityId: req.body.identityId, + jwt: req.body.jwt + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_OIDC_AUTH, + metadata: { + identityId: identityOidcAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityOidcAuthId: identityOidcAuth.id + } + } + }); + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityOidcAuth.accessTokenTTL, + accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL + }; + } + }); + server.route({ method: "POST", url: "/oidc-auth/identities/:identityId", @@ -62,7 +114,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) caCert: z.string().trim().default(""), boundIssuer: z.string().min(1), boundAudiences: validateOidcAuthAudiencesField, - boundClaims: z.record(z.string()), + boundClaims: validateOidcBoundClaimsField, boundSubject: z.string().optional().default("") }), response: { @@ -154,7 +206,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) caCert: z.string().trim().default(""), boundIssuer: z.string().min(1), boundAudiences: validateOidcAuthAudiencesField, - boundClaims: z.record(z.string()), + boundClaims: validateOidcBoundClaimsField, boundSubject: z.string().optional().default("") }) .partial(), diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index ed338ef1a7..fcd0d4ef09 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -1,9 +1,14 @@ import { ForbiddenError } from "@casl/ability"; +import axios from "axios"; +import https from "https"; +import jwt from "jsonwebtoken"; +import { JwksClient } from "jwks-rsa"; import { IdentityAuthMethod, SecretKeyEncoding, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; 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 { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { decryptSymmetric, @@ -12,15 +17,17 @@ import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TOrgBotDALFactory } from "../org/org-bot-dal"; import { TIdentityOidcAuthDALFactory } from "./identity-oidc-auth-dal"; -import { TAttachOidcAuthDTO, TGetOidcAuthDTO, TUpdateOidcAuthDTO } from "./identity-oidc-auth-types"; +import { TAttachOidcAuthDTO, TGetOidcAuthDTO, TLoginOidcAuthDTO, TUpdateOidcAuthDTO } from "./identity-oidc-auth-types"; type TIdentityOidcAuthServiceFactoryDep = { identityOidcAuthDAL: TIdentityOidcAuthDALFactory; @@ -40,8 +47,130 @@ export const identityOidcAuthServiceFactory = ({ identityDAL, permissionService, licenseService, + identityAccessTokenDAL, orgBotDAL }: TIdentityOidcAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => { + const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); + if (!identityOidcAuth) { + throw new UnauthorizedError(); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityOidcAuth.identityId + }); + if (!identityMembershipOrg) { + throw new BadRequestError({ message: "Failed to find identity" }); + } + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.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 { encryptedCaCert, caCertIV, caCertTag } = identityOidcAuth; + + let caCert = ""; + if (encryptedCaCert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCaCert, + iv: caCertIV, + tag: caCertTag, + key + }); + } + + const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert }); + const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>( + `${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`, + { + httpsAgent: requestAgent + } + ); + const jwksUri = discoveryDoc.jwks_uri; + + const decodedToken = jwt.decode(oidcJwt, { complete: true }); + if (!decodedToken) { + throw new BadRequestError({ + message: "Invalid JWT" + }); + } + + const client = new JwksClient({ + jwksUri, + requestAgent + }); + + const { kid } = decodedToken.header; + const oidcSigningKey = await client.getSigningKey(kid); + + const tokenData = jwt.verify(oidcJwt, oidcSigningKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + + if (identityOidcAuth.boundSubject) { + if (tokenData.sub !== identityOidcAuth.boundSubject) { + throw new UnauthorizedError(); + } + } + + if (identityOidcAuth.boundAudiences) { + if (!identityOidcAuth.boundAudiences.split(", ").includes(tokenData.aud)) { + throw new UnauthorizedError(); + } + } + + if (identityOidcAuth.boundClaims) { + Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { + const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; + // handle both single and multi-valued claims + if (!claimValue.split(", ").some((claimEntry) => tokenData[claimKey] === claimEntry)) { + throw new UnauthorizedError(); + } + }); + } + + const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityOidcAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityOidcAuth.accessTokenTTL, + accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityOidcAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityOidcAuth, identityAccessToken, identityMembershipOrg }; + }; + const attachOidcAuth = async ({ identityId, oidcDiscoveryUrl, @@ -345,6 +474,7 @@ export const identityOidcAuthServiceFactory = ({ return { attachOidcAuth, updateOidcAuth, - getOidcAuth + getOidcAuth, + login }; }; diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts index aaa09a4733..0a164311c2 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts @@ -31,3 +31,8 @@ export type TUpdateOidcAuthDTO = { export type TGetOidcAuthDTO = { identityId: string; } & Omit; + +export type TLoginOidcAuthDTO = { + identityId: string; + jwt: string; +}; diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-validators.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-validators.ts index a9518e3eaf..5b02ba237b 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-validators.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-validators.ts @@ -12,3 +12,15 @@ export const validateOidcAuthAudiencesField = z .map((id) => id.trim()) .join(", "); }); + +export const validateOidcBoundClaimsField = z.record(z.string()).transform((data) => { + const formattedClaims: Record = {}; + Object.keys(data).forEach((key) => { + formattedClaims[key] = data[key] + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + + return formattedClaims; +}); diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 41b03669b9..27236f9212 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -5,6 +5,7 @@ export { useAddIdentityAzureAuth, useAddIdentityGcpAuth, useAddIdentityKubernetesAuth, + useAddIdentityOidcAuth, useAddIdentityUniversalAuth, useCreateIdentity, useCreateIdentityUniversalAuthClientSecret, @@ -15,6 +16,7 @@ export { useUpdateIdentityAzureAuth, useUpdateIdentityGcpAuth, useUpdateIdentityKubernetesAuth, + useUpdateIdentityOidcAuth, useUpdateIdentityUniversalAuth } from "./mutations"; export { @@ -22,6 +24,7 @@ export { useGetIdentityAzureAuth, useGetIdentityGcpAuth, useGetIdentityKubernetesAuth, + useGetIdentityOidcAuth, useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "./queries"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx index 294b730b70..d377fcaa3e 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx @@ -8,11 +8,8 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, IconButton, Input, TextArea } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; +import { useAddIdentityOidcAuth, useUpdateIdentityOidcAuth } from "@app/hooks/api"; import { IdentityAuthMethod } from "@app/hooks/api/identities"; -import { - useAddIdentityOidcAuth, - useUpdateIdentityOidcAuth -} from "@app/hooks/api/identities/mutations"; import { useGetIdentityOidcAuth } from "@app/hooks/api/identities/queries"; import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -218,7 +215,11 @@ export const IdentityOidcAuthForm = ({ isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -232,7 +233,11 @@ export const IdentityOidcAuthForm = ({ isError={Boolean(error)} errorText={error?.message} > - + )} />