auth-core-sso.ts361 lines · main
1/**
2 * briven-engine enterprise SSO routes (Phase enterprise).
3 *
4 * Admin (dashboard session + project admin):
5 * GET/POST /v1/auth-core/projects/:projectId/sso/connections
6 * PATCH/DELETE .../sso/connections/:connectionId
7 *
8 * Login (public ACS / OIDC callback):
9 * GET /v1/auth-core/sso/saml/:connectionId
10 * POST /v1/auth-core/sso/saml/:connectionId/acs
11 * GET /v1/auth-core/sso/saml/:connectionId/metadata
12 * GET /v1/auth-core/sso/oidc/:connectionId
13 * GET /v1/auth-core/sso/oidc/:connectionId/callback
14 */
15
16import { Hono } from 'hono';
17import { sanitizeRelayState } from '../services/auth-hardening.js';
18import { setCookie } from 'hono/cookie';
19
20import {
21 requireAuthCoreDashboard,
22 requireAuthCoreProject,
23} from '../middleware/auth-core-guard.js';
24import { BRIVEN_ENGINE_ID, isAuthCoreInitialized } from '../services/auth-core/engine.js';
25import {
26 completeOidcLogin,
27 completeSamlLogin,
28 createEngineSsoConnection,
29 deactivateEngineSsoConnection,
30 generateSamlMetadataXml,
31 getEngineSsoConnection,
32 listEngineSsoConnections,
33 publicSsoConnection,
34 startOidcLogin,
35 startSamlLogin,
36 updateEngineSsoConnection,
37 type SsoProviderType,
38} from '../services/auth-core/sso.js';
39import type { AppEnv } from '../types/app-env.js';
40
41export const authCoreSsoRouter = new Hono<AppEnv>();
42
43authCoreSsoRouter.use(
44 '/v1/auth-core/projects/:projectId/sso/*',
45 ...requireAuthCoreProject('admin'),
46);
47
48// ─── Admin CRUD ───────────────────────────────────────────────────────────
49
50authCoreSsoRouter.get(
51 '/v1/auth-core/projects/:projectId/sso/connections',
52 async (c) => {
53 if (!isAuthCoreInitialized()) {
54 return c.json(
55 { engine: BRIVEN_ENGINE_ID, code: 'auth_core_sdk_not_ready', connections: [] },
56 503,
57 );
58 }
59 const projectId = c.req.param('projectId');
60 const connections = await listEngineSsoConnections(projectId);
61 return c.json({
62 engine: BRIVEN_ENGINE_ID,
63 storage: 'doltgres',
64 projectId,
65 connections: connections.map(publicSsoConnection),
66 productNote:
67 'SAML + OIDC enterprise SSO on briven-engine. productionReady=true when IdP fields are complete.',
68 });
69 },
70);
71
72authCoreSsoRouter.post(
73 '/v1/auth-core/projects/:projectId/sso/connections',
74 async (c) => {
75 const projectId = c.req.param('projectId');
76 let body: {
77 name?: string;
78 providerType?: SsoProviderType;
79 domains?: string[];
80 config?: Record<string, unknown>;
81 jitEnabled?: boolean;
82 } = {};
83 try {
84 body = await c.req.json();
85 } catch {
86 body = {};
87 }
88 if (!body.name || !body.providerType) {
89 return c.json(
90 {
91 engine: BRIVEN_ENGINE_ID,
92 code: 'bad_request',
93 message: 'name and providerType (saml|oidc) required',
94 },
95 400,
96 );
97 }
98 try {
99 const connection = await createEngineSsoConnection({
100 projectId,
101 name: body.name,
102 providerType: body.providerType,
103 domains: body.domains,
104 config: body.config,
105 jitEnabled: body.jitEnabled,
106 });
107 return c.json({
108 engine: BRIVEN_ENGINE_ID,
109 connection: publicSsoConnection(connection),
110 });
111 } catch (err) {
112 return c.json(
113 {
114 engine: BRIVEN_ENGINE_ID,
115 code: 'create_failed',
116 message: err instanceof Error ? err.message : String(err),
117 },
118 400,
119 );
120 }
121 },
122);
123
124authCoreSsoRouter.patch(
125 '/v1/auth-core/projects/:projectId/sso/connections/:connectionId',
126 async (c) => {
127 const connectionId = c.req.param('connectionId');
128 const projectId = c.req.param('projectId');
129 const existing = await getEngineSsoConnection(connectionId);
130 if (!existing || existing.projectId !== projectId) {
131 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'not_found' }, 404);
132 }
133 let body: {
134 name?: string;
135 domains?: string[];
136 config?: Record<string, unknown>;
137 jitEnabled?: boolean;
138 } = {};
139 try {
140 body = await c.req.json();
141 } catch {
142 body = {};
143 }
144 const updated = await updateEngineSsoConnection(connectionId, body);
145 if (!updated) {
146 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'not_found' }, 404);
147 }
148 return c.json({
149 engine: BRIVEN_ENGINE_ID,
150 connection: publicSsoConnection(updated),
151 });
152 },
153);
154
155authCoreSsoRouter.delete(
156 '/v1/auth-core/projects/:projectId/sso/connections/:connectionId',
157 async (c) => {
158 const connectionId = c.req.param('connectionId');
159 const projectId = c.req.param('projectId');
160 const existing = await getEngineSsoConnection(connectionId);
161 if (!existing || existing.projectId !== projectId) {
162 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'not_found' }, 404);
163 }
164 await deactivateEngineSsoConnection(connectionId);
165 return c.json({ engine: BRIVEN_ENGINE_ID, ok: true, connectionId });
166 },
167);
168
169// Dashboard-only list all ready connections across tenants (operator overview)
170authCoreSsoRouter.get(
171 '/v1/auth-core/sso/status',
172 requireAuthCoreDashboard(),
173 async (c) => {
174 return c.json({
175 engine: BRIVEN_ENGINE_ID,
176 storage: 'doltgres',
177 product: 'Briven Auth enterprise SSO',
178 saml: {
179 start: 'GET /v1/auth-core/sso/saml/:connectionId',
180 acs: 'POST /v1/auth-core/sso/saml/:connectionId/acs',
181 metadata: 'GET /v1/auth-core/sso/saml/:connectionId/metadata',
182 },
183 oidc: {
184 start: 'GET /v1/auth-core/sso/oidc/:connectionId',
185 callback: 'GET /v1/auth-core/sso/oidc/:connectionId/callback',
186 },
187 note: 'Configure connections under Enterprise tab. productionReady when IdP SSO URL+cert (SAML) or client id/secret+issuer/URLs (OIDC) are set.',
188 });
189 },
190);
191
192// ─── Public login ─────────────────────────────────────────────────────────
193
194authCoreSsoRouter.get('/v1/auth-core/sso/saml/:connectionId/metadata', async (c) => {
195 try {
196 const xml = await generateSamlMetadataXml(c.req.param('connectionId'));
197 return c.body(xml, 200, { 'content-type': 'application/xml; charset=utf-8' });
198 } catch (err) {
199 return c.json(
200 {
201 engine: BRIVEN_ENGINE_ID,
202 code: 'metadata_failed',
203 message: err instanceof Error ? err.message : String(err),
204 },
205 400,
206 );
207 }
208});
209
210authCoreSsoRouter.get('/v1/auth-core/sso/saml/:connectionId', async (c) => {
211 try {
212 const relayState = c.req.query('relayState') ?? undefined;
213 const { redirectUrl } = await startSamlLogin(
214 c.req.param('connectionId'),
215 relayState,
216 );
217 return c.redirect(redirectUrl, 302);
218 } catch (err) {
219 return c.json(
220 {
221 engine: BRIVEN_ENGINE_ID,
222 code: 'saml_start_failed',
223 message: err instanceof Error ? err.message : String(err),
224 },
225 400,
226 );
227 }
228});
229
230authCoreSsoRouter.post('/v1/auth-core/sso/saml/:connectionId/acs', async (c) => {
231 try {
232 const body = await c.req.parseBody();
233 const samlResponse =
234 typeof body.SAMLResponse === 'string' ? body.SAMLResponse : '';
235 if (!samlResponse) {
236 return c.json(
237 { engine: BRIVEN_ENGINE_ID, code: 'SAMLResponse_required' },
238 400,
239 );
240 }
241 const result = await completeSamlLogin({
242 connectionId: c.req.param('connectionId'),
243 samlResponse,
244 });
245 setCookie(c, 'sAccessToken', result.accessToken, {
246 httpOnly: true,
247 secure: true,
248 sameSite: 'Lax',
249 path: '/',
250 maxAge: 60 * 60 * 24 * 30,
251 });
252 // Open-redirect guard: only allowlisted origins (or relative paths).
253 let allowedOrigins: string[] = [];
254 try {
255 const { getBrivenEngineAppOrigins } = await import(
256 '../services/auth-core/project-config.js'
257 );
258 if (result.projectId) {
259 allowedOrigins = await getBrivenEngineAppOrigins(result.projectId);
260 }
261 } catch {
262 allowedOrigins = [];
263 }
264 const relayRaw =
265 typeof body.RelayState === 'string' ? body.RelayState : null;
266 const relay = sanitizeRelayState(relayRaw, allowedOrigins);
267 if (relay && relay !== '/') return c.redirect(relay, 302);
268 return c.json({
269 engine: BRIVEN_ENGINE_ID,
270 status: 'OK',
271 userId: result.userId,
272 email: result.email,
273 projectId: result.projectId,
274 tenantId: result.tenantId,
275 sessionHandle: result.sessionHandle,
276 });
277 } catch (err) {
278 return c.json(
279 {
280 engine: BRIVEN_ENGINE_ID,
281 code: 'saml_acs_failed',
282 message: err instanceof Error ? err.message : String(err),
283 },
284 400,
285 );
286 }
287});
288
289authCoreSsoRouter.get('/v1/auth-core/sso/oidc/:connectionId', async (c) => {
290 try {
291 // Optional ?returnTo=https://app.example.com/after-login (sanitized server-side)
292 const returnTo = c.req.query('returnTo') ?? c.req.query('return_to') ?? null;
293 const { redirectUrl } = await startOidcLogin(
294 c.req.param('connectionId'),
295 undefined,
296 returnTo,
297 );
298 return c.redirect(redirectUrl, 302);
299 } catch (err) {
300 return c.json(
301 {
302 engine: BRIVEN_ENGINE_ID,
303 code: 'oidc_start_failed',
304 message: err instanceof Error ? err.message : String(err),
305 },
306 400,
307 );
308 }
309});
310
311authCoreSsoRouter.get(
312 '/v1/auth-core/sso/oidc/:connectionId/callback',
313 async (c) => {
314 try {
315 const code = c.req.query('code');
316 const state = c.req.query('state');
317 if (!code || !state) {
318 return c.json(
319 { engine: BRIVEN_ENGINE_ID, code: 'code_and_state_required' },
320 400,
321 );
322 }
323 const result = await completeOidcLogin({
324 connectionId: c.req.param('connectionId'),
325 code,
326 state,
327 });
328 setCookie(c, 'sAccessToken', result.accessToken, {
329 httpOnly: true,
330 secure: true,
331 sameSite: 'Lax',
332 path: '/',
333 maxAge: 60 * 60 * 24 * 30,
334 });
335 // Prefer redirect into the app when returnTo was stored at start.
336 if (result.returnTo) {
337 return c.redirect(result.returnTo, 302);
338 }
339 return c.json({
340 engine: BRIVEN_ENGINE_ID,
341 status: 'OK',
342 userId: result.userId,
343 email: result.email,
344 projectId: result.projectId,
345 tenantId: result.tenantId,
346 sessionHandle: result.sessionHandle,
347 message:
348 'OIDC login ok — pass returnTo on start URL to redirect into your app',
349 });
350 } catch (err) {
351 return c.json(
352 {
353 engine: BRIVEN_ENGINE_ID,
354 code: 'oidc_callback_failed',
355 message: err instanceof Error ? err.message : String(err),
356 },
357 400,
358 );
359 }
360 },
361);