Skip to content

Commit 9227b3c

Browse files
authored
Add support for GCP IAP JIT account provisioning (#330)
* initial gcp iap implementation * gcp iap working * add docs for gcp iap * feedback * changelog
1 parent d5c4486 commit 9227b3c

File tree

11 files changed

+345
-100
lines changed

11 files changed

+345
-100
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- Added copy button for filenames. [#328](https://github.com/sourcebot-dev/sourcebot/pull/328)
1212
- Added development docker compose file. [#328](https://github.com/sourcebot-dev/sourcebot/pull/328)
13+
- Added GCP IAP JIT provisioning. [#330](https://github.com/sourcebot-dev/sourcebot/pull/330)
1314

1415
### Fixed
1516
- Fixed issue with the symbol hover popover clipping at the top of the page. [#326](https://github.com/sourcebot-dev/sourcebot/pull/326)

docs/docs/configuration/auth/overview.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ Optional environment variables:
8080
- `AUTH_EE_GOOGLE_CLIENT_ID`
8181
- `AUTH_EE_GOOGLE_CLIENT_SECRET`
8282

83+
### GCP IAP
84+
---
85+
86+
Custom provider built to enable automatic Sourcebot account registration/login when using GCP IAP.
87+
88+
**Required environment variables**
89+
- `AUTH_EE_GCP_IAP_ENABLED`
90+
- `AUTH_EE_GCP_IAP_AUDIENCE`
91+
- This can be found by selecting the ⋮ icon next to the IAP-enabled backend service and pressing `Get JWT audience code`
92+
8393
### Okta
8494
---
8595

docs/docs/configuration/environment-variables.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ The following environment variables allow you to configure your Sourcebot deploy
5151
| `AUTH_EE_OKTA_CLIENT_ID` | `-` | <p>The client ID for Okta SSO authentication.</p> |
5252
| `AUTH_EE_OKTA_CLIENT_SECRET` | `-` | <p>The client secret for Okta SSO authentication.</p> |
5353
| `AUTH_EE_OKTA_ISSUER` | `-` | <p>The issuer URL for Okta SSO authentication.</p> |
54+
| `AUTH_EE_GCP_IAP_ENABLED` | `false` | <p>When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect</p> |
55+
| `AUTH_EE_GCP_IAP_AUDIENCE` | - | <p>The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning</p> |
5456

5557

5658
### Review Agent Environment Variables

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"embla-carousel-react": "^8.3.0",
115115
"escape-string-regexp": "^5.0.0",
116116
"fuse.js": "^7.0.0",
117+
"google-auth-library": "^9.15.1",
117118
"graphql": "^16.9.0",
118119
"http-status-codes": "^2.3.0",
119120
"input-otp": "^1.4.2",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client';
2+
3+
import { signIn } from "next-auth/react";
4+
import { useEffect } from "react";
5+
6+
interface GcpIapAuthProps {
7+
callbackUrl?: string;
8+
}
9+
10+
export const GcpIapAuth = ({ callbackUrl }: GcpIapAuthProps) => {
11+
useEffect(() => {
12+
signIn("gcp-iap", {
13+
redirectTo: callbackUrl ?? "/"
14+
}).catch((error) => {
15+
console.error("Error signing in with GCP IAP:", error);
16+
});
17+
}, [callbackUrl]);
18+
19+
return (
20+
<div className="flex items-center justify-center min-h-screen">
21+
<div className="text-center">
22+
<p className="text-lg">Signing in with Google Cloud IAP...</p>
23+
</div>
24+
</div>
25+
);
26+
};

packages/web/src/app/[domain]/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { PendingApprovalCard } from "./components/pendingApproval";
1717
import { hasEntitlement } from "@/features/entitlements/server";
1818
import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess";
1919
import { env } from "@/env.mjs";
20+
import { GcpIapAuth } from "./components/gcpIapAuth";
2021

2122
interface LayoutProps {
2223
children: React.ReactNode,
@@ -37,7 +38,12 @@ export default async function Layout({
3738
if (!publicAccessEnabled) {
3839
const session = await auth();
3940
if (!session) {
40-
redirect('/login');
41+
const ssoEntitlement = await hasEntitlement("sso");
42+
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
43+
return <GcpIapAuth callbackUrl={`/${domain}`} />;
44+
} else {
45+
redirect('/login');
46+
}
4147
}
4248

4349
const membership = await prisma.userToOrg.findUnique({

packages/web/src/auth.ts

Lines changed: 3 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,17 @@ import EmailProvider from "next-auth/providers/nodemailer";
55
import { PrismaAdapter } from "@auth/prisma-adapter"
66
import { prisma } from "@/prisma";
77
import { env } from "@/env.mjs";
8-
import { OrgRole, User } from '@sourcebot/db';
8+
import { User } from '@sourcebot/db';
99
import 'next-auth/jwt';
1010
import type { Provider } from "next-auth/providers";
1111
import { verifyCredentialsRequestSchema } from './lib/schemas';
1212
import { createTransport } from 'nodemailer';
1313
import { render } from '@react-email/render';
1414
import MagicLinkEmail from './emails/magicLinkEmail';
15-
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from './lib/constants';
1615
import bcrypt from 'bcryptjs';
17-
import { createAccountRequest } from './actions';
18-
import { getSSOProviders, handleJITProvisioning } from '@/ee/sso/sso';
16+
import { getSSOProviders } from '@/ee/sso/sso';
1917
import { hasEntitlement } from '@/features/entitlements/server';
20-
import { isServiceError } from './lib/utils';
21-
import { ServiceErrorException } from './lib/serviceError';
22-
import { createLogger } from "@sourcebot/logger";
18+
import { onCreateUser } from '@/lib/authUtils';
2319

2420
export const runtime = 'nodejs';
2521

@@ -37,8 +33,6 @@ declare module 'next-auth/jwt' {
3733
}
3834
}
3935

40-
const logger = createLogger('web-auth');
41-
4236
export const getProviders = () => {
4337
const providers: Provider[] = [];
4438

@@ -134,91 +128,6 @@ export const getProviders = () => {
134128
return providers;
135129
}
136130

137-
const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
138-
// In single-tenant mode, we assign the first user to sign
139-
// up as the owner of the default org.
140-
if (
141-
env.SOURCEBOT_TENANCY_MODE === 'single'
142-
) {
143-
const defaultOrg = await prisma.org.findUnique({
144-
where: {
145-
id: SINGLE_TENANT_ORG_ID,
146-
},
147-
include: {
148-
members: {
149-
where: {
150-
role: {
151-
not: OrgRole.GUEST,
152-
}
153-
}
154-
},
155-
}
156-
});
157-
158-
if (!defaultOrg) {
159-
throw new Error("Default org not found on single tenant user creation");
160-
}
161-
162-
// We can't use the getOrgMembers action here because we're not authed yet
163-
const members = await prisma.userToOrg.findMany({
164-
where: {
165-
orgId: SINGLE_TENANT_ORG_ID,
166-
role: {
167-
not: OrgRole.GUEST,
168-
}
169-
},
170-
});
171-
172-
// Only the first user to sign up will be an owner of the default org.
173-
const isFirstUser = members.length === 0;
174-
if (isFirstUser) {
175-
await prisma.$transaction(async (tx) => {
176-
await tx.org.update({
177-
where: {
178-
id: SINGLE_TENANT_ORG_ID,
179-
},
180-
data: {
181-
members: {
182-
create: {
183-
role: OrgRole.OWNER,
184-
user: {
185-
connect: {
186-
id: user.id,
187-
}
188-
}
189-
}
190-
}
191-
}
192-
});
193-
194-
await tx.user.update({
195-
where: {
196-
id: user.id,
197-
},
198-
data: {
199-
pendingApproval: false,
200-
}
201-
});
202-
});
203-
} else {
204-
// TODO(auth): handle multi tenant case
205-
if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) {
206-
const res = await handleJITProvisioning(user.id!, SINGLE_TENANT_ORG_DOMAIN);
207-
if (isServiceError(res)) {
208-
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
209-
throw new ServiceErrorException(res);
210-
}
211-
} else {
212-
const res = await createAccountRequest(user.id!, SINGLE_TENANT_ORG_DOMAIN);
213-
if (isServiceError(res)) {
214-
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
215-
throw new ServiceErrorException(res);
216-
}
217-
}
218-
}
219-
}
220-
}
221-
222131
export const { handlers, signIn, signOut, auth } = NextAuth({
223132
secret: env.AUTH_SECRET,
224133
adapter: PrismaAdapter(prisma),

packages/web/src/ee/sso/sso.tsx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ import { OrgRole } from "@sourcebot/db";
1212
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@/features/entitlements/server";
1313
import { StatusCodes } from "http-status-codes";
1414
import { ErrorCode } from "@/lib/errorCodes";
15+
import { OAuth2Client } from "google-auth-library";
1516
import { sew } from "@/actions";
17+
import Credentials from "next-auth/providers/credentials";
18+
import type { User as AuthJsUser } from "next-auth";
19+
import { onCreateUser } from "@/lib/authUtils";
20+
import { createLogger } from "@sourcebot/logger";
21+
22+
const logger = createLogger('web-sso');
1623

1724
export const getSSOProviders = (): Provider[] => {
1825
const providers: Provider[] = [];
@@ -88,6 +95,82 @@ export const getSSOProviders = (): Provider[] => {
8895
}));
8996
}
9097

98+
if (env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
99+
providers.push(Credentials({
100+
id: "gcp-iap",
101+
name: "Google Cloud IAP",
102+
credentials: {},
103+
authorize: async (credentials, req) => {
104+
try {
105+
const iapAssertion = req.headers?.get("x-goog-iap-jwt-assertion");
106+
if (!iapAssertion || typeof iapAssertion !== "string") {
107+
logger.warn("No IAP assertion found in headers");
108+
return null;
109+
}
110+
111+
const oauth2Client = new OAuth2Client();
112+
113+
const { pubkeys } = await oauth2Client.getIapPublicKeys();
114+
const ticket = await oauth2Client.verifySignedJwtWithCertsAsync(
115+
iapAssertion,
116+
pubkeys,
117+
env.AUTH_EE_GCP_IAP_AUDIENCE,
118+
['https://cloud.google.com/iap']
119+
);
120+
121+
const payload = ticket.getPayload();
122+
if (!payload) {
123+
logger.warn("Invalid IAP token payload");
124+
return null;
125+
}
126+
127+
const email = payload.email;
128+
const name = payload.name || payload.email;
129+
const image = payload.picture;
130+
131+
if (!email) {
132+
logger.warn("Missing email in IAP token");
133+
return null;
134+
}
135+
136+
const existingUser = await prisma.user.findUnique({
137+
where: { email }
138+
});
139+
140+
if (!existingUser) {
141+
const newUser = await prisma.user.create({
142+
data: {
143+
email,
144+
name,
145+
image,
146+
}
147+
});
148+
149+
const authJsUser: AuthJsUser = {
150+
id: newUser.id,
151+
email: newUser.email,
152+
name: newUser.name,
153+
image: newUser.image,
154+
};
155+
156+
await onCreateUser({ user: authJsUser });
157+
return authJsUser;
158+
} else {
159+
return {
160+
id: existingUser.id,
161+
email: existingUser.email,
162+
name: existingUser.name,
163+
image: existingUser.image,
164+
};
165+
}
166+
} catch (error) {
167+
logger.error("Error verifying IAP token:", error);
168+
return null;
169+
}
170+
},
171+
}));
172+
}
173+
91174
return providers;
92175
}
93176

@@ -129,7 +212,7 @@ export const handleJITProvisioning = async (userId: string, domain: string): Pro
129212
});
130213

131214
if (userToOrg) {
132-
console.warn(`JIT provisioning skipped for user ${userId} since they're already a member of org ${domain}`);
215+
logger.warn(`JIT provisioning skipped for user ${userId} since they're already a member of org ${domain}`);
133216
return true;
134217
}
135218

packages/web/src/env.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const env = createEnv({
5050
AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET: z.string().optional(),
5151
AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER: z.string().optional(),
5252

53+
AUTH_EE_GCP_IAP_ENABLED: booleanSchema.default('false'),
54+
AUTH_EE_GCP_IAP_AUDIENCE: z.string().optional(),
55+
5356
DATA_CACHE_DIR: z.string(),
5457

5558
// Email

0 commit comments

Comments
 (0)