Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 37 additions & 34 deletions apps/api/src/app/controllers/team.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ENV, getLogger } from '@jetstream/api-config';
import { AuditLogAction, AuditLogResource, createTeamAuditLog } from '@jetstream/audit-logs';
import * as authService from '@jetstream/auth/server';
import { encryptSecret, oidcService, samlService } from '@jetstream/auth/server';
import { DiscoveredOidcConfig, encryptSecret, oidcService, samlService } from '@jetstream/auth/server';
import { OidcConfigurationRequestSchema, SamlConfigurationRequestSchema } from '@jetstream/auth/types';
import { BLOCKED_PUBLIC_EMAIL_DOMAINS } from '@jetstream/shared/constants';
import { assertDomainResolvesToPublicIp, fetchWithPinnedPublicIp } from '@jetstream/shared/node-utils';
Expand All @@ -24,7 +24,7 @@ import { unparse } from 'papaparse';
import { z } from 'zod';
import * as teamDb from '../db/team.db';
import * as teamService from '../services/team.service';
import { NotAllowedError, NotFoundError } from '../utils/error-handler';
import { NotAllowedError, NotFoundError, UserFacingError } from '../utils/error-handler';
import { sendJson } from '../utils/response.handlers';
import { createRoute, RouteValidator } from '../utils/route.utils';

Expand Down Expand Up @@ -339,15 +339,6 @@ export const routeDefinition = {
body: SamlConfigurationRequestSchema,
} satisfies RouteValidator,
},
discoverOidcConfig: {
controllerFn: () => discoverOidcConfig,
responseType: z.any(),
validators: {
hasSourceOrg: false,
params: z.object({ teamId: z.uuid() }),
body: z.object({ issuer: z.url() }),
} satisfies RouteValidator,
},
createOrUpdateOidcConfig: {
controllerFn: () => createOrUpdateOidcConfig,
responseType: z.any(),
Expand Down Expand Up @@ -940,23 +931,6 @@ const createOrUpdateSamlConfig = createRoute(
},
);

const discoverOidcConfig = createRoute(routeDefinition.discoverOidcConfig.validators, async ({ body }, _, res, next) => {
try {
const { issuer } = body;
// SSRF protection: resolve hostname and reject private/internal IPs. Skipped in dev/CI
// (USE_SECURE_COOKIES=false) so local IdPs are reachable — matches the runtime discovery
// gate in libs/auth/server/src/lib/oidc.service.ts and the SAML save-time fetch above.
if (ENV.USE_SECURE_COOKIES) {
await assertDomainResolvesToPublicIp(issuer);
}
const discovered = await oidcService.getDiscoveredConfigForSaving(issuer);
sendJson(res, discovered);
} catch (ex) {
getLogger().error(getErrorMessageAndStackObj(ex), 'Failed to discover OIDC configuration');
next(ex);
}
});

const createOrUpdateOidcConfig = createRoute(
routeDefinition.createOrUpdateOidcConfig.validators,
async ({ params, body, user }, req, res, next) => {
Expand All @@ -965,9 +939,11 @@ const createOrUpdateOidcConfig = createRoute(

const hasVerifiedDomain = await teamDb.hasVerifiedDomain(teamId);
if (!hasVerifiedDomain) {
throw new Error('At least one verified domain is required to configure SSO.');
throw new UserFacingError('At least one verified domain is required to configure SSO.');
}

// Resolve the client secret first (cheap, local) so a request missing it fails fast — before we
// make an outbound OIDC discovery request to a client-controlled issuer URL.
const existingConfig = await teamDb.getSsoConfiguration(teamId);
let encryptedSecret = '';

Expand All @@ -978,7 +954,28 @@ const createOrUpdateOidcConfig = createRoute(
// Use existing client secret
encryptedSecret = existingConfig.oidcConfiguration.clientSecret;
} else {
throw new Error('Client Secret is required');
throw new UserFacingError('Client Secret is required');
}

// Resolve the IdP endpoints from the issuer's OIDC discovery document instead of trusting client
// input. Login resolves endpoints via oidcService.discoverOidcConfiguration (a cached lookup, 1h
// TTL), not the stored columns, so the persisted endpoints are only an informational snapshot —
// deriving them here keeps them consistent with the issuer and lets an admin configure SSO with
// just the issuer + client credentials. SSRF protection: resolve + reject private IPs in prod.
Comment thread
paustint marked this conversation as resolved.
if (ENV.USE_SECURE_COOKIES) {
await assertDomainResolvesToPublicIp(body.issuer);
}
let discovered: DiscoveredOidcConfig;
try {
discovered = await oidcService.getDiscoveredConfigForSaving(body.issuer);
} catch (ex) {
getLogger().warn({ err: ex, teamId }, 'OIDC discovery failed while saving SSO configuration');
throw new UserFacingError(
`We couldn't read the OIDC discovery document from that Issuer URL. Confirm the issuer is correct and that it publishes a discovery document at ${body.issuer.replace(
/\/$/,
'',
)}/.well-known/openid-configuration.`,
);
}

const {
Expand All @@ -988,6 +985,12 @@ const createOrUpdateOidcConfig = createRoute(
} = await teamDb.createOrUpdateOidcConfiguration(teamId, user.id, {
...body,
clientSecret: encryptedSecret,
// Endpoints are authoritative from discovery, never client input.
authorizationEndpoint: discovered.authorizationEndpoint,
tokenEndpoint: discovered.tokenEndpoint,
userinfoEndpoint: discovered.userinfoEndpoint ?? null,
jwksUri: discovered.jwksUri,
endSessionEndpoint: discovered.endSessionEndpoint ?? null,
});

sendJson(res, maskSsoSecrets(config));
Expand All @@ -1014,10 +1017,10 @@ const createOrUpdateOidcConfig = createRoute(
if (previousOidcConfig.issuer !== body.issuer) changes.issuer = { from: previousOidcConfig.issuer, to: body.issuer };
if (previousOidcConfig.clientId !== body.clientId) changes.clientId = { from: previousOidcConfig.clientId, to: body.clientId };
if (body.clientSecret) changes.clientSecretUpdated = true;
if (previousOidcConfig.authorizationEndpoint !== body.authorizationEndpoint)
changes.authorizationEndpoint = { from: previousOidcConfig.authorizationEndpoint, to: body.authorizationEndpoint };
if (previousOidcConfig.tokenEndpoint !== body.tokenEndpoint)
changes.tokenEndpoint = { from: previousOidcConfig.tokenEndpoint, to: body.tokenEndpoint };
if (previousOidcConfig.authorizationEndpoint !== discovered.authorizationEndpoint)
changes.authorizationEndpoint = { from: previousOidcConfig.authorizationEndpoint, to: discovered.authorizationEndpoint };
if (previousOidcConfig.tokenEndpoint !== discovered.tokenEndpoint)
changes.tokenEndpoint = { from: previousOidcConfig.tokenEndpoint, to: discovered.tokenEndpoint };
if ([...(previousOidcConfig.scopes ?? [])].sort().join(',') !== [...(body.scopes ?? [])].sort().join(','))
changes.scopes = { from: previousOidcConfig.scopes, to: body.scopes };
if (JSON.stringify(previousOidcConfig.attributeMapping) !== JSON.stringify(body.attributeMapping))
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/app/db/team.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,7 +1286,13 @@ export async function createOrUpdateSamlConfiguration(teamId: string, userId: st
return { isNew, previous: previousSamlConfig, result: await getSsoConfiguration(teamId) };
}

export async function createOrUpdateOidcConfiguration(teamId: string, userId: string, data: OidcConfigurationRequest) {
export async function createOrUpdateOidcConfiguration(
teamId: string,
userId: string,
// The IdP endpoints are non-nullable columns resolved from OIDC discovery by the controller, so
// they are required here even though they are optional on the wire request.
Comment on lines +1292 to +1293
data: OidcConfigurationRequest & { authorizationEndpoint: string; tokenEndpoint: string; jwksUri: string },
) {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
Expand Down
6 changes: 0 additions & 6 deletions apps/api/src/app/routes/team.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,6 @@ routes.delete(
);

// OIDC Configuration
routes.post(
'/:teamId/sso/oidc/discover',
validateTeamRoleMiddlewareFn([TEAM_MEMBER_ROLE_ADMIN]),
teamController.discoverOidcConfig.controllerFn(),
);

routes.post(
'/:teamId/sso/oidc/config',
validateTeamRoleMiddlewareFn([TEAM_MEMBER_ROLE_ADMIN]),
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/docs/team-management/sso/sso-azure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Copy this value as you will need it to configure the connection in Jetstream.

#### Configure Jetstream Connection

Enter `https://login.microsoftonline.com/{tenantId}/v2.0` as the Issuer URL and click "Auto-Discover Endpoints".
Enter `https://login.microsoftonline.com/{tenantId}/v2.0` as the Issuer URL. Jetstream discovers the authorization, token, and JWKS endpoints from your issuer automatically when you save.

Your tenant ID can be found in the app registration overview page.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/docs/team-management/sso/sso-generic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ https://{yourIdpDomain}/.well-known/openid-configuration

### Configure Jetstream Connection

Enter the Issuer URL and click "Auto-Discover Endpoints". This will automatically populate the necessary endpoints and configuration for your identity provider.
Enter the Issuer URL. Jetstream automatically discovers the necessary endpoints and configuration for your identity provider from the issuer's discovery document when you save.

Copy the **Client ID** and **Client Secret** obtained from your identity provider into the corresponding fields in the Team Dashboard when adding the SSO provider.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/docs/team-management/sso/sso-google.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ You can set up Single Sign-On (SSO) for your Jetstream team using Google as an *

Choose "Add SSO Provider" from your Team Dashboard and select "OIDC" as the provider.

Enter `https://accounts.google.com` as the Issuer URL and click "Auto-Discover Endpoints".
Enter `https://accounts.google.com` as the Issuer URL. Jetstream discovers the authorization, token, and JWKS endpoints from your issuer automatically when you save.

<img src={require('./images/sso-google-config-modal.png').default} alt="Add SSO Provider Modal" />

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/docs/team-management/sso/sso-okta.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ It is recommended that you enable the **Require PKCE as additional verification*

### Configure Jetstream Connection

Enter the Issuer URL in the format `https://{yourOktaDomain}.okta.com` and click "Auto-Discover Endpoints".
Enter the Issuer URL in the format `https://{yourOktaDomain}.okta.com`. Jetstream discovers the authorization, token, and JWKS endpoints from your issuer automatically when you save.

:::tip

Expand Down Expand Up @@ -120,7 +120,7 @@ Click **Next** and then **Finish** to create the application.

### Configure Jetstream Connection

Copy the Metadata URL from Okta and paste it into Jetstream in the **IdP Metadata URL** field and press Auto-discover. This will automatically populate the IdP SSO URL and IdP Certificate fields.
Copy the Metadata URL from Okta and paste it into Jetstream in the **Metadata URL** field, then click "Fetch Metadata". This will automatically populate the IdP SSO URL and IdP Certificate fields.

Alternatively, you can visit the URL in your browser and copy the contents of the XML file and paste it into the **IdP Metadata XML** field in Jetstream.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { prisma } from '@jetstream/api-config';
import { HTTP } from '@jetstream/shared/constants';
import { randomUUID } from 'crypto';
import { createServer, Server } from 'http';
import { expect, test } from '../../../../fixtures/fixtures';

// Fresh signup per test (mirrors team.spec.ts) so the fixture's admin owns the page session.
test.use({ storageState: { cookies: [], origins: [] } });

// Distinct port from sso-oidc-happy.api.spec.ts (5555) so both mock IdPs can run in parallel workers.
const ISSUER = 'http://127.0.0.1:5556';

/**
* Minimal mock IdP that serves only the OIDC discovery document. The config-save flow resolves and
* snapshots the endpoints from discovery; it does not call token/jwks/userinfo at save time.
*/
function startMockOidcServer(): Server {
const server = createServer((req, res) => {
if (req.url?.startsWith('/.well-known/openid-configuration')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/auth`,
token_endpoint: `${ISSUER}/token`,
userinfo_endpoint: `${ISSUER}/userinfo`,
jwks_uri: `${ISSUER}/jwks`,
}),
);
return;
}
res.writeHead(404);
res.end();
});
server.listen(5556);
return server;
}

test.describe('OIDC SSO configuration save', () => {
let mockServer: Server;
let createdLoginConfigId: string | undefined;

test.beforeAll(() => {
mockServer = startMockOidcServer();
});

test.afterEach(async () => {
// The OIDC config hangs off the team's login config, which the fixture's team-only cleanup does
// not delete, so remove it explicitly to avoid leaking rows between runs.
if (createdLoginConfigId) {
await prisma.oidcConfiguration.deleteMany({ where: { loginConfigId: createdLoginConfigId } }).catch(() => undefined);
createdLoginConfigId = undefined;
}
});

test.afterAll(() => {
mockServer?.close();
});

test('derives OIDC endpoints from the issuer when the client omits them', async ({ page, teamCreationUtils1User: teamCreationUtils }) => {
const { team } = teamCreationUtils;
const loginConfigId = team.loginConfig.id;
createdLoginConfigId = loginConfigId;

await test.step('Seed a verified domain (configuring SSO requires one)', async () => {
await prisma.domainVerification.create({
data: {
teamId: team.id,
domain: `oidc-${randomUUID().slice(0, 8)}.test`,
status: 'VERIFIED',
verificationCode: `jetstream-verification=${randomUUID()}`,
verifiedAt: new Date(),
},
});
});

// The /api/teams routes enforce HMAC double-submit CSRF: echo the `jetstream-csrf` cookie set at
// login back in the X-Csrf-Token header (the app's axios interceptor does this in the real client).
// The /api/auth/csrf token is a separate legacy token and does NOT satisfy this middleware.
const cookies = await page.context().cookies();
const csrfToken = cookies.find(({ name }) => name.endsWith(HTTP.COOKIE.CSRF_SUFFIX))?.value ?? '';
expect(csrfToken).toBeTruthy();

await test.step('Save OIDC config with only the issuer + client credentials (no endpoints)', async () => {
const response = await page.request.post(`/api/teams/${team.id}/sso/oidc/config`, {
headers: { [HTTP.HEADERS.X_CSRF_TOKEN]: csrfToken },
data: {
name: 'Mock OIDC',
issuer: ISSUER,
clientId: 'client-id',
clientSecret: 'client-secret',
scopes: ['openid', 'email', 'profile'],
attributeMapping: { email: 'email', userName: 'email', firstName: 'given_name', lastName: 'family_name' },
// Intentionally NO authorizationEndpoint / tokenEndpoint / jwksUri — this is the exact
// request shape that previously failed validation with a ZodError.
},
});

expect(response.status()).toBe(200);
const body = await response.json();
// Secret is encrypted at rest and never returned to the client.
expect(body?.data?.oidcConfiguration?.clientSecret).toBeNull();
});

await test.step('Server discovered and persisted the endpoints from the issuer', async () => {
const stored = await prisma.oidcConfiguration.findFirstOrThrow({ where: { loginConfigId } });
expect(stored.authorizationEndpoint).toBe(`${ISSUER}/auth`);
expect(stored.tokenEndpoint).toBe(`${ISSUER}/token`);
expect(stored.jwksUri).toBe(`${ISSUER}/jwks`);
expect(stored.userinfoEndpoint).toBe(`${ISSUER}/userinfo`);
});
});
});
59 changes: 59 additions & 0 deletions libs/auth/types/src/lib/__tests__/auth-types.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { OidcConfigurationRequestSchema } from '../auth-types';

/**
* OIDC save-request schema.
*
* Regression guard for the "OIDC config save fails with a ZodError" bug: an admin providing only
* the issuer (relying on server-side discovery to resolve the endpoints) must pass validation. The
* IdP endpoints (authorizationEndpoint / tokenEndpoint / jwksUri / userinfoEndpoint /
* endSessionEndpoint) are resolved server-side, so they are optional/nullable on the wire.
*/
describe('OidcConfigurationRequestSchema', () => {
const baseRequest = {
name: 'JumpCloud SSO',
issuer: 'https://oauth.id.jumpcloud.com/',
clientId: 'client-abc',
clientSecret: 'super-secret',
attributeMapping: { email: 'email', userName: 'email', firstName: 'given_name', lastName: 'family_name' },
};

it('accepts a request with no endpoints (endpoints are resolved server-side from the issuer)', () => {
const result = OidcConfigurationRequestSchema.safeParse(baseRequest);
expect(result.success).toBe(true);
});

it('accepts a request with null endpoints (client sends null when auto-discover was not run)', () => {
const result = OidcConfigurationRequestSchema.safeParse({
...baseRequest,
authorizationEndpoint: null,
tokenEndpoint: null,
userinfoEndpoint: null,
jwksUri: null,
endSessionEndpoint: null,
});
expect(result.success).toBe(true);
});

it('accepts a request with valid discovered endpoint URLs (the auto-discover preview path)', () => {
const result = OidcConfigurationRequestSchema.safeParse({
...baseRequest,
authorizationEndpoint: 'https://oauth.id.jumpcloud.com/oauth2/auth',
tokenEndpoint: 'https://oauth.id.jumpcloud.com/oauth2/token',
jwksUri: 'https://oauth.id.jumpcloud.com/.well-known/jwks.json',
});
expect(result.success).toBe(true);
});

it('still rejects a non-URL endpoint when one is provided', () => {
const result = OidcConfigurationRequestSchema.safeParse({ ...baseRequest, authorizationEndpoint: 'not-a-url' });
expect(result.success).toBe(false);
});

it('still requires the core fields (issuer must be a valid URL)', () => {
const { issuer, ...withoutIssuer } = baseRequest;
expect(OidcConfigurationRequestSchema.safeParse(withoutIssuer).success).toBe(false);
expect(OidcConfigurationRequestSchema.safeParse({ ...baseRequest, issuer: 'not-a-url' }).success).toBe(false);
expect(OidcConfigurationRequestSchema.safeParse({ ...baseRequest, name: '' }).success).toBe(false);
});
});
15 changes: 10 additions & 5 deletions libs/auth/types/src/lib/auth-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,13 +441,18 @@ export const OidcConfigurationRequestSchema = OidcConfigurationSchema.pick({
issuer: true,
clientId: true,
clientSecret: true,
authorizationEndpoint: true,
tokenEndpoint: true,
userinfoEndpoint: true,
jwksUri: true,
endSessionEndpoint: true,
scopes: true,
attributeMapping: true,
}).extend({
// The IdP endpoints are resolved server-side from the issuer's OIDC discovery document at save
// time (and re-discovered on every login), so an admin only needs to supply the issuer. These
// fields remain optional in the request shape for backwards compatibility, but any client-supplied
// values are ignored and overwritten server-side with the authoritative discovered values.
authorizationEndpoint: z.url().nullish(),
tokenEndpoint: z.url().nullish(),
userinfoEndpoint: z.url().nullish(),
jwksUri: z.url().nullish(),
endSessionEndpoint: z.url().nullish(),
});
export type OidcConfigurationRequest = z.infer<typeof OidcConfigurationRequestSchema>;

Expand Down
Loading
Loading