Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
56 changes: 56 additions & 0 deletions agents-api/src/domains/manage/routes/authLookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createApiError, getAuthLookupForEmail } from '@inkeep/agents-core';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import runDbClient from '../../../data/db/runDbClient';
import type { ManageAppVariables } from '../../../types/app';

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.
*
* Response shape: `{ organizations: OrgAuthInfo[] }`.
*/
authLookupRoutes.get('/', async (c) => {
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);

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

console.error('[auth-lookup] Error looking up auth method:', error);
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