From 00825760637c54888c72fbf332962912a09c33ef Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:43:14 +0200 Subject: [PATCH] docs: update architecture docs for single-domain /platform routing Reflects current state: path-based routing, SaaS at /platform, Logto catch-all, TLS init container, server integration env vars, custom JwtDecoder for ES384, skip consent for SSO. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 32 +++- ...2026-04-05-single-domain-routing-design.md | 169 ++++++++---------- 2 files changed, 107 insertions(+), 94 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index acc0234..a67d956 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,10 +26,38 @@ The existing cameleer3-server already has single-tenant auth (JWT, RBAC, bootstr - Proxy or federate access to tenant-specific cameleer3-server instances - Enforce usage quotas and metered billing -Auth enforcement (current state): +### Routing (single-domain, path-based via Traefik) + +All services on one hostname. Two env vars control everything: `PUBLIC_HOST` + `PUBLIC_PROTOCOL`. + +| Path | Target | Notes | +|------|--------|-------| +| `/platform/*` | cameleer-saas:8080 | SPA + API (`server.servlet.context-path: /platform`) | +| `/server/*` | cameleer3-server-ui:80 | Server dashboard (strip-prefix + `BASE_PATH=/server`) | +| `/` | redirect → `/platform/` | Via `docker/traefik-dynamic.yml` | +| `/*` (catch-all) | logto:3001 (priority=1) | Sign-in, OIDC, interaction, assets | + +- SPA assets at `/_app/` (Vite `assetsDir: '_app'`) to avoid conflict with Logto's `/assets/` +- Logto `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain, same origin) +- TLS: self-signed cert init container (`traefik-certs`) for dev, ACME for production +- Root `/` → `/platform/` redirect via Traefik file provider (`docker/traefik-dynamic.yml`) + +### Auth enforcement + - All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations - Tenant isolation enforced by `TenantIsolationInterceptor` (a single `HandlerInterceptor` on `/api/**` that resolves JWT org_id to TenantContext and validates `{tenantId}`, `{environmentId}`, `{appId}` path variables; fail-closed, platform admins bypass) -- 10 OAuth2 scopes defined on the Logto API resource (`https://api.cameleer.local`), served to the frontend from `GET /api/config` +- 10 OAuth2 scopes defined on the Logto API resource (`https://api.cameleer.local`), served to the frontend from `GET /platform/api/config` +- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch) + +### Server integration (cameleer3-server env vars) + +| Env var | Value | Purpose | +|---------|-------|---------| +| `CAMELEER_OIDC_ISSUER_URI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | Token issuer claim validation | +| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch | +| `CAMELEER_OIDC_TLS_SKIP_VERIFY` | `true` | Skip cert verify for OIDC discovery (dev) | +| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | Allow browser requests through Traefik | +| `BASE_PATH` (server-ui) | `/server` | React Router basename + `` tag | ## Related Conventions diff --git a/docs/superpowers/specs/2026-04-05-single-domain-routing-design.md b/docs/superpowers/specs/2026-04-05-single-domain-routing-design.md index a015404..d5cde7c 100644 --- a/docs/superpowers/specs/2026-04-05-single-domain-routing-design.md +++ b/docs/superpowers/specs/2026-04-05-single-domain-routing-design.md @@ -2,118 +2,103 @@ ## Problem -Customers cannot always provision subdomains (`auth.domain.com`, `server.domain.com`). The current architecture requires 3 DNS records. Some enterprise environments only allow a single hostname pointing at the server. - -## Root Cause - -Logto's sign-in UI serves assets at `/assets/*`. The SPA (Vite-built) also outputs assets to `/assets/*`. With path-based Traefik routing on a single domain, both conflict on the same prefix. +Customers cannot always provision subdomains. The platform must work with a single hostname and one DNS record. ## Solution -Move the SPA's asset output directory from `/assets/` to `/_app/`. Then Traefik can route `/assets/*` to Logto while the SPA's assets are served from `/_app/*` via the catch-all. Single domain, single DNS record, no subdomains. +Path-based routing on one domain. SaaS app at `/platform`, server-ui at `/server/`, Logto as catch-all. SPA assets moved to `/_app/` to avoid conflict with Logto's `/assets/`. -## Architecture +## Routing (all on `${PUBLIC_HOST}`) -### Routing (all on `${PUBLIC_HOST}`) +| Path | Target | Priority | Notes | +|------|--------|----------|-------| +| `/platform/*` | cameleer-saas:8080 | default | Spring context-path `/platform` | +| `/server/*` | cameleer3-server-ui:80 | default | Strip-prefix + `BASE_PATH=/server` | +| `/` | redirect → `/platform/` | 100 | Via `docker/traefik-dynamic.yml` | +| `/*` | logto:3001 | 1 (lowest) | Catch-all: sign-in, OIDC, assets | -| Path | Target | Priority | -|------|--------|----------| -| `/oidc/*` | logto:3001 | default | -| `/interaction/*` | logto:3001 | default | -| `/assets/*` | logto:3001 | default | -| `/server/*` | cameleer3-server-ui:80 (prefix stripped) | default | -| `/api/*` | cameleer-saas:8080 | default | -| `/*` (catch-all) | cameleer-saas:8080 | 1 (lowest) | +## Configuration -SPA assets at `/_app/*` are served by cameleer-saas through the catch-all route. +Two env vars control everything: +```env +PUBLIC_HOST=cameleer.mycompany.com +PUBLIC_PROTOCOL=https +``` -### Logto Configuration +## TLS + +- **Dev**: `traefik-certs` init container auto-generates self-signed cert on first boot +- **Production**: Traefik ACME (Let's Encrypt) +- HTTP→HTTPS redirect via Traefik entrypoint config + +## Logto - `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain as SPA) - OIDC issuer = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` -- Same origin for browser — no CORS, cookies work -- Logto Management API (`/api/*` on port 3001) is only accessed via Docker-internal networking, never through Traefik — no conflict with cameleer-saas `/api/*` +- Same origin — no CORS, cookies work +- Management API only via Docker-internal networking (`http://logto:3001`) +- Bootstrap (`docker/logto-bootstrap.sh`) creates apps, users, orgs, roles, scopes +- Traditional app has `skipConsent: true` for first-party SSO -### TLS +## SaaS App (cameleer-saas) -- Self-signed cert init container generates wildcard cert on first boot (dev) -- Traefik ACME (Let's Encrypt) for production -- HTTP→HTTPS redirect +- `server.servlet.context-path: /platform` — Spring handles prefix transparently +- Vite `base: '/platform/'`, `assetsDir: '_app'` +- BrowserRouter `basename="/platform"` +- API client: `API_BASE = '/platform/api'` +- Custom `JwtDecoder`: ES384 algorithm, `at+jwt` token type, split issuer-uri / jwk-set-uri +- Redirect URIs: `${PROTO}://${HOST}/platform/callback` -### Customer Bootstrap +## Server Integration (cameleer3-server) +| Env var | Value | Purpose | +|---------|-------|---------| +| `CAMELEER_OIDC_ISSUER_URI` | `${PROTO}://${HOST}/oidc` | Token issuer claim validation | +| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch | +| `CAMELEER_OIDC_TLS_SKIP_VERIFY` | `true` | Skip cert verify for OIDC discovery (dev only) | +| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PROTO}://${HOST}` | Browser requests through Traefik | + +Server OIDC requirements: +- ES384 signing algorithm (Logto default) +- `at+jwt` token type acceptance +- `X-Forwarded-Prefix` support for correct redirect_uri construction +- Branding endpoint (`/api/v1/branding/logo`) must be publicly accessible + +## Server UI (cameleer3-server-ui) + +| Env var | Value | Purpose | +|---------|-------|---------| +| `BASE_PATH` | `/server` | React Router basename + `` tag | +| `CAMELEER_API_URL` | `http://cameleer3-server:8081` | nginx API proxy target | + +Traefik strip-prefix removes `/server` before forwarding to nginx. Server-ui injects `` via `BASE_PATH`. + +## Bootstrap Redirect URIs + +```sh +# SPA (cameleer-saas) +SPA_REDIRECT_URIS=["${PROTO}://${HOST}/platform/callback"] +SPA_POST_LOGOUT_URIS=["${PROTO}://${HOST}/platform/login"] + +# Traditional (cameleer3-server) — both variants until X-Forwarded-Prefix is consistent +TRAD_REDIRECT_URIS=["${PROTO}://${HOST}/oidc/callback","${PROTO}://${HOST}/server/oidc/callback"] +TRAD_POST_LOGOUT_URIS=["${PROTO}://${HOST}","${PROTO}://${HOST}/server"] ``` -# .env -PUBLIC_HOST=cameleer.mycompany.com -PUBLIC_PROTOCOL=https -# DNS: 1 record -cameleer.mycompany.com → server IP +## Customer Bootstrap -# Start +```bash +# 1. Set domain +echo "PUBLIC_HOST=cameleer.mycompany.com" >> .env +echo "PUBLIC_PROTOCOL=https" >> .env + +# 2. Point DNS (1 record) +# cameleer.mycompany.com → server IP + +# 3. Start docker compose up -d ``` -## Changes by Repository - -### cameleer-saas (this repo) - -1. **`ui/vite.config.ts`** — Set `build.assetsDir: '_app'` -2. **`docker-compose.yml`** — Revert to path-based routing: - - Logto: `PathPrefix(/oidc) || PathPrefix(/interaction) || PathPrefix(/assets)` - - Server-UI: `PathPrefix(/server)` with strip prefix middleware - - SPA: `PathPrefix(/)` priority 1 (catch-all) — same as original - - Remove Host-based routing labels - - Logto `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (no `auth.` prefix) - - All `LOGTO_PUBLIC_ENDPOINT`, `LOGTO_ISSUER_URI` use `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (no `auth.`) -3. **`docker/logto-bootstrap.sh`** — Host header = `${HOST}` (matches ENDPOINT, no `auth.` prefix). Redirect URIs use `${PROTO}://${HOST}/callback`. -4. **`ui/src/config.ts`** — Fallback `logtoEndpoint` = `${window.location.origin}` (same origin) -5. **`ui/src/components/Layout.tsx`** — Dashboard link = `/server/` - -### cameleer3-server-ui (server team) - -Add configurable `BASE_PATH` env var so the SPA can be served from a subpath. - -**Build changes:** -- Accept `BASE_PATH` env var (default: `/`) -- At build time or container start, set Vite's `base` to `BASE_PATH` value -- If using runtime config: nginx rewrites `` to `` at container startup - -**nginx changes:** -- Serve SPA at the configured base path -- API proxy remains internal (`CAMELEER_API_URL` → `http://cameleer3-server:8081`) - -**In cameleer-saas docker-compose.yml:** -```yaml -cameleer3-server-ui: - environment: - CAMELEER_API_URL: http://cameleer3-server:8081 - BASE_PATH: /server/ -``` - -### cameleer3-server (server team) - -Add split JWK/issuer OIDC configuration so token validation works in Docker environments where the public issuer URL is not directly reachable from containers. - -**What to add:** -- New config property: `cameleer.oidc.jwk-set-uri` (or equivalent) -- When set, fetch JWKs from this URL instead of deriving from the OIDC discovery document -- Validate token `iss` claim against `cameleer.oidc.issuer-uri` (string comparison) -- Accept `at+jwt` token type header (Logto uses this instead of plain `jwt`) -- Algorithm: ES384 (Logto's signing algorithm) - -**Reference implementation:** `cameleer-saas/src/main/java/.../config/SecurityConfig.java` — custom `JwtDecoder` bean that does exactly this. - -**In cameleer-saas docker-compose.yml:** -```yaml -cameleer3-server: - environment: - CAMELEER_OIDC_ISSUER_URI: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc - CAMELEER_OIDC_JWK_SET_URI: http://logto:3001/oidc/jwks -``` - ## Future: Custom Sign-In UI (Roadmap) -For full auth UX control (branding, custom flows), build a custom sign-in experience using Logto's Experience API. This eliminates Logto's interaction pages entirely — Logto becomes a pure OIDC/API backend. The SPA renders all auth screens (login, register, forgot password, MFA, social login). - -This is the right long-term play for a SaaS product but is a separate project. +For full auth UX control, build a custom sign-in experience using Logto's Experience API. Eliminates Logto's interaction pages — Logto becomes a pure OIDC/API backend. Separate project.