Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/melted-gray-lynx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@inkeep/agents-core": minor
"@inkeep/agents-api": minor
"@inkeep/agents-manage-ui": minor
---

Add SSO configuration, auth method management, and domain-filtered login and invitation flows
5 changes: 0 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,6 @@ SPICEDB_PRESHARED_KEY=dev-secret-key
# API bypass secret for local development and testing (skips auth)
INKEEP_AGENTS_MANAGE_API_BYPASS_SECRET=test-bypass-secret-for-ci

# AUTH0_DOMAIN=
# NEXT_PUBLIC_AUTH0_DOMAIN=
# AUTH0_CLIENT_ID=
# AUTH0_CLIENT_SECRET=

# ========== SERVICE TOKENS ================
# INKEEP_AGENTS_JWT_SIGNING_SECRET=

Expand Down
1 change: 1 addition & 0 deletions agents-api/__snapshots__/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6193,6 +6193,7 @@
"forbidden",
"not_found",
"conflict",
"too_many_requests",
"internal_server_error",
"unprocessable_entity"
],
Expand Down
1 change: 0 additions & 1 deletion agents-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@
"jmespath": "^0.16.0",
"jose": "^6.1.0",
"llm-info": "^1.0.69",
"openid-client": "^6.8.1",
"pg": "^8.16.3",
"undici": "^7.22.0",
"workflow": "^4.2.0-beta.64"
Expand Down
64 changes: 0 additions & 64 deletions agents-api/src/__tests__/auth.test.ts

This file was deleted.

8 changes: 7 additions & 1 deletion agents-api/src/__tests__/manage/routes/invitations.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Hoist the mock functions
const { getPendingInvitationsByEmailMock, listUserInvitationsMock } = vi.hoisted(() => ({
const {
getPendingInvitationsByEmailMock,
listUserInvitationsMock,
getFilteredAuthMethodsForEmailMock,
} = vi.hoisted(() => ({
getPendingInvitationsByEmailMock: vi.fn(),
listUserInvitationsMock: vi.fn(),
getFilteredAuthMethodsForEmailMock: vi.fn().mockResolvedValue([]),
}));

// Mock @inkeep/agents-core
Expand All @@ -12,6 +17,7 @@ vi.mock('@inkeep/agents-core', async (importOriginal) => {
return {
...original,
getPendingInvitationsByEmail: () => getPendingInvitationsByEmailMock,
getFilteredAuthMethodsForEmail: () => getFilteredAuthMethodsForEmailMock,
createApiError: original.createApiError,
};
});
Expand Down
4 changes: 4 additions & 0 deletions agents-api/src/domains/manage/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OpenAPIHono } from '@hono/zod-openapi';
import { capabilitiesHandler } from '../../routes/capabilities';
import type { ManageAppVariables } from '../../types/app';
import authLookupRoutes from './routes/authLookup';
import availableAgentsRoutes from './routes/availableAgents';
import cliAuthRoutes from './routes/cliAuth';
import githubRoutes from './routes/github';
Expand All @@ -25,6 +26,9 @@ export function createManageRoutes() {
app.route('/api/users', usersRoutes);
app.route('/api/users', userProfileRoutes);

// Mount auth lookup (email-first login flow)
app.route('/api/auth-lookup', authLookupRoutes);

// Mount CLI auth routes - for CLI login flow
app.route('/api/cli', cliAuthRoutes);

Expand Down
105 changes: 105 additions & 0 deletions agents-api/src/domains/manage/routes/authLookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { createApiError, getAuthLookupForEmail } from '@inkeep/agents-core';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import runDbClient from '../../../data/db/runDbClient';
import { getLogger } from '../../../logger';
import type { ManageAppVariables } from '../../../types/app';

const logger = getLogger('auth-lookup');

const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 20;
const ipRequestTimestamps = new Map<string, number[]>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: In-memory rate limiter is per-instance only

Issue: The rate limiter uses a module-level Map<string, number[]> which is process-local. In a multi-instance deployment behind a load balancer, each instance maintains its own independent rate counter. An attacker can bypass the limit by distributing requests across instances.

Why: At 20 req/min per instance with N instances, an attacker could effectively enumerate N × 20 emails per minute. The codebase explicitly acknowledges multi-instance deployments as a supported configuration (see getInProcessFetch() pattern).

Fix: This is acceptable as defense-in-depth for single-instance or light-traffic scenarios, but consider:

  1. Document the limitation in the code comments
  2. Infrastructure-level rate limiting (CDN/WAF) for production
  3. Future: Redis-based rate limiting if enumeration becomes a real threat

For now, adding a comment acknowledging this limitation would be helpful:

// NOTE: In-memory rate limiter provides per-instance protection only.
// For multi-instance deployments, rely on infrastructure-level rate limiting.

Refs:


setInterval(() => {
const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
for (const [ip, timestamps] of ipRequestTimestamps) {
const recent = timestamps.filter((t) => t > cutoff);
if (recent.length === 0) {
ipRequestTimestamps.delete(ip);
} else {
ipRequestTimestamps.set(ip, recent);
}
}
}, RATE_LIMIT_WINDOW_MS);

function getClientIp(c: { req: { header: (name: string) => string | undefined } }): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Consider: IP extraction trusts proxy headers unconditionally

Issue: The x-forwarded-for header is trusted without validation. An attacker could spoof this header to bypass rate limiting:

curl -H 'x-forwarded-for: fake-ip-1' /api/auth-lookup?email=test@example.com
curl -H 'x-forwarded-for: fake-ip-2' /api/auth-lookup?email=test@example.com

Why: This is a common pattern and typically safe when deployed behind a trusted proxy that overwrites incoming x-forwarded-for headers. However, if the API is exposed directly or behind a misconfigured proxy, the rate limit can be bypassed.

Fix: This is deployment-dependent. Options:

  1. Ensure the load balancer/reverse proxy strips incoming x-forwarded-for headers (infrastructure fix)
  2. Use Hono's c.req.ip with trusted proxy configuration
  3. Add an env flag to control whether proxy headers are trusted

No action required if your deployment is behind a properly configured proxy (Vercel, Cloudflare, etc.).

return (
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || c.req.header('x-real-ip') || 'unknown'
);
}

const authLookupRoutes = new Hono<{ Variables: ManageAppVariables }>();

/**
* GET /api/auth-lookup?email=user@example.com
*
* Unauthenticated endpoint for the email-first login flow.
* Returns org-aware auth methods:
* 1. Checks SSO providers by email domain -> resolves org -> returns org's allowed methods (SSO filtered to domain-matched providers)
* 2. Checks existing user account -> resolves org membership -> returns org's allowed methods (SSO filtered to domain-matched providers)
*
* Returns empty organizations array if no match is found.
*
* Rate-limited per IP to mitigate email/org enumeration.
* organizationSlug is intentionally omitted from the response to minimize info disclosure.
*/
authLookupRoutes.get('/', async (c) => {
const clientIp = getClientIp(c);
const now = Date.now();
const timestamps = ipRequestTimestamps.get(clientIp) || [];
const recent = timestamps.filter((t) => t > now - RATE_LIMIT_WINDOW_MS);

if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
logger.warn({ clientIp, count: recent.length }, 'auth-lookup rate limit exceeded');
throw createApiError({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Consider: Adding Retry-After header to 429 response

Issue: The 429 response doesn't include a Retry-After header to indicate when the client should retry.

Why: The codebase's retryWithBackoff utility in packages/agents-core/src/utils/retry.ts respects Retry-After headers. Without it, clients must guess retry timing.

Fix: Optional improvement:

// Calculate time until oldest request expires from window
const oldestTimestamp = recent[0];
const retryAfterSeconds = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - oldestTimestamp)) / 1000);

// Could add to response headers if createApiError supported it
// For now, the message "try again later" is sufficient

Low priority since this endpoint is called by the login UI which handles the error gracefully.

code: 'too_many_requests',
message: 'Too many requests. Please try again later.',
});
}

recent.push(now);
ipRequestTimestamps.set(clientIp, recent);

const email = c.req.query('email');

if (!email) {
throw createApiError({
code: 'bad_request',
message: 'Email parameter is required',
});
}

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw createApiError({
code: 'bad_request',
message: 'Invalid email format',
});
}

try {
const organizations = await getAuthLookupForEmail(runDbClient)(email);

const sanitized = organizations.map(({ organizationSlug: _slug, ...rest }) => rest);

logger.info(
{ clientIp, emailDomain: email.split('@')[1], orgCount: organizations.length },
'auth-lookup completed'
);

return c.json({ organizations: sanitized });
} catch (error) {
if (error instanceof HTTPException) {
throw error;
}

logger.error({ clientIp, error }, 'auth-lookup failed');
throw createApiError({
code: 'internal_server_error',
message: 'Failed to look up authentication method',
});
}
});

export default authLookupRoutes;
10 changes: 8 additions & 2 deletions agents-api/src/domains/manage/routes/invitations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
createApiError,
getEmailSendStatus,
getFilteredAuthMethodsForEmail,
getPendingInvitationsByEmail,
} from '@inkeep/agents-core';
import { Hono } from 'hono';
Expand Down Expand Up @@ -53,7 +54,7 @@ invitationsRoutes.get('/verify', async (c) => {

// Find the specific invitation by ID
const invitation = Array.isArray(invitations)
? invitations.find((inv: { id: string }) => inv.id === invitationId)
? invitations.find((inv) => inv.id === invitationId)
: null;

if (!invitation) {
Expand Down Expand Up @@ -82,7 +83,11 @@ invitationsRoutes.get('/verify', async (c) => {
});
}

// Return limited, safe information
const filteredMethods = await getFilteredAuthMethodsForEmail(runDbClient)(
invitation.organizationId,
email
);

return c.json({
valid: true,
email: invitation.email,
Expand All @@ -91,6 +96,7 @@ invitationsRoutes.get('/verify', async (c) => {
role: invitation.role,
expiresAt: invitation.expiresAt,
authMethod: invitation.authMethod || null,
allowedAuthMethods: filteredMethods,
});
} catch (error) {
// Re-throw API errors (HTTPExceptions from createApiError)
Expand Down
2 changes: 1 addition & 1 deletion agents-api/src/domains/manage/routes/passwordResetLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ passwordResetLinksRoutes.post('/', async (c) => {
headers: c.req.raw.headers,
});

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

if (!isMember) {
throw createApiError({
Expand Down
2 changes: 1 addition & 1 deletion agents-api/src/domains/manage/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ usersRoutes.get('/:userId/organizations', async (c) => {
* POST /api/users/providers
*
* Get authentication providers for a list of users.
* Returns which providers each user has (e.g., 'credential', 'google', 'auth0').
* Returns which providers each user has (e.g., 'credential', 'google', etc).
* Restricted to org admins/owners querying members of their organization.
*
* Body: { userIds: string[], organizationId: string }
Expand Down
3 changes: 0 additions & 3 deletions agents-api/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import runDbClient from './data/db/runDbClient';
import { env } from './env';
import type { SandboxConfig } from './types';

export { createAuth0Provider, createOIDCProvider } from './ssoHelpers';

export type { UserAuthConfig, SSOProviderConfig };

const defaultConfig: ServerConfig = {
Expand All @@ -35,7 +33,6 @@ export function createAgentsAuth(
dbClient: runDbClient,
manageDbPool,
...(env.AUTH_COOKIE_DOMAIN && { cookieDomain: env.AUTH_COOKIE_DOMAIN }),
...(userAuthConfig?.ssoProviders && { ssoProviders: userAuthConfig.ssoProviders }),
...(userAuthConfig?.socialProviders && { socialProviders: userAuthConfig.socialProviders }),
...(emailService && { emailService }),
});
Expand Down
26 changes: 1 addition & 25 deletions agents-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@ import { getLogger } from './logger';

const logger = getLogger('agents-api-init');

import type { SSOProviderConfig } from '@inkeep/agents-core/auth';
import { createEmailService } from '@inkeep/agents-email';
import { Hono } from 'hono';
import { createAgentsHono } from './createApp';
import { createAgentsAuth } from './factory';
import { createAuth0Provider } from './ssoHelpers';
import type { SandboxConfig } from './types';
import { recoverOrphanedWorkflows, world } from './workflow/world';

Expand All @@ -38,8 +36,6 @@ export type { SSOProviderConfig, UserAuthConfig } from './factory';
export {
createAgentsApp,
createAgentsHono,
createAuth0Provider,
createOIDCProvider,
} from './factory';

// Create default configuration
Expand Down Expand Up @@ -67,18 +63,6 @@ const sandboxConfig: SandboxConfig =
}
: { provider: 'native', runtime: 'node22', timeout: 30000, vcpus: 2 };

// Module-level initialization for default app export
// This only runs when importing the default app (legacy/simple deployments)
const ssoProviders = await Promise.all([
process.env.AUTH0_DOMAIN && process.env.AUTH0_CLIENT_ID && process.env.AUTH0_CLIENT_SECRET
? createAuth0Provider({
domain: process.env.AUTH0_DOMAIN,
clientId: process.env.AUTH0_CLIENT_ID,
clientSecret: process.env.AUTH0_CLIENT_SECRET,
})
: null,
]);

const socialProviders =
process.env.PUBLIC_GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? {
Expand All @@ -93,15 +77,7 @@ const socialProviders =

const emailService = createEmailService();

export const auth = createAgentsAuth(
{
ssoProviders: ssoProviders.filter(
(p: SSOProviderConfig | null): p is SSOProviderConfig => p !== null
),
socialProviders,
},
emailService
);
export const auth = createAgentsAuth({ socialProviders }, emailService);

// Create default credential stores
const defaultStores = createDefaultCredentialStores();
Expand Down
3 changes: 2 additions & 1 deletion agents-api/src/middleware/sessionAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createApiError } from '@inkeep/agents-core';
import { registerAuthzMeta } from '@inkeep/agents-core/middleware';
import { createMiddleware } from 'hono/factory';
import { HTTPException } from 'hono/http-exception';
import type { AppVariables } from '../types/app';

/**
* Middleware to enforce session-based authentication.
Expand Down Expand Up @@ -46,7 +47,7 @@ export const sessionAuth = () => {
* Used for all routes that require an active user session.
*/
export const sessionContext = () =>
createMiddleware(async (c, next) => {
createMiddleware<{ Variables: AppVariables }>(async (c, next) => {
const auth = c.get('auth');
if (!auth) {
c.set('user', null);
Expand Down
Loading
Loading