2
Architecture
Patrick Plate edited this page 2026-06-24 15:23:58 +02:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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).

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.

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:

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 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.*)

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 Optional (override strongly recommended for production) PermissiveOrgValidator — returns true for any (org_type, org_id) and logs a WARN on every call ("OrgValidator default permissive — override OrgValidator bean before production") Validate (org_type, org_id) exists in consumer's companies / workspaces / etc.
OrgDisplayNameResolver Optional Returns type + ":" + 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. The PermissiveOrgValidator default exists so the starter boots green with zero consumer code; the per-call WARN log makes it impossible to ignore that no real validation is happening.


4. Public API surface (npm package)

4.1 Exports

// @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

# 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

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)

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

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

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
  • 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.