plan: add Architecture.md (tier model, flows, data model, packaging)

Patrick Plate
2026-06-24 14:12:19 +02:00
parent f1e45a066a
commit a083ae94a4
+525
@@ -0,0 +1,525 @@
# Architecture
**Status:** Draft v1
**Date:** 2026-06-24
**Owner:** Patrick (plate-software)
---
## 1. Two artifacts, one contract
plate-auth ships as **two coordinated artifacts** that share a single wire contract (the HMAC-signed
exchange envelope + the JWT shape).
```mermaid
flowchart LR
subgraph Backend["plate-auth-starter (Maven)"]
BE[de.platesoft.auth.*<br/>Spring Boot 4 auto-config]
end
subgraph Frontend["@platesoft/auth (npm)"]
FE[NextAuth v5 wiring +<br/>exchange + proxy helpers]
end
Wire[(Wire contract:<br/>POST /api/auth/exchange<br/>HMAC-SHA256 envelope<br/>JWT bearer)]
FE -.signs.-> Wire
BE -.verifies.-> Wire
```
The wire contract is the **only thing the two artifacts know about each other**. Either side can be
re-implemented in another language as long as the contract is honored.
---
## 2. Tier model — what is in, what is out
We split InspectFlow's auth/membership system into three tiers. plate-auth ships **T1 + T2 only**.
```mermaid
flowchart TB
subgraph T1["T1 — Auth Core (plate-auth-starter)"]
T1a[User + UserIdentity entities]
T1b[JwtAuthenticationFilter]
T1c[JwtService]
T1d[SecurityConfig auto-config]
T1e[OAuthController + ExchangeService]
T1f[AuthController login/register/refresh/me]
T1g[LoginEventService + revinfo actor]
end
subgraph T2["T2 — Multi-Tenancy (plate-auth-starter)"]
T2a[Membership entity + service]
T2b[Invitation entity + service + controller]
T2c[AccessRequest entity + service + controller]
T2d[OrgContextResolver]
T2e[AdminAuditController]
end
subgraph T3["T3 — Stays in consuming app"]
T3a[Company / Workspace / Club<br/>concrete org entity]
T3b[OnboardingService<br/>business-state machine]
T3c[TenantAutoMapService<br/>MS-tenant → company mapping]
T3d[Org-specific embeddings,<br/>industry codes, etc.]
end
T2 -.references via FK.-> T3
T2 -.OrgContextResolver hook.-> T3
```
**Why this cut:**
- **T1** has zero domain coupling — works for any product.
- **T2** is "user × org × role" — also generic, but its FK target (`org_id`) is per-product.
- **T3** is product-specific business logic. It would constrain consumers if we extracted it.
The seam between T2 and T3 is an **`OrgValidator` SPI** (Service Provider Interface) that the consuming app
implements:
```java
public interface OrgValidator {
boolean exists(OrgType type, UUID orgId);
String displayName(OrgType type, UUID orgId);
}
```
plate-auth's `MembershipService.grant(…)` calls `orgValidator.exists(…)` instead of joining to a `companies`
table directly. This is the only generic-↔-domain coupling point.
---
## 3. Public API surface (Spring Boot starter)
What a consumer can call / configure / override. Everything else is `package-private` or `@Internal`.
### 3.1 HTTP endpoints (mounted under `/api/auth/**` and `/api/`)
| Endpoint | Method | Public? | Purpose |
|---|---|---|---|
| `/api/auth/exchange` | POST | Public (HMAC-signed) | NextAuth → backend session bootstrap |
| `/api/auth/register` | POST | Public (if `plate.auth.registration.enabled=true`) | Password registration |
| `/api/auth/login` | POST | Public | Password login |
| `/api/auth/refresh` | POST | Public | Refresh token rotation |
| `/api/auth/logout` | POST | Public | Refresh token revocation |
| `/api/auth/me` | GET | Authenticated | Current user + memberships |
| `/api/auth/config` | GET | Public | Provider list + registration flag |
| `/api/invitations` | CRUD | Authenticated (admin) | Invite management |
| `/api/invitations/accept` | POST | Authenticated | Accept invite |
| `/api/access-requests` | CRUD | Authenticated (admin) | Access request management |
| `/api/admin/login-events` | GET | `hasRole('ADMIN')` | Audit log |
| `/api/admin/audit/revisions` | GET | `hasRole('ADMIN')` | Envers revision browser |
### 3.2 Spring beans the consumer can inject
| Bean | Purpose |
|---|---|
| [`JwtService`](Architecture.md:62) | Generate / parse JWTs. Issue tokens for arbitrary `User`. |
| `OAuthService` | Find-or-create User+Identity from exchange payload. |
| `MembershipService` | Grant / revoke / list memberships. |
| `InvitationService` | Create / accept / revoke invitations. |
| `AccessRequestService` | Submit / approve / deny access requests. |
| `LoginEventService` | Record login attempts (async). |
### 3.3 Configuration properties (`plate.auth.*`)
```yaml
plate:
auth:
jwt:
secret: ${PLATE_AUTH_JWT_SECRET} # ≥32 chars, HMAC-SHA256 key
access-expiration: PT15M # ISO-8601 duration
refresh-expiration: P30D
issuer: plate-auth
exchange:
secret: ${PLATE_AUTH_EXCHANGE_SECRET} # ≥32 chars, shared with frontend
max-age: PT60S # envelope freshness window
nonce-ttl: PT5M # replay-protection window
registration:
enabled: false # password registration on/off
cors:
allowed-origins: # space-separated or list
- https://app.example.com
providers:
google:
enabled: true
microsoft:
enabled: false # Entra ID — opt-in
email-magic-link:
enabled: false # NextAuth Email provider
```
### 3.4 Extension points (SPI)
Consumers implement these to plug their domain into plate-auth:
| Interface | Required? | Default | Purpose |
|---|---|---|---|
| `OrgValidator` | **Yes** (if T2 used) | — | Validate `org_id` exists in consumer's `companies` / `workspaces` / etc. |
| `OrgDisplayNameResolver` | Optional | Returns `org_id.toString()` | Pretty-print org for invitations / emails. |
| `InvitationMailer` | Optional | No-op logger | Send invitation emails. Default just logs the token URL. |
| `AccessRequestMailer` | Optional | No-op logger | Notify admins of new access requests. |
| `OnboardingHook` | Optional | No-op | Called on first successful sign-in — consumer's place to wire T3 onboarding. |
All extension points are `@ConditionalOnMissingBean` — register your own bean to override.
---
## 4. Public API surface (npm package)
### 4.1 Exports
```ts
// @platesoft/auth/config
export function createAuthConfig(opts: PlateAuthConfigOptions): NextAuthConfig;
// @platesoft/auth/exchange
export async function exchangeWithBackend(envelope: ExchangeEnvelope): Promise<TokenResponse>;
export function signEnvelope(envelope: object, secret: string): { signature: string; envelope: string };
// @platesoft/auth/proxy
export function createProxyHandlers(opts: ProxyOptions): {
GET: RouteHandler; POST: RouteHandler; PUT: RouteHandler; PATCH: RouteHandler; DELETE: RouteHandler;
};
// @platesoft/auth/middleware
export function createAuthMiddleware(opts?: MiddlewareOptions): NextMiddleware;
// @platesoft/auth/client
export { useSession, signIn, signOut } from 'next-auth/react'; // re-export
export function useAccessToken(): string | null;
export function useMemberships(): Membership[];
```
### 4.2 Required env vars
```bash
# Frontend (Next.js)
NEXTAUTH_SECRET=... # NextAuth session secret
NEXTAUTH_EXCHANGE_SECRET=... # MUST match backend plate.auth.exchange.secret
NEXT_PUBLIC_BACKEND_URL=https://api.example.com
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
# Optional
MICROSOFT_ENTRA_CLIENT_ID=...
MICROSOFT_ENTRA_CLIENT_SECRET=...
MICROSOFT_ENTRA_TENANT_ID=common
EMAIL_SERVER=... # only if magic-link enabled
EMAIL_FROM=...
```
---
## 5. End-to-end sign-in flow
```mermaid
sequenceDiagram
autonumber
actor U as User
participant FE as Browser (Next.js)
participant NA as NextAuth (/api/auth/[...nextauth])
participant G as Google
participant BE as Spring Boot (plate-auth)
participant DB as Postgres
U->>FE: click "Sign in with Google"
FE->>NA: signIn('google')
NA->>G: redirect to Google OAuth
G-->>NA: callback with id_token
NA->>NA: signIn callback fires
NA->>NA: build ExchangeEnvelope<br/>{provider, providerSubject, email, name, nonce, iat}
NA->>NA: sign envelope with HMAC-SHA256(NEXTAUTH_EXCHANGE_SECRET)
NA->>BE: POST /api/auth/exchange<br/>X-Exchange-Signature: <hmac><br/>body: envelope
BE->>BE: ExchangeService.verifyAndParse()<br/>(HMAC verify, max-age 60s, nonce dedup)
BE->>DB: SELECT user_identity WHERE provider+subject
alt Identity exists
DB-->>BE: User row
else New identity
BE->>DB: INSERT user + user_identity
BE->>BE: OnboardingHook.onFirstSignIn(user)
end
BE->>DB: INSERT login_event (success)
BE-->>NA: 200 {access_token, refresh_token, user, memberships}
NA->>NA: store tokens in NextAuth JWT
NA-->>FE: set session cookie
FE-->>U: redirect to app
```
Key invariants:
- **Step 5 (signIn callback)** is where NextAuth's "I authenticated this user" claim becomes plate-auth's
"I issue tokens for this user" — bridged by the HMAC envelope.
- **Step 7 (HMAC verify)** is the only thing preventing a malicious caller from minting tokens for any
Google account.
- **Step 8 (nonce dedup)** is the only thing preventing replay of a captured envelope within its 60s
freshness window.
- **Step 13 (`OnboardingHook`)** is the T3 escape hatch — consumers wire whatever first-login flow they need.
---
## 6. Request → API flow (authenticated)
```mermaid
sequenceDiagram
autonumber
actor U as User
participant FE as Browser
participant PR as Next.js /api/[...path] proxy
participant NA as NextAuth auth()
participant BE as Spring Boot
participant JF as JwtAuthFilter
participant OR as OrgContextResolver
participant CT as @RestController
U->>FE: action triggers fetch /api/things
FE->>PR: GET /api/things (no auth header — session cookie only)
PR->>NA: auth() — read session
NA-->>PR: {accessToken, user}
PR->>BE: GET /api/things<br/>Authorization: Bearer <accessToken><br/>X-Org-Id: <selectedOrg> (optional)
BE->>JF: JwtAuthFilter.doFilter()
JF->>JF: extract + verify JWT (HMAC SHA-256, issuer check)
JF->>JF: set SecurityContext (principal = email, authorities = [ROLE_<role>])
JF->>OR: OrgContextResolver.resolve()
OR->>OR: parse X-Org-Id, validate membership via MembershipService
OR->>OR: set OrgContext threadlocal
OR->>CT: forward to controller
CT-->>BE: response
BE-->>PR: 200 response (or 401/403)
PR-->>FE: passthrough response
```
The proxy pattern (step 2-6) is the key design choice: **the browser never sees the JWT directly**. The
JWT lives in the encrypted NextAuth session cookie; the proxy adds the `Authorization` header server-side
before forwarding. This sidesteps XSS token theft.
---
## 7. Data model
```mermaid
erDiagram
USERS ||--o{ USER_IDENTITIES : "1:N"
USERS ||--o{ MEMBERSHIPS : "1:N"
USERS ||--o{ INVITATIONS : "created_by"
USERS ||--o{ ACCESS_REQUESTS : "requested_by"
USERS ||--o{ LOGIN_EVENTS : "actor"
MEMBERSHIPS }o..|| ORG_TABLE : "(org_type, org_id)<br/>polymorphic FK"
INVITATIONS }o..|| ORG_TABLE : "polymorphic"
ACCESS_REQUESTS }o..|| ORG_TABLE : "polymorphic"
USERS {
uuid id PK
text email UK
text password_hash "nullable for OAuth-only"
text first_name
text last_name
text role "ROLE_USER | ROLE_ADMIN"
boolean active
uuid default_org_id "nullable"
text last_provider "google|microsoft|email|password"
bigint version "optimistic lock"
}
USER_IDENTITIES {
uuid id PK
uuid user_id FK
text provider "google|microsoft|email|password"
text subject "provider's stable user id"
text email
text tenant_id "MS Entra tenant, nullable"
timestamptz linked_at
timestamptz last_login_at
}
MEMBERSHIPS {
uuid id PK
uuid user_id FK
text org_type "COMPANY|WORKSPACE|CLUB|..."
uuid org_id "polymorphic — validated via OrgValidator SPI"
text role "OWNER|ADMIN|MEMBER|VIEWER"
text status "ACTIVE|SUSPENDED|REVOKED"
uuid granted_by FK
text grant_reason
timestamptz revoked_at
uuid revoked_by FK
}
INVITATIONS {
uuid id PK
text token UK "64-char single-use"
text email
text org_type
uuid org_id
text role
text status "PENDING|ACCEPTED|REVOKED|EXPIRED"
uuid created_by FK
timestamptz created_at
timestamptz expires_at
timestamptz accepted_at
}
ACCESS_REQUESTS {
uuid id PK
uuid requester_id FK
text org_type
uuid org_id
text requested_role "default VIEWER"
text justification
text status "PENDING|APPROVED|DENIED|EXPIRED"
uuid reviewer_id FK
text decision_reason
}
LOGIN_EVENTS {
bigint id PK
uuid user_id FK
text email "denormalized for failed attempts"
text provider
text outcome "SUCCESS|FAILURE|LOCKED"
text ip_address
text user_agent
text correlation_id
timestamptz occurred_at
}
```
**The polymorphic FK on `memberships.org_id`** is the only "loose" join in the schema. It is intentional:
plate-auth must not require a specific concrete org table. Validation is enforced via:
1. The `OrgValidator` SPI (application-level check at membership-grant time).
2. The `fn_membership_org_fk()` trigger (consumer-defined — InspectFlow's trigger validates against
`companies`; Sparkboard's will validate against `workspaces`).
---
## 8. Package layout
### 8.1 Maven module
```
plate-auth/
├── plate-auth-starter/ # the published artifact
│ ├── pom.xml # de.platesoft:plate-auth-starter:0.1.0
│ └── src/main/java/de/platesoft/auth/
│ ├── PlateAuthAutoConfiguration.java
│ ├── PlateAuthProperties.java
│ ├── config/
│ │ └── SecurityConfig.java
│ ├── filter/
│ │ ├── JwtAuthenticationFilter.java
│ │ └── OrgContextResolver.java
│ ├── service/
│ │ ├── JwtService.java
│ │ ├── OAuthService.java
│ │ ├── MembershipService.java
│ │ ├── InvitationService.java
│ │ ├── AccessRequestService.java
│ │ ├── LoginEventService.java
│ │ └── ExchangeService.java
│ ├── controller/
│ │ ├── AuthController.java
│ │ ├── OAuthController.java
│ │ ├── InvitationController.java
│ │ ├── AccessRequestController.java
│ │ └── AdminAuditController.java
│ ├── entity/
│ │ ├── User.java
│ │ ├── UserIdentity.java
│ │ ├── Membership.java
│ │ ├── Invitation.java
│ │ ├── AccessRequest.java
│ │ └── LoginEvent.java
│ ├── repository/...
│ ├── dto/
│ │ ├── request/...
│ │ └── response/...
│ ├── spi/
│ │ ├── OrgValidator.java
│ │ ├── OrgDisplayNameResolver.java
│ │ ├── InvitationMailer.java
│ │ ├── AccessRequestMailer.java
│ │ └── OnboardingHook.java
│ └── enums/
│ ├── OrgType.java
│ ├── MembershipRole.java
│ ├── MembershipStatus.java
│ ├── InvitationStatus.java
│ ├── AccessRequestStatus.java
│ ├── LoginProvider.java
│ └── Role.java
│ └── resources/
│ ├── META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
│ └── db/migration/auth/
│ ├── V1__create_users_and_identities.sql
│ ├── V2__create_memberships.sql
│ ├── V3__create_invitations.sql
│ ├── V4__create_access_requests.sql
│ ├── V5__add_microsoft_tenant_id_index.sql
│ └── V6__create_login_events_and_revinfo_actor.sql
└── plate-auth-tests/ # internal integration tests, not published
```
### 8.2 npm package
```
@platesoft/auth/
├── package.json # @platesoft/auth@0.1.0
├── src/
│ ├── config/
│ │ ├── index.ts # createAuthConfig()
│ │ ├── providers/
│ │ │ ├── google.ts
│ │ │ ├── microsoft.ts
│ │ │ └── email.ts
│ │ └── callbacks/
│ │ ├── signIn.ts # exchange-with-backend signIn
│ │ └── jwt.ts
│ ├── exchange/
│ │ ├── envelope.ts # signEnvelope, verifyEnvelope (Edge-compatible HMAC)
│ │ └── client.ts # exchangeWithBackend
│ ├── proxy/
│ │ └── handlers.ts # createProxyHandlers
│ ├── middleware/
│ │ └── index.ts # createAuthMiddleware
│ └── client/
│ ├── hooks.ts # useAccessToken, useMemberships
│ └── index.ts # re-exports from next-auth/react
└── tests/
```
---
## 9. Distribution
```mermaid
flowchart LR
Source[plate-auth git repo<br/>git.plate-software.de/pplate/plate-auth] --> CI{Gitea Actions}
CI -->|tag v0.x.y| MavenReg[Gitea Package Registry<br/>Maven]
CI -->|tag v0.x.y| NpmReg[Gitea Package Registry<br/>npm]
MavenReg --> IF1[InspectFlow backend]
MavenReg --> SB1[Sparkboard backend]
NpmReg --> IF2[InspectFlow frontend]
NpmReg --> SB2[Sparkboard frontend]
```
- Single git repo, two artifact pipelines.
- Tag `v0.1.0` triggers publish of both `de.platesoft:plate-auth-starter:0.1.0` and `@platesoft/auth@0.1.0`.
**Versions stay locked in lockstep** — frontend 0.2.0 implies backend 0.2.0 is the required peer.
- Gitea Package Registry is the only distribution channel. No public registry.
---
## 10. Threat model summary
| Threat | Mitigation |
|---|---|
| Forged exchange envelope | HMAC-SHA256 with ≥32-char shared secret, constant-time compare |
| Replay of captured envelope | 60s `max-age` window + 5min nonce dedup store |
| JWT theft via XSS | Token never sent to browser — proxy injects it server-side |
| JWT forgery | HMAC-SHA256, ≥32-char secret, issuer + expiration validation |
| Membership escalation | Rank-based `MembershipService.grant()` — cannot grant higher role than your own |
| Invitation token enumeration | 64-char URL-safe random, single-use, expires after 7d (configurable) |
| Access request DoS | Rate-limit 3 per user per day |
| Replay across requests | JWT is short-lived (15min); refresh tokens are rotated |
| Audit gap | All identity-changing ops emit revinfo with `actor_user_id`; failed logins emit `login_event` |
Full threat model lives in `Sprint-0-Plan.md` § "Security review checklist".
---
## 11. What this document is not
- ❌ Not a step-by-step extraction plan → see [`Sprint-0-Plan.md`](Sprint-0-Plan.md)
- ❌ Not a per-API specification → generated from JavaDoc / TSDoc at v0.2
- ❌ Not the threat model in full → see Sprint-0-Plan § Security
It is the **shape** of the system. The rest of the wiki fills in the pixels.