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) <noreply@anthropic.com>
This commit is contained in:
32
CLAUDE.md
32
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 + `<base>` tag |
|
||||
|
||||
## Related Conventions
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user