diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..03deb5e --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Example environment for id.helpwave.de +# Copy this file to `.env` and fill in the placeholder values. +# +# For local development: docker compose --env-file .env up +# For NixOS deployment: see docs/deployment-nixos.md — values map 1:1. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Theme env vars (Keycloakify exposes these as kcContext.properties.) ── +# Public Turnstile site key — rendered into the registration page HTML. +KC_TURNSTILE_SITE_KEY=0x4AAAAAAAxxxxxxxxxxxxxxxx + +# Full URL of the profile-picture SPI for the customer realm. The account +# console fetches/posts to this from the browser. +KC_PROFILE_PICTURE_API_URL=https://id.helpwave.de/realms/customer/helpwave-picture + +# ── Profile-picture SPI: storage backend ────────────────────────────────────── +# Keys are read as kc.conf settings; in containerized Keycloak they map to the +# KC_ env var format below. + +# Cloudflare R2 example. For AWS S3, leave _ENDPOINT empty and set _REGION to +# your bucket's actual region. +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_ENDPOINT=https://.r2.cloudflarestorage.com +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_REGION=auto +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_BUCKET=helpwave-id-avatars +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_PUBLIC_BASE_URL=https://cdn.helpwave.de/avatars + +# *** Secret — never commit *** +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_ACCESS_KEY=changeme +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_SECRET_KEY=changeme + +# Optional: override the 5 MiB upload limit (value in bytes). +# KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_MAX_BYTES=5242880 + +# ── Cloudflare Turnstile secret ─────────────────────────────────────────────── +# Consumed by the realm JSON via the `$${env.TURNSTILE_SECRET}` placeholder, or +# entered through the admin console per registration flow. +# +# *** Secret — never commit *** +TURNSTILE_SECRET=0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c2e1acd..1ed7703 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,9 +65,9 @@ jobs: run: | mkdir -p release-artifacts cp dist_keycloak/keycloak-theme-*.jar release-artifacts/ - cp keycloak-extensions/turnstile-authenticator/target/helpwave-turnstile-authenticator-*.jar release-artifacts/ - cp keycloak-extensions/privacy-acceptance/target/helpwave-privacy-acceptance-*.jar release-artifacts/ - cp keycloak-extensions/profile-picture/target/helpwave-profile-picture-*.jar release-artifacts/ + cp keycloak-extensions/captcha/target/helpwave-captcha-*.jar release-artifacts/ + cp keycloak-extensions/privacy/target/helpwave-privacy-*.jar release-artifacts/ + cp keycloak-extensions/picture/target/helpwave-picture-*.jar release-artifacts/ rm -f release-artifacts/original-*.jar - uses: softprops/action-gh-release@v2 with: diff --git a/README.md b/README.md index f6417b0..dd5a033 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,12 @@ cd keycloak-extensions mvn -DskipTests package ``` -This produces three jars: +This produces three jars (each plugin lives in its own folder so it can be built / shipped +independently): -- `turnstile-authenticator/target/helpwave-turnstile-authenticator-.jar` -- `privacy-acceptance/target/helpwave-privacy-acceptance-.jar` -- `profile-picture/target/helpwave-profile-picture-.jar` (shaded with AWS SDK + Thumbnailator) +- `captcha/target/helpwave-captcha-.jar` +- `privacy/target/helpwave-privacy-.jar` +- `picture/target/helpwave-picture-.jar` (shaded with AWS SDK + Thumbnailator) Drop all three (alongside the theme jar) into Keycloak's `providers/` directory and run `kc.sh build`. @@ -91,7 +92,7 @@ For nixos users, see [docs/nixos.md](docs/nixos.md) for nix-shell setup instruct - Field-level validation matching hightide patterns - **Cloudflare Turnstile** CAPTCHA on signup (`helpwave-turnstile` FormAction SPI) - **Privacy policy** checkbox on signup with acceptance metadata stored on the user - (`helpwave-privacy-acceptance` FormAction SPI) + (`helpwave-privacy` FormAction SPI) - **Profile picture upload** with server-side scaling to multiple sizes and storage in any S3-compatible bucket (`helpwave-picture` Realm Resource SPI) @@ -104,9 +105,9 @@ The release workflow publishes the following jars on every version bump in `pack | Jar | Purpose | |--------------------------------------------------|--------------------------------------------------| | `keycloak-theme-for-kc-26.2-and-above.jar` | The login/account theme | -| `helpwave-turnstile-authenticator-.jar` | Cloudflare Turnstile registration form action | -| `helpwave-privacy-acceptance-.jar` | Privacy acceptance form action + attribute store | -| `helpwave-profile-picture-.jar` | Profile picture REST endpoint + R2/S3 upload | +| `helpwave-captcha-.jar` | Cloudflare Turnstile registration form action | +| `helpwave-privacy-.jar` | Privacy acceptance form action + attribute store | +| `helpwave-picture-.jar` | Profile picture REST endpoint + R2/S3 upload | Copy all jars into Keycloak's `providers/` directory (or mount them into the container) and run `kc.sh build` to rebuild the runtime, then start Keycloak normally. @@ -196,8 +197,12 @@ KC_PROFILE_PICTURE_API_URL=https://id.helpwave.de/realms/customer/helpwave-pictu ### 4. NixOS deployment -A complete `services.keycloak` example with `_secret` file handling and the matching -admin-console steps lives in [docs/deployment-nixos.md](docs/deployment-nixos.md). +A complete `services.keycloak` example with [sops-nix] secret handling and the matching +admin-console steps lives in [docs/deployment-nixos.md](docs/deployment-nixos.md). For +local development with `docker compose`, copy [`.env.example`](.env.example) to `.env` +and fill in the values. + +[sops-nix]: https://github.com/Mic92/sops-nix ### 5. Releases diff --git a/docs/deployment-nixos.md b/docs/deployment-nixos.md index 3276d16..108815a 100644 --- a/docs/deployment-nixos.md +++ b/docs/deployment-nixos.md @@ -1,28 +1,25 @@ -# NixOS deployment +# NixOS deployment (with sops-nix) This guide shows how to deploy `id.helpwave.de` on a NixOS host using -`services.keycloak`, fetching the theme and SPI jars from a GitHub release and wiring -credentials via the standard `_secret` pattern. +`services.keycloak`, pulling the theme and SPI jars from a GitHub release and injecting +secrets with [sops-nix]. -## 1. Jars published per release +[sops-nix]: https://github.com/Mic92/sops-nix -Every release of this repository attaches the following artifacts to its GitHub release -(see CI workflow `.github/workflows/ci.yaml`): +## 1. Jars per release -| File | Source module | Purpose | -|---------------------------------------------------|-------------------------------|---------------------------------------------------------------| -| `keycloak-theme-for-kc-26.2-and-above.jar` | Keycloakify build | Login + account theme (`helpwave-id`). | -| `helpwave-turnstile-authenticator-.jar` | `turnstile-authenticator` | `FormAction` SPI: Cloudflare Turnstile CAPTCHA on signup. | -| `helpwave-privacy-acceptance-.jar` | `privacy-acceptance` | `FormAction` SPI: privacy checkbox + acceptance attributes. | -| `helpwave-profile-picture-.jar` | `profile-picture` | `RealmResourceProvider` SPI: avatar upload to S3 / R2. | +Every release attaches the following artifacts to the GitHub release: -`` is the SPI Maven version (`keycloak-extensions/pom.xml`, currently `0.1.0`) — it -is independent from the npm/theme version in `package.json`. +| File | Source folder (`keycloak-extensions/`) | What it is | +|--------------------------------------------|----------------------------------------|-----------------------------------------------------------| +| `keycloak-theme-for-kc-26.2-and-above.jar` | (root, built by Keycloakify) | Login + account theme `helpwave-id`. | +| `helpwave-captcha-.jar` | `captcha/` | `FormAction` SPI: Cloudflare Turnstile CAPTCHA on signup. | +| `helpwave-privacy-.jar` | `privacy/` | `FormAction` SPI: privacy checkbox + acceptance attrs. | +| `helpwave-picture-.jar` | `picture/` | `RealmResourceProvider` SPI: avatar upload to S3 / R2. | -All four jars are dropped into Keycloak's `providers/` directory. The -[`services.keycloak.plugins`][nixopts] option does exactly that for you. - -[nixopts]: https://search.nixos.org/options?channel=25.11&query=services.keycloak.plugins +`` is the SPI Maven version (`keycloak-extensions/pom.xml`, currently `0.1.0`), +which is independent from the theme/npm version in `package.json`. All four jars go into +Keycloak's `providers/` directory — `services.keycloak.plugins` does that for you. ## 2. Full NixOS module example @@ -31,50 +28,60 @@ All four jars are dropped into Keycloak's `providers/` directory. The let domain = "id.helpwave.de"; - themeVersion = "0.2.0"; # package.json version → release tag v0.2.0 - spiVersion = "0.1.0"; # keycloak-extensions/pom.xml version + themeVersion = "0.4.0"; # ⇄ package.json version → release tag v0.4.0 + spiVersion = "0.1.0"; # ⇄ keycloak-extensions/pom.xml - release = ver: file: sha: + release = file: sha: pkgs.fetchurl { name = file; - url = "https://github.com/helpwave/id.helpwave.de/releases/download/v${ver}/${file}"; + url = "https://github.com/helpwave/id.helpwave.de/releases/download/v${themeVersion}/${file}"; sha256 = sha; }; - themePlugin = release themeVersion "keycloak-theme-for-kc-26.2-and-above.jar" - "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; - - turnstileSPI = release themeVersion "helpwave-turnstile-authenticator-${spiVersion}.jar" - "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="; - - privacySPI = release themeVersion "helpwave-privacy-acceptance-${spiVersion}.jar" - "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="; - - pictureSPI = release themeVersion "helpwave-profile-picture-${spiVersion}.jar" - "sha256-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD="; + themePlugin = release "keycloak-theme-for-kc-26.2-and-above.jar" + "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + captchaPlugin = release "helpwave-captcha-${spiVersion}.jar" + "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="; + privacyPlugin = release "helpwave-privacy-${spiVersion}.jar" + "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="; + picturePlugin = release "helpwave-picture-${spiVersion}.jar" + "sha256-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD="; in { + # ── sops-nix secrets ────────────────────────────────────────────────────── + # See https://github.com/Mic92/sops-nix#1-add-a-secret on how to populate + # secrets/keycloak.yaml. Each secret is decrypted at activation time and + # written to /run/secrets/ with the configured owner & mode. + + sops.secrets."keycloak/r2-access-key" = { owner = "keycloak"; mode = "0400"; }; + sops.secrets."keycloak/r2-secret-key" = { owner = "keycloak"; mode = "0400"; }; + sops.secrets."keycloak/turnstile-secret" = { owner = "keycloak"; mode = "0400"; }; + + # An EnvironmentFile-friendly bundle used by the systemd unit below. + sops.templates."keycloak.env".owner = "keycloak"; + sops.templates."keycloak.env".content = '' + TURNSTILE_SECRET=${config.sops.placeholder."keycloak/turnstile-secret"} + ''; + + # ── keycloak service ────────────────────────────────────────────────────── services.keycloak = { enable = true; database.type = "postgresql"; - # ... database, hostname, tls etc. as you already have it + # database.passwordFile, hostname, sslCertificate etc. as you already have plugins = [ themePlugin - turnstileSPI - privacySPI - pictureSPI + captchaPlugin + privacyPlugin + picturePlugin ]; settings = { hostname = domain; - # ---- Profile picture SPI (Cloudflare R2 example) ----------------------- - # - # The SPI name for RealmResourceProvider is "realm-restapi-extension". - # Provider id is "helpwave-picture" (matches RealmResourceProviderFactory.getId()). - # Hence the long key prefix below. - + # ── Profile picture SPI ── (Cloudflare R2 example) ───────────────────── + # SPI name "realm-restapi-extension" + provider id "helpwave-picture" → + # this long key prefix is what Keycloak actually expects. "spi-realm-restapi-extension-helpwave-picture-endpoint" = "https://.r2.cloudflarestorage.com"; "spi-realm-restapi-extension-helpwave-picture-region" = "auto"; @@ -82,74 +89,52 @@ in "spi-realm-restapi-extension-helpwave-picture-public-base-url" = "https://cdn.helpwave.de/avatars"; - # Secrets — file content is read at activation time, NOT committed to the Nix store + # `_secret = ""` is the standard NixOS keycloak module idiom; it + # reads the file content at activation time and writes it into + # keycloak.conf without ever placing the value in the Nix store. "spi-realm-restapi-extension-helpwave-picture-access-key" = { - _secret = "/run/keys/helpwave-r2-access-key"; + _secret = config.sops.secrets."keycloak/r2-access-key".path; }; "spi-realm-restapi-extension-helpwave-picture-secret-key" = { - _secret = "/run/keys/helpwave-r2-secret-key"; + _secret = config.sops.secrets."keycloak/r2-secret-key".path; }; }; }; - # ---- Theme env vars (rendered into the React page via kcContext.properties) ---- - # services.keycloak.settings can only produce keys for keycloak.conf, so the - # KC_= variables must be supplied via the systemd unit: + # ── Theme env vars ─────────────────────────────────────────────────────── + # Keycloakify reads KC_ at boot and exposes the value as + # kcContext.properties.. These can't go in services.keycloak.settings + # (that only writes keycloak.conf), so we layer them via systemd: systemd.services.keycloak.serviceConfig = { + EnvironmentFile = config.sops.templates."keycloak.env".path; Environment = [ - "KC_TURNSTILE_SITE_KEY=0x4AAAAAAAxxxxxxxxxxxxxxxx" + "KC_TURNSTILE_SITE_KEY=0x4AAAAAAAxxxxxxxxxxxxxxxx" # public, OK in /nix/store "KC_PROFILE_PICTURE_API_URL=https://${domain}/realms/customer/helpwave-picture" ]; - # Cloudflare Turnstile secret + R2 credentials are loaded via the secret - # files referenced above; nothing further needed here. }; - - # ---- Secrets provisioning (example using NixOS systemd tmpfiles) ---------- - # In production use agenix / sops-nix / deploy-rs vaults. The keycloak.service - # only reads these at start; rotate by writing new content + systemctl restart. - environment.etc."keycloak-secrets/.keep".text = ""; } ``` > **Computing the sha256 placeholders** > > ```sh -> nix-prefetch-url \ -> --type sha256 \ -> "https://github.com/helpwave/id.helpwave.de/releases/download/v0.2.0/keycloak-theme-for-kc-26.2-and-above.jar" +> nix-prefetch-url --type sha256 \ +> "https://github.com/helpwave/id.helpwave.de/releases/download/v0.4.0/helpwave-picture-0.1.0.jar" > ``` > -> Or, easier, run `nix build` once with the placeholder and copy the `got:` line from the -> error message into the expression. - -## 3. Bootstrapping the authentication flow - -Two of the SPIs (`Cloudflare Turnstile (helpwave)` and `Privacy Policy Acceptance -(helpwave)`) plug into the **registration flow** as `FormAction`s. Their config (Turnstile -site key + secret, privacy policy URL + version) is **not** read from `keycloak.conf` — -it is set per execution in the admin console so different realms can have different -keys. Two options: - -### 3a. One-time admin console setup - -1. Open `https:///admin`. -2. Pick your realm → **Authentication** → **Flows** → duplicate `registration`. -3. In the *registration form* sub-flow add two new executions and set both to - **Required**: - - `Cloudflare Turnstile (helpwave)` - - `Privacy Policy Acceptance (helpwave)` -4. Click the gear ⚙️ on each, enter: - - Turnstile: site key (public) + secret (private) from - . - - Privacy: URL (defaults to `https://helpwave.de/privacy`), version string - (e.g. `2024-01`), both stored on every new user as - `privacy_policy_accepted_at` + `privacy_policy_version` user attributes. -5. **Action** menu on the flow → *Bind* → *Registration flow*. - -### 3b. Declarative realm export (preferred for NixOS) - -Add `services.keycloak.realmFiles = [ ./helpwave-id-realm.json ];` and ship the -configured flow as part of the JSON export. Snippet of the relevant part of the export: +> Or just run `nixos-rebuild switch` once with all four `sha256-AAA…` placeholders, copy +> each `got: sha256-…` line out of the error message, and paste it back. + +## 3. Wiring the auth flow (Turnstile + privacy) + +The captcha and privacy SPIs plug into the *registration form* flow as `FormAction`s. +Their config is **not** read from `keycloak.conf` — it's per-flow so different realms +can have different keys. + +### Option A — Realm export (preferred, declarative) + +Ship the flow as part of a realm JSON and load it via +`services.keycloak.realmFiles = [ ./customer-realm.json ];`. The interesting bits: ```json { @@ -163,7 +148,6 @@ configured flow as part of the JSON export. Snippet of the relevant part of the "authenticator": "registration-page-form", "requirement": "REQUIRED", "flowAlias": "registration form helpwave", - "userSetupAllowed": false, "autheticatorFlow": true } ] @@ -175,7 +159,7 @@ configured flow as part of the JSON export. Snippet of the relevant part of the "authenticationExecutions": [ { "authenticator": "registration-user-creation", "requirement": "REQUIRED" }, { "authenticator": "registration-password-action", "requirement": "REQUIRED" }, - { "authenticator": "helpwave-turnstile", "requirement": "REQUIRED", + { "authenticator": "helpwave-turnstile", "requirement": "REQUIRED", "authenticatorConfig": "turnstile-config" }, { "authenticator": "helpwave-privacy-acceptance", "requirement": "REQUIRED", "authenticatorConfig": "privacy-config" } @@ -193,7 +177,7 @@ configured flow as part of the JSON export. Snippet of the relevant part of the { "alias": "privacy-config", "config": { - "privacy.policy.url": "https://helpwave.de/privacy", + "privacy.policy.url": "https://helpwave.de/privacy", "privacy.policy.version": "2024-01" } } @@ -202,37 +186,42 @@ configured flow as part of the JSON export. Snippet of the relevant part of the } ``` -Keycloak resolves `$${env.VAR}` placeholders at import time, so the Turnstile *secret* -can be injected through the systemd unit: +The `$${env.TURNSTILE_SECRET}` placeholder is resolved by Keycloak at import time from +the `EnvironmentFile` we mounted via sops-nix above — the secret value never sits on +disk in cleartext or in the Nix store. -```nix -systemd.services.keycloak.serviceConfig.EnvironmentFile = - "/run/keys/helpwave-turnstile-env"; # file containing TURNSTILE_SECRET=... -``` +### Option B — Click through the admin console -Use `agenix` / `sops-nix` to render that file with mode `0400` owned by `keycloak`. +Same steps as the manual setup section in [README.md](../README.md#1-enable-the-cloudflare-turnstile-and-privacy-form-actions). -## 4. CORS / cookie notes for the profile picture endpoint +## 4. Example `.env` (for local dev with `docker compose`) -The Account Console talks to `/realms//helpwave-picture` from -`https:///realms//account`. Same origin → no CORS or extra cookie config -needed. If you host the account console under a different origin, add the SPI's path to -your reverse proxy CORS allow-list (`POST`, `DELETE`, `Authorization` header, -`credentials: include`). +A flat env file matching the same variables works for the docker-compose local stack +(`docker-compose.yml` in the repo root). Copy `.env.example` to `.env`, fill it in, then +`docker compose --env-file .env up`. -## 5. Updating +See [`.env.example`](../.env.example) at the repo root. -When a new release lands: +## 5. CORS / cookie note for the picture endpoint -1. Bump `themeVersion` (and `spiVersion` if it changed — check the release notes). +The Account Console talks to `/realms//helpwave-picture` from +`https:///realms//account` — same origin, no extra CORS config needed. If +you host the account console under a different origin, add `POST`, `DELETE`, +`Authorization`, `credentials: include` to your reverse proxy's allow-list. + +## 6. Updating + +1. Bump `themeVersion` (and `spiVersion` if it changed — check release notes). 2. Replace the four `sha256-…` placeholders with the new digests. -3. `nixos-rebuild switch` — Keycloak will be restarted automatically because +3. `nixos-rebuild switch`. Keycloak restarts automatically because `services.keycloak.plugins` changed. 4. If the SPI's config keys changed, update `services.keycloak.settings` accordingly. -## 6. Smoke test after deployment +## 7. Smoke test ```sh +DOMAIN=id.helpwave.de + # Theme served? curl -sf "https://${DOMAIN}/realms/customer/login-actions/registration" | grep -q "helpwave id" diff --git a/keycloak-extensions/turnstile-authenticator/pom.xml b/keycloak-extensions/captcha/pom.xml similarity index 96% rename from keycloak-extensions/turnstile-authenticator/pom.xml rename to keycloak-extensions/captcha/pom.xml index 9c7286d..6453722 100644 --- a/keycloak-extensions/turnstile-authenticator/pom.xml +++ b/keycloak-extensions/captcha/pom.xml @@ -10,7 +10,7 @@ 0.1.0 - helpwave-turnstile-authenticator + helpwave-captcha jar helpwave Cloudflare Turnstile Authenticator diff --git a/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java b/keycloak-extensions/captcha/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java similarity index 100% rename from keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java rename to keycloak-extensions/captcha/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java diff --git a/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java b/keycloak-extensions/captcha/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java similarity index 100% rename from keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java rename to keycloak-extensions/captcha/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java diff --git a/keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/keycloak-extensions/captcha/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory similarity index 100% rename from keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory rename to keycloak-extensions/captcha/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory diff --git a/keycloak-extensions/profile-picture/pom.xml b/keycloak-extensions/picture/pom.xml similarity index 98% rename from keycloak-extensions/profile-picture/pom.xml rename to keycloak-extensions/picture/pom.xml index ddf33bc..e684d38 100644 --- a/keycloak-extensions/profile-picture/pom.xml +++ b/keycloak-extensions/picture/pom.xml @@ -10,7 +10,7 @@ 0.1.0 - helpwave-profile-picture + helpwave-picture jar helpwave Profile Picture SPI diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java similarity index 97% rename from keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java rename to keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java index 841aa3e..50c04d7 100644 --- a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java +++ b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java @@ -1,5 +1,6 @@ package de.helpwave.keycloak.picture; +import jakarta.enterprise.inject.Vetoed; import net.coobird.thumbnailator.Thumbnails; import javax.imageio.ImageIO; @@ -14,6 +15,7 @@ * Decodes uploaded images, strips metadata (re-encoding to PNG/JPEG via Thumbnailator) and * produces a fixed set of square thumbnails for the avatar use case. */ +@Vetoed public final class ImageProcessor { /** Output sizes (pixels). Keys are used as filename suffixes. */ diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java similarity index 98% rename from keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java rename to keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java index 4b76d57..ffac077 100644 --- a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java +++ b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java @@ -1,5 +1,7 @@ package de.helpwave.keycloak.picture; +import jakarta.enterprise.inject.Vetoed; + import java.nio.charset.StandardCharsets; /** @@ -7,6 +9,7 @@ * the request body. We do not depend on RESTEasy's MultipartFormDataInput because the type * is not on Keycloak's classpath in 26.x. */ +@Vetoed final class MultipartParser { private MultipartParser() {} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java similarity index 71% rename from keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java rename to keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java index e126f3e..18d171a 100644 --- a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java +++ b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java @@ -1,20 +1,13 @@ package de.helpwave.keycloak.picture; +import jakarta.enterprise.inject.Vetoed; + /** - * Configuration for the profile-picture storage backend. All values are read from - * Keycloak's SPI configuration ({@code spi-helpwave-picture-default-*}) or environment - * variables, whichever is set first. - * - *

Example for Cloudflare R2: - *

- * KC_SPI_HELPWAVE_PICTURE_DEFAULT_ENDPOINT=https://<account>.r2.cloudflarestorage.com
- * KC_SPI_HELPWAVE_PICTURE_DEFAULT_REGION=auto
- * KC_SPI_HELPWAVE_PICTURE_DEFAULT_BUCKET=helpwave-id-avatars
- * KC_SPI_HELPWAVE_PICTURE_DEFAULT_ACCESS_KEY=...
- * KC_SPI_HELPWAVE_PICTURE_DEFAULT_SECRET_KEY=...
- * KC_SPI_HELPWAVE_PICTURE_DEFAULT_PUBLIC_BASE_URL=https://cdn.helpwave.de/avatars
- * 
+ * Configuration for the profile-picture storage backend. Read from Keycloak's SPI + * configuration ({@code spi-realm-restapi-extension-helpwave-picture-*}) or env vars. + * See README + docs/deployment-nixos.md for the full key list. */ +@Vetoed public record PictureConfig( String endpoint, String region, diff --git a/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/PictureProviderHolder.java b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/PictureProviderHolder.java new file mode 100644 index 0000000..8d6f47d --- /dev/null +++ b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/PictureProviderHolder.java @@ -0,0 +1,29 @@ +package de.helpwave.keycloak.picture; + +import jakarta.enterprise.inject.Vetoed; + +/** + * Static holder for the profile-picture storage configuration and S3 client. Initialized + * once by {@link ProfilePictureResourceProviderFactory#init} and accessed by the JAX-RS + * resource at request time. Using a holder lets the resource class stay free of + * constructor parameters so Quarkus' Arc CDI scanner does not try to inject them. + */ +@Vetoed +public final class PictureProviderHolder { + + private static volatile PictureConfig config; + private static volatile S3Storage storage; + + private PictureProviderHolder() {} + + public static void set(PictureConfig cfg, S3Storage st) { + config = cfg; + storage = st; + } + + public static PictureConfig config() { return config; } + + public static S3Storage storage() { return storage; } + + public static boolean isReady() { return storage != null && config != null; } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java similarity index 84% rename from keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java rename to keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java index 8cdbd2f..defd619 100644 --- a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java +++ b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java @@ -1,5 +1,6 @@ package de.helpwave.keycloak.picture; +import jakarta.enterprise.inject.Vetoed; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.HeaderParam; @@ -7,6 +8,7 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -22,31 +24,28 @@ import java.io.InputStream; import java.util.HashMap; import java.util.Map; -import java.util.Set; import java.util.UUID; /** - * REST endpoint exposed at {@code /realms/{realm}/helpwave-picture}. The browser sends the - * raw image bytes as the request body with the corresponding {@code Content-Type} header - * (no multipart wrapper needed). Authentication is the standard Keycloak bearer token. + * REST endpoint exposed at {@code /realms/{realm}/helpwave-picture}. + * + *

The browser sends the raw image bytes as the request body with the corresponding + * {@code Content-Type} header (no multipart wrapper needed). Authentication is the + * standard Keycloak bearer token. + * + *

{@code @Vetoed} keeps Quarkus' Arc CDI scanner from trying to bean-resolve the class + * — the resource is constructed manually by {@link ProfilePictureResourceProvider} and + * pulls its dependencies from {@link PictureProviderHolder}. */ +@Vetoed @Path("/") public class ProfilePictureResource { private static final Logger log = Logger.getLogger(ProfilePictureResource.class); public static final String ATTR_PICTURE_URL = "picture"; - private static final Set ALLOWED_TYPES = Set.of("image/jpeg", "image/png", "image/webp"); - - private final KeycloakSession session; - private final PictureConfig config; - private final S3Storage storage; - - public ProfilePictureResource(KeycloakSession session, PictureConfig config, S3Storage storage) { - this.session = session; - this.config = config; - this.storage = storage; - } + @Context + KeycloakSession session; @OPTIONS public Response preflight() { @@ -57,13 +56,16 @@ public Response preflight() { @Consumes({"image/jpeg", "image/png", "image/webp", MediaType.APPLICATION_OCTET_STREAM, MediaType.MULTIPART_FORM_DATA}) @Produces(MediaType.APPLICATION_JSON) public Response upload(@HeaderParam(HttpHeaders.CONTENT_TYPE) String contentType, InputStream body) { - if (storage == null) { + if (!PictureProviderHolder.isReady()) { return Response.status(Response.Status.SERVICE_UNAVAILABLE) .entity(Map.of("error", "storage not configured")).build(); } UserModel user = authenticate(); if (user == null) return unauthorized(); + PictureConfig config = PictureProviderHolder.config(); + S3Storage storage = PictureProviderHolder.storage(); + byte[] bytes; try { bytes = body.readAllBytes(); @@ -71,7 +73,6 @@ public Response upload(@HeaderParam(HttpHeaders.CONTENT_TYPE) String contentType return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "read failed")).build(); } - // If sent as multipart, extract the first file part. if (contentType != null && contentType.toLowerCase().startsWith("multipart/")) { byte[] extracted = MultipartParser.extractFirstFile(bytes, contentType); if (extracted != null) bytes = extracted; @@ -121,6 +122,10 @@ public Response upload(@HeaderParam(HttpHeaders.CONTENT_TYPE) String contentType @DELETE @Produces(MediaType.APPLICATION_JSON) public Response delete() { + if (!PictureProviderHolder.isReady()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "storage not configured")).build(); + } UserModel user = authenticate(); if (user == null) return unauthorized(); @@ -135,6 +140,8 @@ public Response delete() { private void tryDeletePrevious(String url) { try { + PictureConfig config = PictureProviderHolder.config(); + S3Storage storage = PictureProviderHolder.storage(); String prefix = config.publicBaseUrl().replaceAll("/+$", "") + "/"; if (!url.startsWith(prefix)) return; String relative = url.substring(prefix.length()); diff --git a/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java new file mode 100644 index 0000000..4711064 --- /dev/null +++ b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java @@ -0,0 +1,14 @@ +package de.helpwave.keycloak.picture; + +import org.keycloak.services.resource.RealmResourceProvider; + +public class ProfilePictureResourceProvider implements RealmResourceProvider { + + @Override + public Object getResource() { + return new ProfilePictureResource(); + } + + @Override + public void close() { } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java similarity index 85% rename from keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java rename to keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java index 6ce4d81..1140096 100644 --- a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java +++ b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java @@ -12,22 +12,23 @@ public class ProfilePictureResourceProviderFactory implements RealmResourceProvi public static final String ID = "helpwave-picture"; private static final Logger log = Logger.getLogger(ProfilePictureResourceProviderFactory.class); - private PictureConfig config; private S3Storage storage; @Override public RealmResourceProvider create(KeycloakSession session) { - return new ProfilePictureResourceProvider(session, config, storage); + return new ProfilePictureResourceProvider(); } @Override public void init(Config.Scope scope) { - this.config = PictureConfig.fromEnv(scope); + PictureConfig config = PictureConfig.fromEnv(scope); if (!config.isValid()) { log.warn("helpwave-picture: storage config is incomplete; uploads will return 503"); + PictureProviderHolder.set(config, null); return; } this.storage = new S3Storage(config); + PictureProviderHolder.set(config, storage); log.infof("helpwave-picture initialized (bucket=%s, region=%s)", config.bucket(), config.region()); } diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java similarity index 97% rename from keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java rename to keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java index d17a212..21514f0 100644 --- a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java +++ b/keycloak-extensions/picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java @@ -1,5 +1,6 @@ package de.helpwave.keycloak.picture; +import jakarta.enterprise.inject.Vetoed; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.sync.RequestBody; @@ -13,6 +14,7 @@ import java.net.URI; /** Tiny wrapper around the AWS S3 client that works with Cloudflare R2 via a custom endpoint. */ +@Vetoed public final class S3Storage { private final S3Client client; diff --git a/keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/keycloak-extensions/picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory similarity index 100% rename from keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory rename to keycloak-extensions/picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory diff --git a/keycloak-extensions/pom.xml b/keycloak-extensions/pom.xml index f03f7b2..e689662 100644 --- a/keycloak-extensions/pom.xml +++ b/keycloak-extensions/pom.xml @@ -12,9 +12,9 @@ Keycloak SPI extensions used by id.helpwave.de - turnstile-authenticator - privacy-acceptance - profile-picture + captcha + privacy + picture diff --git a/keycloak-extensions/privacy-acceptance/pom.xml b/keycloak-extensions/privacy/pom.xml similarity index 95% rename from keycloak-extensions/privacy-acceptance/pom.xml rename to keycloak-extensions/privacy/pom.xml index de7a95b..ce0360e 100644 --- a/keycloak-extensions/privacy-acceptance/pom.xml +++ b/keycloak-extensions/privacy/pom.xml @@ -10,7 +10,7 @@ 0.1.0 - helpwave-privacy-acceptance + helpwave-privacy jar helpwave Privacy Acceptance Form Action diff --git a/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java b/keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java similarity index 100% rename from keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java rename to keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java diff --git a/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java b/keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java similarity index 100% rename from keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java rename to keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java diff --git a/keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/keycloak-extensions/privacy/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory similarity index 100% rename from keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory rename to keycloak-extensions/privacy/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java deleted file mode 100644 index b6f96b4..0000000 --- a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java +++ /dev/null @@ -1,25 +0,0 @@ -package de.helpwave.keycloak.picture; - -import org.keycloak.models.KeycloakSession; -import org.keycloak.services.resource.RealmResourceProvider; - -public class ProfilePictureResourceProvider implements RealmResourceProvider { - - private final KeycloakSession session; - private final PictureConfig config; - private final S3Storage storage; - - public ProfilePictureResourceProvider(KeycloakSession session, PictureConfig config, S3Storage storage) { - this.session = session; - this.config = config; - this.storage = storage; - } - - @Override - public Object getResource() { - return new ProfilePictureResource(session, config, storage); - } - - @Override - public void close() { } -} diff --git a/package.json b/package.json index f7ca6e0..a2fcac8 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "id.helpwave.de", - "version": "0.3.0", + "version": "0.4.0", "repository": { "type": "git", "url": "git://github.com/helpwave/id.helpwave.de.git"