2
Integration Guide
Patrick Plate edited this page 2026-06-24 15:28:48 +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.

Integration Guide — Sparkboard ⇆ plate-auth

Status: Draft v1 Audience: Anyone wiring a new plate-software app against plate-auth (Sparkboard is the worked example) Last updated: 2026-06-24 Companion to: plate-auth/Integration-Guide

This page is the Sparkboard-specific walkthrough. It assumes you've read the generic plate-auth Integration Guide and shows how the abstract pieces map to Sparkboard's concrete code.


1. What Sparkboard imports from plate-auth

Sparkboard depends on exactly two artifacts:

Artifact Version Where it shows up
de.platesoft:plate-auth-starter 0.1.0 backend/pom.xml
@platesoft/auth 0.1.0 frontend/package.json

That's it. There is no third artifact. There is no Sparkboard-specific fork. There is no "we patched plate-auth to do X."

If Sparkboard needs new behaviour that doesn't fit the SPI seams, the change goes upstream into plate-auth, gets released as v0.2.0 or v0.1.1, and Sparkboard bumps its dependency. Sparkboard never customises plate-auth in-tree.


2. The one SPI Sparkboard implements

Of plate-auth's 5 SPI seams (OrgValidator, OrgDisplayNameResolver, InvitationMailer, AccessRequestMailer, OnboardingHook), Sparkboard implements only one: OnboardingHook.

The other four:

SPI What plate-auth does by default Why Sparkboard doesn't override
OrgValidator "Allow any org_type" Sparkboard only has one org_type (SPARK_ORG) and one org instance; nothing to validate against.
OrgDisplayNameResolver Returns "Untitled org" Sparkboard's single org is hard-coded as "Family Spark" via the seed migration. The resolver is never queried because no UI surfaces multi-org.
InvitationMailer No-op (log only) Sparkboard has no invite UI in v1.
AccessRequestMailer No-op (log only) Sparkboard has no access-request flow.

Sparkboard's SparkboardOnboardingHook is the entire Java auth integration. The full impl sketch:

// backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardOnboardingHook.java
package de.plate.sparkboard.onboarding;

import de.platesoft.auth.spi.OnboardingHook;
import de.platesoft.auth.model.AuthenticatedUser;
import de.platesoft.auth.service.MembershipService;
import org.springframework.stereotype.Component;
import java.util.UUID;

@Component
public class SparkboardOnboardingHook implements OnboardingHook {

    public static final UUID FAMILY_SPARK_ID =
        UUID.fromString("00000000-0000-0000-0000-000000000001");
    public static final String SPARK_ORG_TYPE = "SPARK_ORG";

    private final MembershipService memberships;
    private final SparkboardAdminProperties adminProps;

    public SparkboardOnboardingHook(MembershipService memberships,
                                    SparkboardAdminProperties adminProps) {
        this.memberships = memberships;
        this.adminProps = adminProps;
    }

    @Override
    public void onFirstSignIn(AuthenticatedUser user) {
        var role = adminProps.admins().contains(user.email()) ? "ADMIN" : "MEMBER";
        memberships.upsert(user.id(), SPARK_ORG_TYPE, FAMILY_SPARK_ID, role);
    }
}

That's the entire SPI footprint. Sparkboard is a @Component, plate-auth's auto-config picks it up via Spring's ObjectProvider<OnboardingHook>, and the hook fires every login (idempotent thanks to upsert).


3. Backend wire-up — line-by-line

3.1 backend/pom.xml excerpt

<project>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
    </parent>

    <properties>
        <java.version>25</java.version>
        <plate-auth.version>0.1.0</plate-auth.version>
    </properties>

    <repositories>
        <repository>
            <id>gitea-platesoft</id>
            <url>https://git.plate-software.de/api/packages/platesoft/maven</url>
        </repository>
    </repositories>

    <dependencies>
        <dependency>
            <groupId>de.platesoft</groupId>
            <artifactId>plate-auth-starter</artifactId>
            <version>${plate-auth.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
</project>

Note: plate-auth transitively brings Spring Security, Resource Server (JWT), and its own Flyway migrations. Sparkboard's pom.xml doesn't list those — that's the whole point of the starter.

3.2 backend/src/main/resources/application.yml

The complete plate-auth-relevant config block:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/sparkboard
    username: ${DB_USER}
    password: ${DB_PASSWORD}
  flyway:
    enabled: true
    locations: classpath:db/migration
    # Sparkboard's history table
    table: flyway_schema_history

# plate-auth section
plate:
  auth:
    base-url: http://localhost:8080
    public-url: https://sparkboard.plate-software.de
    jwt:
      issuer: https://sparkboard.plate-software.de
      audience: sparkboard-frontend
      secret: ${PLATE_AUTH_JWT_SECRET}
      lifetime: PT12H
    oauth:
      google:
        client-id: ${GOOGLE_CLIENT_ID}
        client-secret: ${GOOGLE_CLIENT_SECRET}
        redirect-uri: ${PLATE_AUTH_GOOGLE_REDIRECT_URI:http://localhost:8080/api/auth/callback/google}
    allowlist:
      - patrick@plate-software.de
      - friend@example.com
      - kid1@example.com
      - kid2@example.com
    flyway:
      # plate-auth's separate history table — prevents collision with Sparkboard's
      history-table: flyway_schema_history_auth
    onboarding:
      auto-create-membership: false   # we handle that in SparkboardOnboardingHook
      first-org-type: SPARK_ORG       # informs plate-auth which org_type to use if it ever creates one
    exchange:
      secret: ${PLATE_AUTH_EXCHANGE_SECRET}

# Sparkboard-specific config
sparkboard:
  admins:
    - patrick@plate-software.de
    - friend@example.com

Key points:

  • plate.auth.flyway.history-table is set to flyway_schema_history_auth so the two Flyway histories never collide.
  • plate.auth.onboarding.auto-create-membership: false — we explicitly opt-out of plate-auth's built-in membership creation because SparkboardOnboardingHook handles it.
  • sparkboard.admins[] is a Sparkboard property, not a plate-auth property. It's read by SparkboardAdminProperties.

3.3 What auto-config wires for Sparkboard

Sparkboard writes zero lines of Spring Security config. plate-auth auto-config wires:

Bean What it does
SecurityFilterChain authChain Permits /api/auth/**.
SecurityFilterChain apiChain Requires authentication for /api/**. JWT bearer via Resource Server.
JwtDecoder Decodes Sparkboard JWTs using plate.auth.jwt.secret.
@CurrentUser argument resolver Injects an AuthenticatedUser into controllers.
Onboarding hook orchestrator Calls SparkboardOnboardingHook.onFirstSignIn(...) on first plate-auth sign-in per user.
Flyway migrations Loads from classpath:db/migration-auth/ into flyway_schema_history_auth.

Sparkboard's controllers therefore look like this — no auth ceremony at all:

@RestController
@RequestMapping("/api/ideas")
public class IdeaController {

    @PostMapping
    public IdeaDto create(@CurrentUser AuthenticatedUser user,
                          @Valid @RequestBody CreateIdeaRequest req) {
        return service.create(req, user.id(), SparkboardOnboardingHook.FAMILY_SPARK_ID);
    }
}

@CurrentUser is the integration point. If you see it, you know the request is authenticated and you have the user's identity.


4. Frontend wire-up — line-by-line

4.1 frontend/package.json excerpt

{
  "name": "sparkboard-frontend",
  "version": "0.1.0",
  "dependencies": {
    "@platesoft/auth": "0.1.0",
    "next": "15.0.0",
    "react": "19.0.0",
    "react-dom": "19.0.0",
    "next-auth": "5.0.0"
  }
}

4.2 frontend/.npmrc

@platesoft:registry=https://git.plate-software.de/api/packages/platesoft/npm/
//git.plate-software.de/api/packages/platesoft/npm/:_authToken=${GITEA_NPM_TOKEN}

GITEA_NPM_TOKEN is set in .env.local (dev) and in Gitea Actions secrets (CI).

4.3 frontend/.env.local excerpt

NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
PLATE_AUTH_EXCHANGE_SECRET=<same as backend>
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=<random 32 bytes base64>
GOOGLE_CLIENT_ID=<your-client-id>
GOOGLE_CLIENT_SECRET=<your-secret>
GITEA_NPM_TOKEN=<your-token>

4.4 frontend/lib/auth.ts

The factory call. Sparkboard writes ~15 lines of auth config:

import { createAuthConfig } from "@platesoft/auth/next-auth";
import Google from "next-auth/providers/google";

export const authConfig = createAuthConfig({
  providers: [Google({ clientId: process.env.GOOGLE_CLIENT_ID!,
                       clientSecret: process.env.GOOGLE_CLIENT_SECRET! })],
  exchange: {
    backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL!,
    secret: process.env.PLATE_AUTH_EXCHANGE_SECRET!,
  },
  pages: { signIn: "/login" },
});

export const { handlers, signIn, signOut, auth } = authConfig;

That's the complete NextAuth v5 setup. No callbacks, no JWT customisation, no signIn handlers. The factory does all of it.

4.5 frontend/app/api/auth/[...nextauth]/route.ts

export { GET, POST } from "@/lib/auth";

(Yes, one line.)

4.6 frontend/app/api/backend/[...path]/route.ts

The proxy. Sparkboard never lets the browser call the backend directly:

import { createProxyHandlers } from "@platesoft/auth/proxy";

const { GET, POST, PUT, DELETE, PATCH } = createProxyHandlers({
  backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL!,
});

export { GET, POST, PUT, DELETE, PATCH };

4.7 frontend/middleware.ts

export { middleware, config } from "@platesoft/auth/middleware";

The middleware redirects unauthenticated requests away from protected routes to /login.

4.8 Optional: React components

If Sparkboard needs a "Sign in with Google" button, plate-auth ships one:

import { SignInButton } from "@platesoft/auth/components";

export default function LoginPage() {
  return (
    <div>
      <h1>Sparkboard</h1>
      <SignInButton provider="google">Sign in with Google</SignInButton>
    </div>
  );
}

Or roll your own using signIn from lib/auth.ts.


5. Database setup

5.1 Two histories, one schema

postgres
└── sparkboard (database)
    ├── flyway_schema_history          (Sparkboard's migrations)
    │   └── tracks: spark_org, ideas, ...
    └── flyway_schema_history_auth     (plate-auth's migrations)
        └── tracks: auth_identities, memberships, ...

Both tables sit in the same Postgres database. Both are managed by Flyway. They don't reference each other.

5.2 What plate-auth creates

  • auth_identities (user_id PK, email, provider, ...) — user identities.
  • memberships (user_id, org_type, org_id, role, ...) — polymorphic membership pivot.
  • invitations, access_requests — empty for Sparkboard (no UI uses them).

5.3 What Sparkboard creates

  • spark_org (id PK, name, org_type, created_at) — seeded with one row.
  • ideas (id PK, author_id, org_id, title, body, status, created_at, updated_at).

5.4 No cross-history foreign keys

Important: Sparkboard's ideas.author_id is NOT declared as a FK to auth_identities.user_id.

Why:

  • The two Flyway histories run independently. If plate-auth's V1__auth_identities.sql hasn't run yet when Sparkboard's V1__init.sql runs, the FK declaration fails.
  • Referential integrity is enforced at the application layer (controllers always inject @CurrentUser, which guarantees a valid user_id).
  • Trade-off: theoretical risk of orphan ideas rows if a auth_identities row is deleted. For 4 humans, the risk is zero in practice.

6. Full sign-in flow walkthrough (Sparkboard concrete)

  1. Patrick navigates to https://sparkboard.plate-software.de.
  2. Sparkboard middleware sees no session → redirects to /login.
  3. /login renders the SignInButton.
  4. Patrick clicks it → NextAuth-Google OAuth dance → callback to /api/auth/callback/google.
  5. NextAuth verifies the Google token, looks up the email patrick@plate-software.de.
  6. The signIn callback (configured by createAuthConfig) hashes the email with PLATE_AUTH_EXCHANGE_SECRET and POSTs an envelope to POST /api/auth/exchange on the backend.
  7. Backend's plate-auth verifies the HMAC, checks the allowlist (patrick@plate-software.de is in it), looks up or inserts an auth_identities row.
  8. If this is the first sign-in, plate-auth's onboarding orchestrator calls SparkboardOnboardingHook.onFirstSignIn(user).
  9. Hook checks sparkboard.admins[]patrick@plate-software.de is there → calls MembershipService.upsert(userId, "SPARK_ORG", FAMILY_SPARK_ID, "ADMIN").
  10. plate-auth issues a JWT, signs it with plate.auth.jwt.secret, returns it in the exchange response.
  11. NextAuth stores it in the session cookie.
  12. Frontend redirects to /ideas.
  13. /ideas server component calls listIdeas() which calls fetch("http://backend:8080/api/ideas", { headers: { Cookie } }).
  14. Backend's SecurityFilterChain extracts the JWT from the session-cookie-carried bearer, verifies it via JwtDecoder, populates @CurrentUser.
  15. IdeaController.list(@CurrentUser, FAMILY_SPARK_ID) returns the ideas for Family Spark.

Steps 1, 12, 13 are pure Sparkboard. Steps 211, 14 are pure plate-auth. Sparkboard never touched a security filter or a JWT.


7. What to do when…

7.1 …a new family member arrives

  1. Add their email to plate.auth.allowlist[] in application.yml.
  2. (Optional) add to sparkboard.admins[] if they should be ADMIN.
  3. git push origin main → Gitea Actions deploys.
  4. New member signs in via Google → SparkboardOnboardingHook auto-creates their membership.

No DB edits, no manual steps.

7.2 …plate-auth ships v0.2.0 with a new feature

  1. Bump plate-auth.version in pom.xml and @platesoft/auth in package.json.
  2. Read plate-auth's CHANGELOG.
  3. Adjust any breaking changes (semver minor should mean no breaks, but read).
  4. Sparkboard tests should still pass; CI verifies.

7.3 …Sparkboard needs an auth feature plate-auth doesn't have

  1. Don't fork plate-auth in-tree.
  2. File an issue on the plate-auth repo describing the use case.
  3. Either:
    • plate-auth adds a new SPI seam (Sparkboard implements it).
    • plate-auth ships the feature behind a plate.auth.* config flag (Sparkboard enables it).
  4. Sparkboard bumps to the new plate-auth version.

This protects both projects from divergence.

7.4 …an exchange envelope verification fails

Most likely cause: PLATE_AUTH_EXCHANGE_SECRET differs between frontend .env.local and backend application.yml. Both must be identical. Symptom: 401 immediately after Google OAuth callback.

7.5 …Flyway complains about migration history

Likely cause: someone added a Sparkboard migration to db/migration-auth/ by accident. Sparkboard's migrations go in db/migration/. plate-auth's are inside its starter JAR; never put migration files for plate-auth in Sparkboard's repo.


8. Verification checklist (after first deploy)

Run these in order. If any fails, stop and fix before continuing.

  • docker compose ps shows postgres, backend, frontend, caddy all healthy.
  • psql -U sparkboard -c "\dt" lists both flyway_schema_history AND flyway_schema_history_auth.
  • psql confirms spark_org has one row with id 00000000-0000-0000-0000-000000000001.
  • curl https://sparkboard.plate-software.de/api/health returns 200.
  • Browser loads https://sparkboard.plate-software.de/login and shows "Sign in with Google".
  • Patrick signs in → reaches /ideas (empty list).
  • psql -c "SELECT * FROM memberships;" shows one row with (patrick_user_id, 'SPARK_ORG', '00...01', 'ADMIN').
  • Patrick creates an idea → it appears on /ideas.

9. Common pitfalls (Sparkboard-specific)

Pitfall Symptom Fix
Forgetting plate.auth.flyway.history-table Flyway error: duplicate migration version Set it to flyway_schema_history_auth
Using plate.auth.onboarding.auto-create-membership: true AND SparkboardOnboardingHook Two memberships per user Set the property to false
Setting org_id to the wrong UUID in IdeaController GET /api/ideas returns [] even though ideas exist Verify FAMILY_SPARK_ID = UUID.fromString("00000000-0000-0000-0000-000000000001")
Hardcoded Authorization: Bearer ... in fetch from server component Works locally, breaks in prod Always forward cookies() from next/headers instead
Migrating with idea.author_id FK to auth_identities Migration fails because plate-auth tables don't exist yet at migration time Drop the FK; rely on app-layer integrity
Different PLATE_AUTH_EXCHANGE_SECRET on frontend vs backend 401 right after Google callback Set both from the same secret store
sparkboard.admins[] email case mismatch All users get MEMBER role Normalise to lowercase in SparkboardOnboardingHook

10. Cross-references


End of Integration Guide. Status: Draft v1, awaiting GO from Patrick.