From 4be1d3e0d017d9d6a291b730ab18bb89e3025527 Mon Sep 17 00:00:00 2001 From: msukkari Date: Tue, 3 Jun 2025 15:16:40 -0700 Subject: [PATCH 1/5] initial gcp iap implementation --- packages/web/package.json | 1 + packages/web/src/auth.ts | 97 +---------------------- packages/web/src/ee/sso/sso.tsx | 81 ++++++++++++++++++- packages/web/src/env.mjs | 3 + packages/web/src/lib/authUtils.ts | 98 +++++++++++++++++++++++ packages/web/src/middleware.ts | 46 +++++++++++ yarn.lock | 124 +++++++++++++++++++++++++++++- 7 files changed, 351 insertions(+), 99 deletions(-) create mode 100644 packages/web/src/lib/authUtils.ts diff --git a/packages/web/package.json b/packages/web/package.json index 986e18dca..4ce595500 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -114,6 +114,7 @@ "embla-carousel-react": "^8.3.0", "escape-string-regexp": "^5.0.0", "fuse.js": "^7.0.0", + "google-auth-library": "^9.15.1", "graphql": "^16.9.0", "http-status-codes": "^2.3.0", "input-otp": "^1.4.2", diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index f4e0aa82a..55fb38912 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -5,21 +5,17 @@ import EmailProvider from "next-auth/providers/nodemailer"; import { PrismaAdapter } from "@auth/prisma-adapter" import { prisma } from "@/prisma"; import { env } from "@/env.mjs"; -import { OrgRole, User } from '@sourcebot/db'; +import { User } from '@sourcebot/db'; import 'next-auth/jwt'; import type { Provider } from "next-auth/providers"; import { verifyCredentialsRequestSchema } from './lib/schemas'; import { createTransport } from 'nodemailer'; import { render } from '@react-email/render'; import MagicLinkEmail from './emails/magicLinkEmail'; -import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from './lib/constants'; import bcrypt from 'bcryptjs'; -import { createAccountRequest } from './actions'; -import { getSSOProviders, handleJITProvisioning } from '@/ee/sso/sso'; +import { getSSOProviders } from '@/ee/sso/sso'; import { hasEntitlement } from '@/features/entitlements/server'; -import { isServiceError } from './lib/utils'; -import { ServiceErrorException } from './lib/serviceError'; -import { createLogger } from "@sourcebot/logger"; +import { onCreateUser } from '@/lib/authUtils'; export const runtime = 'nodejs'; @@ -37,8 +33,6 @@ declare module 'next-auth/jwt' { } } -const logger = createLogger('web-auth'); - export const getProviders = () => { const providers: Provider[] = []; @@ -134,91 +128,6 @@ export const getProviders = () => { return providers; } -const onCreateUser = async ({ user }: { user: AuthJsUser }) => { - // In single-tenant mode, we assign the first user to sign - // up as the owner of the default org. - if ( - env.SOURCEBOT_TENANCY_MODE === 'single' - ) { - const defaultOrg = await prisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - include: { - members: { - where: { - role: { - not: OrgRole.GUEST, - } - } - }, - } - }); - - if (!defaultOrg) { - throw new Error("Default org not found on single tenant user creation"); - } - - // We can't use the getOrgMembers action here because we're not authed yet - const members = await prisma.userToOrg.findMany({ - where: { - orgId: SINGLE_TENANT_ORG_ID, - role: { - not: OrgRole.GUEST, - } - }, - }); - - // Only the first user to sign up will be an owner of the default org. - const isFirstUser = members.length === 0; - if (isFirstUser) { - await prisma.$transaction(async (tx) => { - await tx.org.update({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - data: { - members: { - create: { - role: OrgRole.OWNER, - user: { - connect: { - id: user.id, - } - } - } - } - } - }); - - await tx.user.update({ - where: { - id: user.id, - }, - data: { - pendingApproval: false, - } - }); - }); - } else { - // TODO(auth): handle multi tenant case - if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) { - const res = await handleJITProvisioning(user.id!, SINGLE_TENANT_ORG_DOMAIN); - if (isServiceError(res)) { - logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`); - throw new ServiceErrorException(res); - } - } else { - const res = await createAccountRequest(user.id!, SINGLE_TENANT_ORG_DOMAIN); - if (isServiceError(res)) { - logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`); - throw new ServiceErrorException(res); - } - } - } - } -} - export const { handlers, signIn, signOut, auth } = NextAuth({ secret: env.AUTH_SECRET, adapter: PrismaAdapter(prisma), diff --git a/packages/web/src/ee/sso/sso.tsx b/packages/web/src/ee/sso/sso.tsx index c3d80333f..4648b9e2a 100644 --- a/packages/web/src/ee/sso/sso.tsx +++ b/packages/web/src/ee/sso/sso.tsx @@ -12,7 +12,14 @@ import { OrgRole } from "@sourcebot/db"; import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@/features/entitlements/server"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; +import { OAuth2Client } from "google-auth-library"; import { sew } from "@/actions"; +import Credentials from "next-auth/providers/credentials"; +import type { User as AuthJsUser } from "next-auth"; +import { onCreateUser } from "@/lib/authUtils"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('web-sso'); export const getSSOProviders = (): Provider[] => { const providers: Provider[] = []; @@ -88,6 +95,78 @@ export const getSSOProviders = (): Provider[] => { })); } + if (env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) { + providers.push(Credentials({ + id: "gcp-iap", + name: "Google Cloud IAP", + credentials: {}, + authorize: async (credentials, req) => { + try { + const iapAssertion = req.headers?.get("x-goog-iap-jwt-assertion"); + if (!iapAssertion || typeof iapAssertion !== "string") { + logger.warn("No IAP assertion found in headers"); + return null; + } + + const oauth2Client = new OAuth2Client(); + const ticket = await oauth2Client.verifyIdToken({ + idToken: iapAssertion, + audience: env.AUTH_EE_GCP_IAP_AUDIENCE, + }); + + const payload = ticket.getPayload(); + if (!payload) { + logger.warn("Invalid IAP token payload"); + return null; + } + + const email = payload.email; + const name = payload.name || payload.email; + const image = payload.picture; + + if (!email) { + logger.warn("Missing email in IAP token"); + return null; + } + + const existingUser = await prisma.user.findUnique({ + where: { email } + }); + + if (!existingUser) { + const newUser = await prisma.user.create({ + data: { + email, + name, + image, + } + }); + + const authJsUser: AuthJsUser = { + id: newUser.id, + email: newUser.email, + name: newUser.name, + image: newUser.image, + }; + + await onCreateUser({ user: authJsUser }); + return authJsUser; + } else { + return { + id: existingUser.id, + email: existingUser.email, + name: existingUser.name, + image: existingUser.image, + }; + } + } catch (error) { + logger.error("Error verifying IAP token:", error); + return null; + } + }, + })); + } + return providers; } @@ -129,7 +208,7 @@ export const handleJITProvisioning = async (userId: string, domain: string): Pro }); if (userToOrg) { - console.warn(`JIT provisioning skipped for user ${userId} since they're already a member of org ${domain}`); + logger.warn(`JIT provisioning skipped for user ${userId} since they're already a member of org ${domain}`); return true; } diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index 60996de9d..bd4af8c8f 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -50,6 +50,9 @@ export const env = createEnv({ AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET: z.string().optional(), AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER: z.string().optional(), + AUTH_EE_GCP_IAP_ENABLED: booleanSchema.default('false'), + AUTH_EE_GCP_IAP_AUDIENCE: z.string().optional(), + DATA_CACHE_DIR: z.string(), // Email diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts new file mode 100644 index 000000000..62590b7c1 --- /dev/null +++ b/packages/web/src/lib/authUtils.ts @@ -0,0 +1,98 @@ +import type { User as AuthJsUser } from "next-auth"; +import { env } from "@/env.mjs"; +import { prisma } from "@/prisma"; +import { OrgRole } from "@sourcebot/db"; +import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { hasEntitlement } from "@/features/entitlements/server"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { createAccountRequest } from "@/actions"; +import { handleJITProvisioning } from "@/ee/sso/sso"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('web-auth-utils'); + +export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { + // In single-tenant mode, we assign the first user to sign + // up as the owner of the default org. + if ( + env.SOURCEBOT_TENANCY_MODE === 'single' + ) { + const defaultOrg = await prisma.org.findUnique({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + include: { + members: { + where: { + role: { + not: OrgRole.GUEST, + } + } + }, + } + }); + + if (!defaultOrg) { + throw new Error("Default org not found on single tenant user creation"); + } + + // We can't use the getOrgMembers action here because we're not authed yet + const members = await prisma.userToOrg.findMany({ + where: { + orgId: SINGLE_TENANT_ORG_ID, + role: { + not: OrgRole.GUEST, + } + }, + }); + + // Only the first user to sign up will be an owner of the default org. + const isFirstUser = members.length === 0; + if (isFirstUser) { + await prisma.$transaction(async (tx) => { + await tx.org.update({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + data: { + members: { + create: { + role: OrgRole.OWNER, + user: { + connect: { + id: user.id, + } + } + } + } + } + }); + + await tx.user.update({ + where: { + id: user.id, + }, + data: { + pendingApproval: false, + } + }); + }); + } else { + // TODO(auth): handle multi tenant case + if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) { + const res = await handleJITProvisioning(user.id!, SINGLE_TENANT_ORG_DOMAIN); + if (isServiceError(res)) { + logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`); + throw new ServiceErrorException(res); + } + } else { + const res = await createAccountRequest(user.id!, SINGLE_TENANT_ORG_DOMAIN); + if (isServiceError(res)) { + logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`); + throw new ServiceErrorException(res); + } + } + } + } +}; \ No newline at end of file diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index e8476db58..8d2205e60 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -6,6 +6,52 @@ import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants' export async function middleware(request: NextRequest) { const url = request.nextUrl.clone(); + // Check for GCP IAP headers and auto sign-in if present + if (env.AUTH_EE_GCP_IAP_ENABLED === 'true' && env.AUTH_EE_GCP_IAP_AUDIENCE) { + const iapAssertion = request.headers.get('x-goog-iap-jwt-assertion'); + const sessionToken = request.cookies.get('next-auth.session-token') || request.cookies.get('__Secure-next-auth.session-token'); + + // If IAP header exists but no session, auto sign-in + if (iapAssertion && !sessionToken && !url.pathname.startsWith('/api/auth')) { + try { + // Make server-side request to NextAuth gcp-iap credentials endpoint + const signInResponse = await fetch(new URL('/api/auth/signin/gcp-iap', request.url), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'x-goog-iap-jwt-assertion': iapAssertion, + 'X-Forwarded-For': request.headers.get('x-forwarded-for') || request.ip || '', + 'User-Agent': request.headers.get('user-agent') || '', + }, + body: new URLSearchParams({ + callbackUrl: request.url, + json: 'true' + }) + }); + + if (signInResponse.ok) { + const result = await signInResponse.json(); + + // If sign-in was successful and we got a redirect URL + if (result.url) { + const response = NextResponse.redirect(result.url); + + // Copy any Set-Cookie headers from the sign-in response + const setCookieHeader = signInResponse.headers.get('set-cookie'); + if (setCookieHeader) { + response.headers.set('set-cookie', setCookieHeader); + } + + return response; + } + } + } catch (error) { + console.error('Failed to auto sign-in with GCP IAP:', error); + // Fall through to normal flow if auto sign-in fails + } + } + } + if (env.SOURCEBOT_TENANCY_MODE !== 'single') { return NextResponse.next(); } diff --git a/yarn.lock b/yarn.lock index 80a79b2ad..b379f320c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6002,6 +6002,7 @@ __metadata: eslint-plugin-react: "npm:^7.35.0" eslint-plugin-react-hooks: "npm:^4.6.2" fuse.js: "npm:^7.0.0" + google-auth-library: "npm:^9.15.1" graphql: "npm:^16.9.0" http-status-codes: "npm:^2.3.0" input-otp: "npm:^1.4.2" @@ -7480,7 +7481,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.1": +"base64-js@npm:^1.3.0, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -7517,6 +7518,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^9.0.0": + version: 9.3.0 + resolution: "bignumber.js@npm:9.3.0" + checksum: 10c0/f54a79cd6fc98552ac0510c1cd9381650870ae443bdb20ba9b98e3548188d941506ac3c22a9f9c69b2cc60da9be5700e87d3f54d2825310a8b2ae999dfd6d99d + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.3.0 resolution: "binary-extensions@npm:2.3.0" @@ -7628,6 +7636,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:^1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10c0/fb2294e64d23c573d0dd1f1e7a466c3e978fe94a4e0f8183937912ca374619773bef8e2aceb854129d2efecbbc515bbd0cc78d2734a3e3031edb0888531bbc8e + languageName: node + linkType: hard + "buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" @@ -8821,6 +8836,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/ebfbf19d4b8be938f4dd4a83b8788385da353d63307ede301a9252f9f7f88672e76f2191618fd8edfc2f24679236064176fab0b78131b161ee73daa37125408c + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -9857,6 +9881,13 @@ __metadata: languageName: node linkType: hard +"extend@npm:^3.0.2": + version: 3.0.2 + resolution: "extend@npm:3.0.2" + checksum: 10c0/73bf6e27406e80aa3e85b0d1c4fd987261e628064e170ca781125c0b635a3dabad5e05adbf07595ea0cf1e6c5396cacb214af933da7cbaf24fe75ff14818e8f9 + languageName: node + linkType: hard + "fast-content-type-parse@npm:^2.0.0": version: 2.0.1 resolution: "fast-content-type-parse@npm:2.0.1" @@ -10199,6 +10230,30 @@ __metadata: languageName: node linkType: hard +"gaxios@npm:^6.0.0, gaxios@npm:^6.1.1": + version: 6.7.1 + resolution: "gaxios@npm:6.7.1" + dependencies: + extend: "npm:^3.0.2" + https-proxy-agent: "npm:^7.0.1" + is-stream: "npm:^2.0.0" + node-fetch: "npm:^2.6.9" + uuid: "npm:^9.0.1" + checksum: 10c0/53e92088470661c5bc493a1de29d05aff58b1f0009ec5e7903f730f892c3642a93e264e61904383741ccbab1ce6e519f12a985bba91e13527678b32ee6d7d3fd + languageName: node + linkType: hard + +"gcp-metadata@npm:^6.1.0": + version: 6.1.1 + resolution: "gcp-metadata@npm:6.1.1" + dependencies: + gaxios: "npm:^6.1.1" + google-logging-utils: "npm:^0.0.2" + json-bigint: "npm:^1.0.0" + checksum: 10c0/71f6ad4800aa622c246ceec3955014c0c78cdcfe025971f9558b9379f4019f5e65772763428ee8c3244fa81b8631977316eaa71a823493f82e5c44d7259ffac8 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -10440,6 +10495,27 @@ __metadata: languageName: node linkType: hard +"google-auth-library@npm:^9.15.1": + version: 9.15.1 + resolution: "google-auth-library@npm:9.15.1" + dependencies: + base64-js: "npm:^1.3.0" + ecdsa-sig-formatter: "npm:^1.0.11" + gaxios: "npm:^6.1.1" + gcp-metadata: "npm:^6.1.0" + gtoken: "npm:^7.0.0" + jws: "npm:^4.0.0" + checksum: 10c0/6eef36d9a9cb7decd11e920ee892579261c6390104b3b24d3e0f3889096673189fe2ed0ee43fd563710e2560de98e63ad5aa4967b91e7f4e69074a422d5f7b65 + languageName: node + linkType: hard + +"google-logging-utils@npm:^0.0.2": + version: 0.0.2 + resolution: "google-logging-utils@npm:0.0.2" + checksum: 10c0/9a4bbd470dd101c77405e450fffca8592d1d7114f245a121288d04a957aca08c9dea2dd1a871effe71e41540d1bb0494731a0b0f6fea4358e77f06645e4268c1 + languageName: node + linkType: hard + "gopd@npm:^1.0.1, gopd@npm:^1.2.0": version: 1.2.0 resolution: "gopd@npm:1.2.0" @@ -10483,6 +10559,16 @@ __metadata: languageName: node linkType: hard +"gtoken@npm:^7.0.0": + version: 7.1.0 + resolution: "gtoken@npm:7.1.0" + dependencies: + gaxios: "npm:^6.0.0" + jws: "npm:^4.0.0" + checksum: 10c0/0a3dcacb1a3c4578abe1ee01c7d0bf20bffe8ded3ee73fc58885d53c00f6eb43b4e1372ff179f0da3ed5cfebd5b7c6ab8ae2776f1787e90d943691b4fe57c716 + languageName: node + linkType: hard + "has-bigints@npm:^1.0.2": version: 1.1.0 resolution: "has-bigints@npm:1.1.0" @@ -11323,6 +11409,15 @@ __metadata: languageName: node linkType: hard +"json-bigint@npm:^1.0.0": + version: 1.0.0 + resolution: "json-bigint@npm:1.0.0" + dependencies: + bignumber.js: "npm:^9.0.0" + checksum: 10c0/e3f34e43be3284b573ea150a3890c92f06d54d8ded72894556357946aeed9877fd795f62f37fe16509af189fd314ab1104d0fd0f163746ad231b9f378f5b33f4 + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -11431,6 +11526,27 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^2.0.0": + version: 2.0.1 + resolution: "jwa@npm:2.0.1" + dependencies: + buffer-equal-constant-time: "npm:^1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/ab3ebc6598e10dc11419d4ed675c9ca714a387481466b10e8a6f3f65d8d9c9237e2826f2505280a739cf4cbcf511cb288eeec22b5c9c63286fc5a2e4f97e78cf + languageName: node + linkType: hard + +"jws@npm:^4.0.0": + version: 4.0.0 + resolution: "jws@npm:4.0.0" + dependencies: + jwa: "npm:^2.0.0" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/f1ca77ea5451e8dc5ee219cb7053b8a4f1254a79cb22417a2e1043c1eb8a569ae118c68f24d72a589e8a3dd1824697f47d6bd4fb4bebb93a3bdf53545e721661 + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -12404,7 +12520,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.7, node-fetch@npm:^2.7.0": +"node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9, node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -14359,7 +14475,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -16025,7 +16141,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^9.0.0": +"uuid@npm:^9.0.0, uuid@npm:^9.0.1": version: 9.0.1 resolution: "uuid@npm:9.0.1" bin: From 497ec0f6e157bbb5a94e48b9cbef16b4f3475bb3 Mon Sep 17 00:00:00 2001 From: msukkari Date: Wed, 4 Jun 2025 01:55:09 +0000 Subject: [PATCH 2/5] gcp iap working --- .../app/[domain]/components/gcpIapAuth.tsx | 24 ++++++++++ packages/web/src/app/[domain]/layout.tsx | 8 +++- packages/web/src/ee/sso/sso.tsx | 12 +++-- packages/web/src/middleware.ts | 46 ------------------- 4 files changed, 39 insertions(+), 51 deletions(-) create mode 100644 packages/web/src/app/[domain]/components/gcpIapAuth.tsx diff --git a/packages/web/src/app/[domain]/components/gcpIapAuth.tsx b/packages/web/src/app/[domain]/components/gcpIapAuth.tsx new file mode 100644 index 000000000..b13ccc7f8 --- /dev/null +++ b/packages/web/src/app/[domain]/components/gcpIapAuth.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { signIn } from "next-auth/react"; +import { useEffect } from "react"; + +interface GcpIapAuthProps { + callbackUrl?: string; +} + +export const GcpIapAuth = ({ callbackUrl }: GcpIapAuthProps) => { + useEffect(() => { + signIn("gcp-iap", { + redirectTo: callbackUrl ?? "/" + }); + }, [callbackUrl]); + + return ( +
+
+

Signing in with Google Cloud IAP...

+
+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 8b1122a38..1bb708d84 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -17,6 +17,7 @@ import { PendingApprovalCard } from "./components/pendingApproval"; import { hasEntitlement } from "@/features/entitlements/server"; import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess"; import { env } from "@/env.mjs"; +import { GcpIapAuth } from "./components/gcpIapAuth"; interface LayoutProps { children: React.ReactNode, @@ -37,7 +38,12 @@ export default async function Layout({ if (!publicAccessEnabled) { const session = await auth(); if (!session) { - redirect('/login'); + const ssoEntitlement = await hasEntitlement("sso"); + if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) { + return ; + } else { + redirect('/login'); + } } const membership = await prisma.userToOrg.findUnique({ diff --git a/packages/web/src/ee/sso/sso.tsx b/packages/web/src/ee/sso/sso.tsx index 4648b9e2a..bb1ec7b95 100644 --- a/packages/web/src/ee/sso/sso.tsx +++ b/packages/web/src/ee/sso/sso.tsx @@ -109,10 +109,14 @@ export const getSSOProviders = (): Provider[] => { } const oauth2Client = new OAuth2Client(); - const ticket = await oauth2Client.verifyIdToken({ - idToken: iapAssertion, - audience: env.AUTH_EE_GCP_IAP_AUDIENCE, - }); + + const { pubkeys } = await oauth2Client.getIapPublicKeys(); + const ticket = await oauth2Client.verifySignedJwtWithCertsAsync( + iapAssertion, + pubkeys, + env.AUTH_EE_GCP_IAP_AUDIENCE, + ['https://cloud.google.com/iap'] + ); const payload = ticket.getPayload(); if (!payload) { diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index 8d2205e60..e8476db58 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -6,52 +6,6 @@ import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants' export async function middleware(request: NextRequest) { const url = request.nextUrl.clone(); - // Check for GCP IAP headers and auto sign-in if present - if (env.AUTH_EE_GCP_IAP_ENABLED === 'true' && env.AUTH_EE_GCP_IAP_AUDIENCE) { - const iapAssertion = request.headers.get('x-goog-iap-jwt-assertion'); - const sessionToken = request.cookies.get('next-auth.session-token') || request.cookies.get('__Secure-next-auth.session-token'); - - // If IAP header exists but no session, auto sign-in - if (iapAssertion && !sessionToken && !url.pathname.startsWith('/api/auth')) { - try { - // Make server-side request to NextAuth gcp-iap credentials endpoint - const signInResponse = await fetch(new URL('/api/auth/signin/gcp-iap', request.url), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'x-goog-iap-jwt-assertion': iapAssertion, - 'X-Forwarded-For': request.headers.get('x-forwarded-for') || request.ip || '', - 'User-Agent': request.headers.get('user-agent') || '', - }, - body: new URLSearchParams({ - callbackUrl: request.url, - json: 'true' - }) - }); - - if (signInResponse.ok) { - const result = await signInResponse.json(); - - // If sign-in was successful and we got a redirect URL - if (result.url) { - const response = NextResponse.redirect(result.url); - - // Copy any Set-Cookie headers from the sign-in response - const setCookieHeader = signInResponse.headers.get('set-cookie'); - if (setCookieHeader) { - response.headers.set('set-cookie', setCookieHeader); - } - - return response; - } - } - } catch (error) { - console.error('Failed to auto sign-in with GCP IAP:', error); - // Fall through to normal flow if auto sign-in fails - } - } - } - if (env.SOURCEBOT_TENANCY_MODE !== 'single') { return NextResponse.next(); } From f2282dc38c52c01ef4ed47c6e54c798fa71c0377 Mon Sep 17 00:00:00 2001 From: msukkari Date: Tue, 3 Jun 2025 19:06:44 -0700 Subject: [PATCH 3/5] add docs for gcp iap --- docs/docs/configuration/auth/overview.mdx | 10 ++++++++++ docs/docs/configuration/environment-variables.mdx | 2 ++ 2 files changed, 12 insertions(+) diff --git a/docs/docs/configuration/auth/overview.mdx b/docs/docs/configuration/auth/overview.mdx index c89299ba1..19343c8dc 100644 --- a/docs/docs/configuration/auth/overview.mdx +++ b/docs/docs/configuration/auth/overview.mdx @@ -80,6 +80,16 @@ Optional environment variables: - `AUTH_EE_GOOGLE_CLIENT_ID` - `AUTH_EE_GOOGLE_CLIENT_SECRET` +### GCP IAP +--- + +Custom provider built to enable automatic Sourcebot account registration/login when using GCP IAP. + +**Required environment variables** +- `AUTH_EE_GCP_IAP_ENABLED` +- `AUTH_EE_GCP_IAP_AUDIENCE` + - This can be found by selecting the ⋮ icon next to the IAP-enabled backend service and pressing `Get JWT audience code` + ### Okta --- diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 199a214d6..f59b3fe05 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -51,6 +51,8 @@ The following environment variables allow you to configure your Sourcebot deploy | `AUTH_EE_OKTA_CLIENT_ID` | `-` |

The client ID for Okta SSO authentication.

| | `AUTH_EE_OKTA_CLIENT_SECRET` | `-` |

The client secret for Okta SSO authentication.

| | `AUTH_EE_OKTA_ISSUER` | `-` |

The issuer URL for Okta SSO authentication.

| +| `AUTH_EE_GCP_IAP_ENABLED` | `false` |

When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect

| +| `AUTH_EE_GCP_IAP_AUDIENCE` | - |

The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning

| ### Review Agent Environment Variables From 3c5d6bb7ac4a58994db11e10a127429b2993d5df Mon Sep 17 00:00:00 2001 From: msukkari Date: Wed, 4 Jun 2025 02:19:44 +0000 Subject: [PATCH 4/5] feedback --- .../web/src/app/[domain]/components/gcpIapAuth.tsx | 2 ++ packages/web/src/lib/authUtils.ts | 12 +----------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/web/src/app/[domain]/components/gcpIapAuth.tsx b/packages/web/src/app/[domain]/components/gcpIapAuth.tsx index b13ccc7f8..292161d66 100644 --- a/packages/web/src/app/[domain]/components/gcpIapAuth.tsx +++ b/packages/web/src/app/[domain]/components/gcpIapAuth.tsx @@ -11,6 +11,8 @@ export const GcpIapAuth = ({ callbackUrl }: GcpIapAuthProps) => { useEffect(() => { signIn("gcp-iap", { redirectTo: callbackUrl ?? "/" + }).catch((error) => { + console.error("Error signing in with GCP IAP:", error); }); }, [callbackUrl]); diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index 62590b7c1..6b7df3ebb 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -37,18 +37,8 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { throw new Error("Default org not found on single tenant user creation"); } - // We can't use the getOrgMembers action here because we're not authed yet - const members = await prisma.userToOrg.findMany({ - where: { - orgId: SINGLE_TENANT_ORG_ID, - role: { - not: OrgRole.GUEST, - } - }, - }); - // Only the first user to sign up will be an owner of the default org. - const isFirstUser = members.length === 0; + const isFirstUser = defaultOrg.members.length === 0; if (isFirstUser) { await prisma.$transaction(async (tx) => { await tx.org.update({ From 8e89188704683540f8c48dae4a516ecb86d74fb7 Mon Sep 17 00:00:00 2001 From: msukkari Date: Tue, 3 Jun 2025 19:28:11 -0700 Subject: [PATCH 5/5] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e9dd28a..0c0660bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added copy button for filenames. [#328](https://github.com/sourcebot-dev/sourcebot/pull/328) - Added development docker compose file. [#328](https://github.com/sourcebot-dev/sourcebot/pull/328) +- Added GCP IAP JIT provisioning. [#330](https://github.com/sourcebot-dev/sourcebot/pull/330) ### Fixed - Fixed issue with the symbol hover popover clipping at the top of the page. [#326](https://github.com/sourcebot-dev/sourcebot/pull/326)