branding-public.ts81 lines · main
1import { Hono } from 'hono';
2
3import { getBrandingLogo, isStorageConfigured } from '../services/auth-branding-logo.js';
4import {
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 */
29export const brandingPublicRouter = new Hono();
30
31brandingPublicRouter.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 */
71brandingPublicRouter.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});