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 | |
| 16 | import { Hono } from 'hono'; |
| 17 | import { sanitizeRelayState } from '../services/auth-hardening.js'; |
| 18 | import { setCookie } from 'hono/cookie'; |
| 19 | |
| 20 | import { |
| 21 | requireAuthCoreDashboard, |
| 22 | requireAuthCoreProject, |
| 23 | } from '../middleware/auth-core-guard.js'; |
| 24 | import { BRIVEN_ENGINE_ID, isAuthCoreInitialized } from '../services/auth-core/engine.js'; |
| 25 | import { |
| 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'; |
| 39 | import type { AppEnv } from '../types/app-env.js'; |
| 40 | |
| 41 | export const authCoreSsoRouter = new Hono<AppEnv>(); |
| 42 | |
| 43 | authCoreSsoRouter.use( |
| 44 | '/v1/auth-core/projects/:projectId/sso/*', |
| 45 | ...requireAuthCoreProject('admin'), |
| 46 | ); |
| 47 | |
| 48 | // ─── Admin CRUD ─────────────────────────────────────────────────────────── |
| 49 | |
| 50 | authCoreSsoRouter.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 | |
| 72 | authCoreSsoRouter.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 | |
| 124 | authCoreSsoRouter.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 | |
| 155 | authCoreSsoRouter.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) |
| 170 | authCoreSsoRouter.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 | |
| 194 | authCoreSsoRouter.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 | |
| 210 | authCoreSsoRouter.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 | |
| 230 | authCoreSsoRouter.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 | |
| 289 | authCoreSsoRouter.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 | |
| 311 | authCoreSsoRouter.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 | ); |