2
Integration Guide
Patrick Plate edited this page 2026-06-24 15:23:58 +02:00

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

<dependency>
    <groupId>de.platesoft</groupId>
    <artifactId>plate-auth-starter</artifactId>
    <version>0.1.0</version>
</dependency>

Configure the Gitea registry in pom.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:

<server>
    <id>gitea-platesoft</id>
    <username>your-gitea-user</username>
    <password>your-gitea-token</password>
</server>

3.2 Required configuration (application.yml)

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) and logs a WARN on every call ("OrgValidator default permissive — override de.platesoft.auth.spi.OrgValidator bean before production") — replace via SPI before production
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)

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

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

@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

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

# .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:

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:

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 for "remove boilerplate" v0.2 ambition.

4.5 API proxy (forward authenticated requests to backend)

Create frontend/app/api/[...path]/route.ts:

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

// app/login/page.tsx
import { LoginForm } from "@platesoft/auth/react";

export default function LoginPage() {
  return <LoginForm onSuccess={() => window.location.href = "/dashboard"} />;
}
// 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:

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 for the schema.


6. Minimum viable consumer app — full example

A working hello world consumer:

Backend (SparkboardApplication.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)

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 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 6 new tables / index objects under your schema. flyway_schema_history_auth has 6 rows (V1..V6).
  • 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.
  • WARN logs "OrgValidator default permissive — override ..." are gone — meaning you registered a real OrgValidator bean. Until you do, the default fires on every membership check.
  • No remaining LoggingMailer warnings (you should have replaced InvitationMailer and AccessRequestMailer).

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 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 v0.2 — refresh-token rotation + magic-link + multi-key secrets.
  5. Read Open-Questions.md to understand which decisions might shift in v0.2.

11. Cross-references


End of Integration-Guide.md (v1).