docs: update architecture docs for single-domain /platform routing
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 10s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-06 09:43:14 +02:00
parent 5a8d38a946
commit 0082576063
2 changed files with 107 additions and 94 deletions

View File

@@ -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 + `<base>` tag |
## Related Conventions

View File

@@ -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 + `<base>` tag |
| `CAMELEER_API_URL` | `http://cameleer3-server:8081` | nginx API proxy target |
Traefik strip-prefix removes `/server` before forwarding to nginx. Server-ui injects `<base href="/server/">` 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 `<base href="/">` to `<base href="/server/">` 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.