branding-public.ts81 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | |
| 3 | import { getBrandingLogo, isStorageConfigured } from '../services/auth-branding-logo.js'; |
| 4 | import { |
| 5 | buildAuthBrandingPublicPayload, |
| 6 | getAuthConfig, |
| 7 | listEnabledProviders, |
| 8 | } from '../services/tenant-config-store.js'; |
| 9 | |
| 10 | /** |
| 11 | * Public, UNAUTHENTICATED branding-logo route — lives in its OWN router on |
| 12 | * purpose so `index.ts` can mount it BEFORE every project-auth guard. |
| 13 | * |
| 14 | * Previously this handler lived inside `authServiceRouter`, which broke it two |
| 15 | * ways on live: (1) `authServiceRouter` is mounted after `projectsRouter`, |
| 16 | * whose `/v1/projects/:id/*` auth middleware fired first and returned |
| 17 | * `401 authentication required`; and (2) the whole auth service is gated behind |
| 18 | * `BRIVEN_AUTH_ENABLED`. But a hosted login page (and any embedder) loads the |
| 19 | * logo via a plain `<img src>`, so it must serve with no session/key AND |
| 20 | * regardless of the auth-service kill switch. Mounting this router first makes |
| 21 | * its handler the first match — it returns a Response and ends the chain before |
| 22 | * any guard runs. The URL is unchanged, so existing `branding.logoUrl` values |
| 23 | * keep working. |
| 24 | * |
| 25 | * The object stays PRIVATE in storage; we proxy the bytes with the stored |
| 26 | * content-type. `nosniff` + a locked-down CSP stop a customer-supplied SVG from |
| 27 | * being treated as anything other than an image. |
| 28 | */ |
| 29 | export const brandingPublicRouter = new Hono(); |
| 30 | |
| 31 | brandingPublicRouter.get('/v1/projects/:id/auth/branding/logo', async (c) => { |
| 32 | const projectId = c.req.param('id'); |
| 33 | if (!projectId) { |
| 34 | return c.json({ code: 'validation_failed', message: 'missing :id' }, 400); |
| 35 | } |
| 36 | if (!isStorageConfigured()) { |
| 37 | return c.json({ code: 'storage_not_configured' }, 503); |
| 38 | } |
| 39 | const obj = await getBrandingLogo(projectId); |
| 40 | if (!obj) { |
| 41 | return c.json({ code: 'not_found' }, 404); |
| 42 | } |
| 43 | return new Response(obj.bytes, { |
| 44 | status: 200, |
| 45 | headers: { |
| 46 | 'content-type': obj.contentType, |
| 47 | 'cache-control': 'public, max-age=300', |
| 48 | 'x-content-type-options': 'nosniff', |
| 49 | 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox", |
| 50 | }, |
| 51 | }); |
| 52 | }); |
| 53 | |
| 54 | /** |
| 55 | * Public, UNAUTHENTICATED branding-CONFIG route — sibling of the logo route |
| 56 | * above and mounted on the same router (before every project-auth guard), for |
| 57 | * the same reason: the hosted login pages render server-side with NO admin |
| 58 | * session, so they can't read the admin-gated `/v1/projects/:id/auth/config` |
| 59 | * (it 401s) to pick up the tenant's accent colour. |
| 60 | * |
| 61 | * Returns ONLY non-sensitive presentation fields — `primaryColor`, |
| 62 | * `senderName`, and the `socialProviders` ENABLED list (built-in keys + custom- |
| 63 | * OIDC slugs, with display labels for the OIDC ones). Nothing here is a secret: |
| 64 | * no provider client ids, no client secrets, no domains, no toggles, no |
| 65 | * endpoints. The enabled list lets the hosted pages + SDK render exactly the |
| 66 | * OAuth buttons that are actually wired (the admin `/auth/config` endpoint is |
| 67 | * 401-gated, so render-gating cannot read from it). `getAuthConfig` returns the |
| 68 | * frozen defaults when a project has no config row yet, so this always yields a |
| 69 | * usable colour and an (empty) provider list. |
| 70 | */ |
| 71 | brandingPublicRouter.get('/v1/projects/:id/auth/branding/config', async (c) => { |
| 72 | const projectId = c.req.param('id'); |
| 73 | if (!projectId) { |
| 74 | return c.json({ code: 'validation_failed', message: 'missing :id' }, 400); |
| 75 | } |
| 76 | const config = await getAuthConfig(projectId); |
| 77 | const enabled = await listEnabledProviders(projectId, config); |
| 78 | return c.json(buildAuthBrandingPublicPayload(config, enabled), 200, { |
| 79 | 'cache-control': 'public, max-age=60', |
| 80 | }); |
| 81 | }); |