Skip to content

Commit b1e6ced

Browse files
authored
feat: SSO configuration and auth method management (#2686)
* feat: add SSO configuration, auth method management, and domain-filtered login flows Add end-to-end SSO support including: - SSO provider configuration UI (SAML/OIDC via Okta, Entra, etc.) - Auth method toggles (email-password, Google, per-SSO-provider) - Domain-filtered method picker on login and invitation pages - Auto-provisioning with pending-invitation precedence - Members page extracted from settings with invitation management - No-org page shows pending invitations for unauthenticated members - UI guard preventing disabling all auth methods - Auth lookup API for email-based org/method discovery Made-with: Cursor * feedback * update cypress login * feedback * address UX feedback * add missing tests * fix test * adjust sso config
1 parent 3d54b6b commit b1e6ced

File tree

72 files changed

+8339
-945
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+8339
-945
lines changed

.changeset/melted-gray-lynx.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@inkeep/agents-core": minor
3+
"@inkeep/agents-api": minor
4+
"@inkeep/agents-manage-ui": minor
5+
---
6+
7+
Add SSO configuration, auth method management, and domain-filtered login and invitation flows

.env.example

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,6 @@ SPICEDB_PRESHARED_KEY=dev-secret-key
8888
# API bypass secret for local development and testing (skips auth)
8989
INKEEP_AGENTS_MANAGE_API_BYPASS_SECRET=test-bypass-secret-for-ci
9090

91-
# AUTH0_DOMAIN=
92-
# NEXT_PUBLIC_AUTH0_DOMAIN=
93-
# AUTH0_CLIENT_ID=
94-
# AUTH0_CLIENT_SECRET=
95-
9691
# ========== SERVICE TOKENS ================
9792
# INKEEP_AGENTS_JWT_SIGNING_SECRET=
9893

agents-api/__snapshots__/openapi.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6193,6 +6193,7 @@
61936193
"forbidden",
61946194
"not_found",
61956195
"conflict",
6196+
"too_many_requests",
61966197
"internal_server_error",
61976198
"unprocessable_entity"
61986199
],

agents-api/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@
9191
"jmespath": "^0.16.0",
9292
"jose": "^6.1.0",
9393
"llm-info": "^1.0.69",
94-
"openid-client": "^6.8.1",
9594
"pg": "^8.16.3",
9695
"undici": "^7.22.0",
9796
"workflow": "^4.2.0-beta.64"

agents-api/src/__tests__/auth.test.ts

Lines changed: 0 additions & 64 deletions
This file was deleted.

agents-api/src/__tests__/manage/routes/invitations.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
// Hoist the mock functions
4-
const { getPendingInvitationsByEmailMock, listUserInvitationsMock } = vi.hoisted(() => ({
4+
const {
5+
getPendingInvitationsByEmailMock,
6+
listUserInvitationsMock,
7+
getFilteredAuthMethodsForEmailMock,
8+
} = vi.hoisted(() => ({
59
getPendingInvitationsByEmailMock: vi.fn(),
610
listUserInvitationsMock: vi.fn(),
11+
getFilteredAuthMethodsForEmailMock: vi.fn().mockResolvedValue([]),
712
}));
813

914
// Mock @inkeep/agents-core
@@ -12,6 +17,7 @@ vi.mock('@inkeep/agents-core', async (importOriginal) => {
1217
return {
1318
...original,
1419
getPendingInvitationsByEmail: () => getPendingInvitationsByEmailMock,
20+
getFilteredAuthMethodsForEmail: () => getFilteredAuthMethodsForEmailMock,
1521
createApiError: original.createApiError,
1622
};
1723
});

agents-api/src/domains/manage/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OpenAPIHono } from '@hono/zod-openapi';
22
import { capabilitiesHandler } from '../../routes/capabilities';
33
import type { ManageAppVariables } from '../../types/app';
4+
import authLookupRoutes from './routes/authLookup';
45
import availableAgentsRoutes from './routes/availableAgents';
56
import cliAuthRoutes from './routes/cliAuth';
67
import githubRoutes from './routes/github';
@@ -25,6 +26,9 @@ export function createManageRoutes() {
2526
app.route('/api/users', usersRoutes);
2627
app.route('/api/users', userProfileRoutes);
2728

29+
// Mount auth lookup (email-first login flow)
30+
app.route('/api/auth-lookup', authLookupRoutes);
31+
2832
// Mount CLI auth routes - for CLI login flow
2933
app.route('/api/cli', cliAuthRoutes);
3034

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { createApiError, getAuthLookupForEmail } from '@inkeep/agents-core';
2+
import { Hono } from 'hono';
3+
import { HTTPException } from 'hono/http-exception';
4+
import runDbClient from '../../../data/db/runDbClient';
5+
import { getLogger } from '../../../logger';
6+
import type { ManageAppVariables } from '../../../types/app';
7+
8+
const logger = getLogger('auth-lookup');
9+
10+
const RATE_LIMIT_WINDOW_MS = 60_000;
11+
const RATE_LIMIT_MAX_REQUESTS = 20;
12+
const ipRequestTimestamps = new Map<string, number[]>();
13+
14+
setInterval(() => {
15+
const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
16+
for (const [ip, timestamps] of ipRequestTimestamps) {
17+
const recent = timestamps.filter((t) => t > cutoff);
18+
if (recent.length === 0) {
19+
ipRequestTimestamps.delete(ip);
20+
} else {
21+
ipRequestTimestamps.set(ip, recent);
22+
}
23+
}
24+
}, RATE_LIMIT_WINDOW_MS);
25+
26+
function getClientIp(c: { req: { header: (name: string) => string | undefined } }): string {
27+
return (
28+
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || c.req.header('x-real-ip') || 'unknown'
29+
);
30+
}
31+
32+
const authLookupRoutes = new Hono<{ Variables: ManageAppVariables }>();
33+
34+
/**
35+
* GET /api/auth-lookup?email=user@example.com
36+
*
37+
* Unauthenticated endpoint for the email-first login flow.
38+
* Returns org-aware auth methods:
39+
* 1. Checks SSO providers by email domain -> resolves org -> returns org's allowed methods (SSO filtered to domain-matched providers)
40+
* 2. Checks existing user account -> resolves org membership -> returns org's allowed methods (SSO filtered to domain-matched providers)
41+
*
42+
* Returns empty organizations array if no match is found.
43+
*
44+
* Rate-limited per IP to mitigate email/org enumeration.
45+
* organizationSlug is intentionally omitted from the response to minimize info disclosure.
46+
*/
47+
authLookupRoutes.get('/', async (c) => {
48+
const clientIp = getClientIp(c);
49+
const now = Date.now();
50+
const timestamps = ipRequestTimestamps.get(clientIp) || [];
51+
const recent = timestamps.filter((t) => t > now - RATE_LIMIT_WINDOW_MS);
52+
53+
if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
54+
logger.warn({ clientIp, count: recent.length }, 'auth-lookup rate limit exceeded');
55+
throw createApiError({
56+
code: 'too_many_requests',
57+
message: 'Too many requests. Please try again later.',
58+
});
59+
}
60+
61+
recent.push(now);
62+
ipRequestTimestamps.set(clientIp, recent);
63+
64+
const email = c.req.query('email');
65+
66+
if (!email) {
67+
throw createApiError({
68+
code: 'bad_request',
69+
message: 'Email parameter is required',
70+
});
71+
}
72+
73+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
74+
if (!emailRegex.test(email)) {
75+
throw createApiError({
76+
code: 'bad_request',
77+
message: 'Invalid email format',
78+
});
79+
}
80+
81+
try {
82+
const organizations = await getAuthLookupForEmail(runDbClient)(email);
83+
84+
const sanitized = organizations.map(({ organizationSlug: _slug, ...rest }) => rest);
85+
86+
logger.info(
87+
{ clientIp, emailDomain: email.split('@')[1], orgCount: organizations.length },
88+
'auth-lookup completed'
89+
);
90+
91+
return c.json({ organizations: sanitized });
92+
} catch (error) {
93+
if (error instanceof HTTPException) {
94+
throw error;
95+
}
96+
97+
logger.error({ clientIp, error }, 'auth-lookup failed');
98+
throw createApiError({
99+
code: 'internal_server_error',
100+
message: 'Failed to look up authentication method',
101+
});
102+
}
103+
});
104+
105+
export default authLookupRoutes;

agents-api/src/domains/manage/routes/invitations.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
createApiError,
33
getEmailSendStatus,
4+
getFilteredAuthMethodsForEmail,
45
getPendingInvitationsByEmail,
56
} from '@inkeep/agents-core';
67
import { Hono } from 'hono';
@@ -53,7 +54,7 @@ invitationsRoutes.get('/verify', async (c) => {
5354

5455
// Find the specific invitation by ID
5556
const invitation = Array.isArray(invitations)
56-
? invitations.find((inv: { id: string }) => inv.id === invitationId)
57+
? invitations.find((inv) => inv.id === invitationId)
5758
: null;
5859

5960
if (!invitation) {
@@ -82,7 +83,11 @@ invitationsRoutes.get('/verify', async (c) => {
8283
});
8384
}
8485

85-
// Return limited, safe information
86+
const filteredMethods = await getFilteredAuthMethodsForEmail(runDbClient)(
87+
invitation.organizationId,
88+
email
89+
);
90+
8691
return c.json({
8792
valid: true,
8893
email: invitation.email,
@@ -91,6 +96,7 @@ invitationsRoutes.get('/verify', async (c) => {
9196
role: invitation.role,
9297
expiresAt: invitation.expiresAt,
9398
authMethod: invitation.authMethod || null,
99+
allowedAuthMethods: filteredMethods,
94100
});
95101
} catch (error) {
96102
// Re-throw API errors (HTTPExceptions from createApiError)

agents-api/src/domains/manage/routes/passwordResetLinks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ passwordResetLinksRoutes.post('/', async (c) => {
4545
headers: c.req.raw.headers,
4646
});
4747

48-
const isMember = result.members.some((m: { user: { email: string } }) => m.user.email === email);
48+
const isMember = result.members.some((m) => m.user.email === email);
4949

5050
if (!isMember) {
5151
throw createApiError({

0 commit comments

Comments
 (0)