plan: add Integration-Guide.md (greenfield Sparkboard 5-min setup)
+453
@@ -0,0 +1,453 @@
|
||||
# Integration Guide
|
||||
|
||||
**Status:** Draft v1
|
||||
**Date:** 2026-06-24
|
||||
**Owner:** Patrick
|
||||
**Audience:** Greenfield Spring Boot 4.1 + Next.js 15 App Router consumers (primary target: Sparkboard)
|
||||
**Library version:** `0.1.0`
|
||||
|
||||
> If you are migrating an existing app that already has its own auth (InspectFlow), see [Migration-InspectFlow.md](Migration-InspectFlow.md) instead.
|
||||
|
||||
---
|
||||
|
||||
## 1. What you get
|
||||
|
||||
By adding `de.platesoft:plate-auth-starter:0.1.0` (backend) + `@platesoft/auth:0.1.0` (frontend) to your project, you get:
|
||||
|
||||
- **Backend:** REST endpoints for login, signup (opt-in), password reset, OAuth (Google), token exchange (HMAC-signed envelope), JWT-protected filter, audit, memberships, invitations, access requests.
|
||||
- **Frontend:** NextAuth v5 config factory, edge-runtime API proxy, HMAC envelope sign/verify helpers, optional React components (`<AuthProvider>`, `<LoginForm>`, `<SignupForm>`).
|
||||
- **Schema:** Five Postgres tables created via Flyway in a private `flyway_schema_history_auth` table.
|
||||
- **Extension points (SPI):** `OrgValidator`, `OrgDisplayNameResolver`, `InvitationMailer`, `AccessRequestMailer`, `OnboardingHook` — implement to plug your domain in without forking.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
| Item | Required | Notes |
|
||||
|------|----------|-------|
|
||||
| Java | 25 LTS | Matches Spring Boot 4.1 minimum |
|
||||
| Spring Boot | 4.1.0+ | 4.0.x not supported (see [Open-Questions.md](Open-Questions.md) Q08) |
|
||||
| Postgres | 14+ (16 recommended) | Or compatible (e.g. CockroachDB — untested) |
|
||||
| Node | 20+ (22 LTS recommended) | NextAuth v5 + Edge runtime |
|
||||
| Next.js | 15+ App Router | Pages Router not supported |
|
||||
| Gitea Maven registry access | Yes | URL + token in `~/.m2/settings.xml` |
|
||||
| Gitea npm registry access | Yes | `.npmrc` with `@platesoft:registry=...` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Five-minute setup (backend)
|
||||
|
||||
### 3.1 Add Maven dependency
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>de.platesoft</groupId>
|
||||
<artifactId>plate-auth-starter</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Configure the Gitea registry in `pom.xml`:
|
||||
|
||||
```xml
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>gitea-platesoft</id>
|
||||
<url>https://git.plate-software.de/api/packages/platesoft/maven</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
```
|
||||
|
||||
Add credentials to `~/.m2/settings.xml`:
|
||||
|
||||
```xml
|
||||
<server>
|
||||
<id>gitea-platesoft</id>
|
||||
<username>your-gitea-user</username>
|
||||
<password>your-gitea-token</password>
|
||||
</server>
|
||||
```
|
||||
|
||||
### 3.2 Required configuration (application.yml)
|
||||
|
||||
```yaml
|
||||
plate:
|
||||
auth:
|
||||
jwt:
|
||||
secret: ${PLATE_AUTH_JWT_SECRET} # 32+ chars, hex/base64
|
||||
access-expiration: PT15M
|
||||
refresh-expiration: P30D
|
||||
issuer: my-app
|
||||
exchange:
|
||||
secret: ${PLATE_AUTH_EXCHANGE_SECRET} # 32+ chars, distinct from jwt secret
|
||||
max-age: PT60S
|
||||
nonce-ttl: PT5M
|
||||
registration:
|
||||
enabled: false # invite-only by default
|
||||
cors:
|
||||
allowed-origins:
|
||||
- https://app.example.com
|
||||
providers:
|
||||
google:
|
||||
enabled: true
|
||||
client-id: ${GOOGLE_CLIENT_ID}
|
||||
client-secret: ${GOOGLE_CLIENT_SECRET}
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/myapp
|
||||
username: ${DB_USER}
|
||||
password: ${DB_PASSWORD}
|
||||
```
|
||||
|
||||
That's it for the bare minimum. Run your app — the starter auto-configures.
|
||||
|
||||
### 3.3 What auto-config wires for you
|
||||
|
||||
| Bean | Purpose |
|
||||
|------|---------|
|
||||
| `JwtService` | Mints + parses JWTs |
|
||||
| `ExchangeService` | HMAC envelope mint/consume |
|
||||
| `JwtAuthenticationFilter` | Reads `Authorization: Bearer ...`, populates `SecurityContext` |
|
||||
| `SecurityFilterChain` (named `plateAuthSecurityChain`) | Whitelists `/auth/**`, requires JWT elsewhere |
|
||||
| `OrgValidator` (default = `PermissiveOrgValidator`) | Accepts any `(org_type, org_id)` — replace via SPI |
|
||||
| `InvitationMailer` (default = `LoggingMailer`) | Logs mails to console — replace via SPI |
|
||||
| `AccessRequestMailer` (default = `LoggingMailer`) | Same |
|
||||
| `OnboardingHook` (default = `NoopOnboardingHook`) | Does nothing — replace via SPI |
|
||||
|
||||
All defaults are `@ConditionalOnMissingBean` — define your own bean of the same type to override.
|
||||
|
||||
### 3.4 Plug in your domain (SPI seams)
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class PlateAuthSpiConfig {
|
||||
|
||||
@Bean
|
||||
public OrgValidator orgValidator(StudioRepository repo) {
|
||||
return (orgType, orgId) ->
|
||||
"STUDIO".equals(orgType) && repo.existsById(orgId);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OrgDisplayNameResolver orgDisplayNameResolver(StudioRepository repo) {
|
||||
return (orgType, orgId) -> repo.findById(orgId)
|
||||
.map(Studio::getName)
|
||||
.orElse("Unknown");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public InvitationMailer invitationMailer(MailService mail) {
|
||||
return (toEmail, inviter, orgName, token) ->
|
||||
mail.send(toEmail, "Invite to " + orgName, "Open: https://app.example.com/invite/" + token);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OnboardingHook onboardingHook(StudioService studios) {
|
||||
return (userId, email, signupContext) -> {
|
||||
// e.g. create a personal default studio for new sign-ups
|
||||
studios.createForUser(userId, email);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Protect your endpoints
|
||||
|
||||
By default, anything outside `/auth/**` requires a valid JWT. To customize:
|
||||
|
||||
```java
|
||||
@Bean
|
||||
@Order(0) // before plateAuthSecurityChain
|
||||
public SecurityFilterChain myCustomChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.securityMatcher("/public/**")
|
||||
.authorizeHttpRequests(a -> a.anyRequest().permitAll())
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
To read the current user in a controller:
|
||||
|
||||
```java
|
||||
@GetMapping("/api/me")
|
||||
public MeResponse me(@AuthenticationPrincipal Jwt principal) {
|
||||
return new MeResponse(principal.getSubject(), principal.getClaim("email"));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Five-minute setup (frontend)
|
||||
|
||||
### 4.1 Add npm dependency
|
||||
|
||||
```bash
|
||||
# .npmrc must contain:
|
||||
# @platesoft:registry=https://git.plate-software.de/api/packages/platesoft/npm/
|
||||
# //git.plate-software.de/api/packages/platesoft/npm/:_authToken=YOUR_TOKEN
|
||||
|
||||
pnpm add @platesoft/auth@0.1.0
|
||||
```
|
||||
|
||||
### 4.2 Required env vars
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=... # NextAuth v5 session secret, 32+ chars
|
||||
PLATE_AUTH_EXCHANGE_SECRET=... # MUST match backend plate.auth.exchange.secret
|
||||
PLATE_AUTH_BACKEND_URL=http://localhost:8080
|
||||
GOOGLE_CLIENT_ID=...
|
||||
GOOGLE_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
### 4.3 Wire NextAuth via factory
|
||||
|
||||
Create `frontend/auth.ts`:
|
||||
|
||||
```typescript
|
||||
import { createAuthConfig } from "@platesoft/auth/server";
|
||||
import NextAuth from "next-auth";
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(
|
||||
createAuthConfig({
|
||||
backendUrl: process.env.PLATE_AUTH_BACKEND_URL!,
|
||||
exchangeSecret: process.env.PLATE_AUTH_EXCHANGE_SECRET!,
|
||||
nextAuthSecret: process.env.NEXTAUTH_SECRET!,
|
||||
providers: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### 4.4 NextAuth route handler (boilerplate, required)
|
||||
|
||||
Create `frontend/app/api/auth/[...nextauth]/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { handlers } from "@/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
```
|
||||
|
||||
> **Note:** NextAuth v5 requires this exact filename for its OAuth callback URLs to work. Do not rename it. This is a NextAuth limitation, not ours — see [Roadmap.md](Roadmap.md) for "remove boilerplate" v0.2 ambition.
|
||||
|
||||
### 4.5 API proxy (forward authenticated requests to backend)
|
||||
|
||||
Create `frontend/app/api/[...path]/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { createProxyHandlers } from "@platesoft/auth/edge";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export const { GET, POST, PUT, PATCH, DELETE } = createProxyHandlers({
|
||||
backendUrl: process.env.PLATE_AUTH_BACKEND_URL!,
|
||||
exchangeSecret: process.env.PLATE_AUTH_EXCHANGE_SECRET!,
|
||||
});
|
||||
```
|
||||
|
||||
### 4.6 Optional React components
|
||||
|
||||
```tsx
|
||||
// app/login/page.tsx
|
||||
import { LoginForm } from "@platesoft/auth/react";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginForm onSuccess={() => window.location.href = "/dashboard"} />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { AuthProvider } from "@platesoft/auth/react";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default async function RootLayout({ children }) {
|
||||
const session = await auth();
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<AuthProvider session={session}>{children}</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If you want to ship your own UI, skip the React import — only `@platesoft/auth/server` + `@platesoft/auth/edge` are required.
|
||||
|
||||
---
|
||||
|
||||
## 5. Database setup
|
||||
|
||||
The starter ships Flyway migrations under classpath:`db/migration/plate-auth/`. They run automatically on startup against a **dedicated history table** (`flyway_schema_history_auth`).
|
||||
|
||||
If your app also uses Flyway with its own migrations:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
flyway:
|
||||
enabled: true # your migrations, your history table
|
||||
locations: classpath:db/migration
|
||||
table: flyway_schema_history # YOUR table
|
||||
```
|
||||
|
||||
The starter's Flyway bean (`plateAuthFlyway`) runs **before** the application's main Flyway:
|
||||
|
||||
| Order | Bean | Locations | History table |
|
||||
|-------|------|-----------|---------------|
|
||||
| 1 | `plateAuthFlyway` | `db/migration/plate-auth` | `flyway_schema_history_auth` |
|
||||
| 2 | (your default Flyway) | `db/migration` | `flyway_schema_history` |
|
||||
|
||||
You don't need to configure the starter's Flyway — it's wired automatically.
|
||||
|
||||
### 5.1 What's created
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `users` | Identity record + password hash (nullable for OAuth-only) |
|
||||
| `user_identities` | Provider linkage (google, password, ms-entra) |
|
||||
| `memberships` | `(user_id, org_type, org_id, role)` — polymorphic FK |
|
||||
| `invitations` | Token-hashed invitations |
|
||||
| `access_requests` | Self-service access requests |
|
||||
| `login_events` | Audit (success/failure, IP, UA) |
|
||||
|
||||
See [Architecture.md § 6 ER diagram](Architecture.md) for the schema.
|
||||
|
||||
---
|
||||
|
||||
## 6. Minimum viable consumer app — full example
|
||||
|
||||
A working **hello world** consumer:
|
||||
|
||||
### Backend (`SparkboardApplication.java`)
|
||||
|
||||
```java
|
||||
@SpringBootApplication
|
||||
public class SparkboardApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(SparkboardApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
class HelloController {
|
||||
@GetMapping("/api/hello")
|
||||
public Map<String, String> hello(@AuthenticationPrincipal Jwt principal) {
|
||||
return Map.of("hello", principal.getClaim("email"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That's it. Add the dependency, set the 4 env vars, and `/api/hello` is JWT-protected.
|
||||
|
||||
### Frontend (`app/page.tsx`)
|
||||
|
||||
```tsx
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Home() {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const res = await fetch("http://localhost:3000/api/hello", {
|
||||
headers: { Cookie: (await import("next/headers")).cookies().toString() },
|
||||
});
|
||||
const { hello } = await res.json();
|
||||
return <h1>Hello, {hello}</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Operational considerations
|
||||
|
||||
### 7.1 Secrets
|
||||
|
||||
| Secret | Length | Rotation strategy (v0.1) |
|
||||
|--------|--------|-------------------------|
|
||||
| `plate.auth.jwt.secret` | ≥32 chars | Rotate → all sessions invalidated. Plan downtime. |
|
||||
| `plate.auth.exchange.secret` | ≥32 chars | Rotate → all in-flight envelopes rejected. Coordinate rolling deploy with frontend. |
|
||||
| `NEXTAUTH_SECRET` | ≥32 chars | Rotate → all NextAuth sessions invalidated. |
|
||||
|
||||
v0.2 will add multi-key support (key-id header). For v0.1: rotate during planned maintenance.
|
||||
|
||||
### 7.2 Logging
|
||||
|
||||
Set `logging.level.de.platesoft.auth=INFO` in production. `DEBUG` reveals envelope details (without secrets) — useful for troubleshooting but verbose.
|
||||
|
||||
### 7.3 Replication / scale-out
|
||||
|
||||
v0.1 nonce store is **in-memory** (`ConcurrentHashMap`). Replicas do not share the nonce set. Consequence: if the frontend sends the same envelope to a different replica, the replay-detection misses. Mitigations:
|
||||
|
||||
- Sticky sessions (load balancer affinity by user)
|
||||
- Single replica (acceptable for v0.1 consumers)
|
||||
- Wait for v0.2 `NonceStore` SPI with Redis backend
|
||||
|
||||
### 7.4 Migrations on existing DBs
|
||||
|
||||
For greenfield (Sparkboard): nothing special. For existing DBs with conflicting tables (e.g. you already have `users`): rename your tables or talk to Patrick — there's no `tablePrefix` option in v0.1 (see [Roadmap.md](Roadmap.md) v0.2).
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification checklist (after first deploy)
|
||||
|
||||
Run these manually to verify the integration is healthy:
|
||||
|
||||
- [ ] App boots without errors. Logs show `Started ... in X seconds`.
|
||||
- [ ] `curl http://localhost:8080/auth/health` returns 200 OK.
|
||||
- [ ] DB has 5 new tables under your schema. `flyway_schema_history_auth` has 5 rows.
|
||||
- [ ] `curl -d '{"email":"a","password":"b"}' http://localhost:8080/auth/login` returns 401 with structured JSON error (not a stack trace).
|
||||
- [ ] Frontend `/login` page renders.
|
||||
- [ ] Sign-in with Google works → redirect → `/api/hello` returns email.
|
||||
- [ ] No `WARN` logs about missing SPI defaults (you should have replaced `PermissiveOrgValidator` and `LoggingMailer`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Common pitfalls
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `BindValidationException: jwt.secret` at boot | Secret missing or < 32 chars | Set env var |
|
||||
| 401 on every `/api/...` call from frontend | `exchangeSecret` mismatch between FE and BE | Verify both sides read same secret |
|
||||
| `OAuth: redirect_uri_mismatch` from Google | Google console not updated with your URL | Add `https://app.example.com/api/auth/callback/google` to Google OAuth client |
|
||||
| `409 nonce already used` on every login | Sticky sessions disabled, multiple replicas | Use single replica or wait for v0.2 NonceStore |
|
||||
| Flyway: "schema not empty" on a fresh DB | Your existing tables collide with `users`, etc. | See [Migration-InspectFlow.md](Migration-InspectFlow.md) for the existing-DB recipe |
|
||||
| `@AuthenticationPrincipal` returns null | Filter chain order — your custom chain ran before `plateAuthSecurityChain` and short-circuited | Set `@Order` on your custom chain explicitly |
|
||||
|
||||
---
|
||||
|
||||
## 10. Next steps after first integration
|
||||
|
||||
1. Replace `PermissiveOrgValidator` with a real one (your org table).
|
||||
2. Replace `LoggingMailer` with `JavaMailSender` + your SMTP config.
|
||||
3. Add `OnboardingHook` to create default resources for new users.
|
||||
4. Subscribe to [Roadmap.md](Roadmap.md) v0.2 — refresh-token rotation + magic-link + multi-key secrets.
|
||||
5. Read [Open-Questions.md](Open-Questions.md) to understand which decisions might shift in v0.2.
|
||||
|
||||
---
|
||||
|
||||
## 11. Cross-references
|
||||
|
||||
- [Home.md](Home.md)
|
||||
- [Vision.md](Vision.md)
|
||||
- [Architecture.md](Architecture.md)
|
||||
- [Roadmap.md](Roadmap.md)
|
||||
- [Sprint-0-Assessment.md](Sprint-0-Assessment.md)
|
||||
- [Sprint-0-Plan.md](Sprint-0-Plan.md)
|
||||
- [Sprint-0-Testplan.md](Sprint-0-Testplan.md)
|
||||
- [Open-Questions.md](Open-Questions.md)
|
||||
- [Migration-InspectFlow.md](Migration-InspectFlow.md)
|
||||
|
||||
---
|
||||
|
||||
**End of Integration-Guide.md (v1).**
|
||||
Reference in New Issue
Block a user