From 5a60b880195e1375e630f86f47d6d82688a5038b Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:09:04 +0100 Subject: [PATCH 01/12] docs: inventory auth migration for pocket id --- .gestalt/plans/pocket-id-auth.org | 213 ++++++++++++++++++++++++++++++ doc/pocket-id-auth-inventory.md | 34 +++++ 2 files changed, 247 insertions(+) create mode 100644 .gestalt/plans/pocket-id-auth.org create mode 100644 doc/pocket-id-auth-inventory.md diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org new file mode 100644 index 0000000..d5deb51 --- /dev/null +++ b/.gestalt/plans/pocket-id-auth.org @@ -0,0 +1,213 @@ +#+TITLE: Pocket ID Authentication Plan +#+SUBTITLE: Add Pocket ID passkey-based authentication as an alternative to PocketBase +#+DATE: 2026-03-12 +#+KEYWORDS: auth pocket-id oidc passkey pocketbase ring clojure + +* WIP [#A] Establish the target auth model and backend boundary +Effort: M +Goal: Define one stable auth contract that can support both PocketBase and Pocket ID without spreading provider-specific logic across routes and views. +Notes: Keep the existing `agiladmin.auth.core` boundary, but challenge its current password-centric shape. Pocket ID is an OIDC provider, not a username/password API, so the boundary must model authentication as a use-case, not as a form post. Preserve the current session contract used by `agiladmin.session` and downstream views. + +** WIP [#A] Inventory the current auth use-cases and classify what remains valid +Why: The current backend contract mixes three concerns: password login, user registration, and admin queries for pending users. Pocket ID supports a different model, so the plan must separate reusable use-cases from PocketBase-only behavior before any code changes start. +Change: Read `src/agiladmin/auth/core.clj`, `src/agiladmin/view_auth.clj`, `src/agiladmin/ring.clj`, `src/agiladmin/session.clj`, `src/agiladmin/handlers.clj`, and tests touching login or signup. Produce a short design note inside the branch or commit message that classifies each existing operation as one of: provider-neutral (`healthy?`, session-user normalization, logout), PocketBase-only (`sign-in`, `sign-up`, verification, pending users), or needs replacement (`login-post`). +Tests: No code changes expected. If no files change, skip tests. +Done when: A written inventory exists and every current public auth operation has an explicit disposition for the Pocket ID migration. + +** TODO [#A] Define the provider-neutral auth port around browser redirect login +Why: Pocket ID requires the OIDC authorization code flow. A password-based `sign-in` function is the wrong abstraction and will force hacks into the adapter if left unchanged. +Change: Design a minimal auth port with operations shaped around the real use-cases: +`healthy?` +`login-entry-response` for the GET `/login` page behavior +`begin-login` for redirect creation and CSRF state persistence +`complete-login` for callback exchange, token validation, and session-user construction +`logout-response` for provider-specific logout behavior if needed +Optional operations should be explicit, not assumed: `signup-entry-response`, `list-pending-users`, `request-verification`, `confirm-verification`. +Keep provider-neutral return values small and map them into the existing Ring session shape: +`{:id ... :email ... :name ... :role ... :other-names [] :verified true}` +Prefer a map-based port over protocols to stay consistent with the current codebase. +Tests: Add or update unit tests for `agiladmin.auth.core` to prove missing optional functions fail clearly or are feature-gated cleanly. +Done when: The port supports redirect-based auth cleanly and there is no requirement for Pocket ID to pretend it accepts passwords. + +** TODO [#A] Decide the Pocket ID role mapping and invariants +Why: PocketBase currently stores the role directly on the user record, while Pocket ID uses OIDC plus group-based access controls. Agiladmin needs a deterministic rule for deriving `admin`, `manager`, or plain user from Pocket ID claims. +Change: Choose one invariant and document it in code comments and config docs: +Option A, preferred: derive Agiladmin role from Pocket ID `groups` claim by configured group names, such as `agiladmin-admin` and `agiladmin-manager`. +Option B: derive from a custom claim if Pocket ID exposes one and the chosen OIDC client can rely on it. +Document precedence, for example `admin` wins over `manager`, unknown groups yield nil role, and access checks continue to rely on `agiladmin.session`. +Tests: Add normalization tests for role derivation from representative claims payloads. +Done when: A single source of truth exists for role mapping and it is independent of view code. + +* TODO [#A] Add explicit backend selection and Pocket ID configuration +Effort: M +Goal: Allow Agiladmin to run with PocketBase, Pocket ID, or dev auth through explicit configuration instead of implicit presence checks. +Notes: This is the key architectural cleanup. The current `ring/init` behavior picks PocketBase if the config exists, otherwise maybe dev auth. That will become ambiguous once two real providers exist. + +** TODO [#A] Introduce an explicit auth backend selector in config +Why: Two optional provider blocks are not enough. Startup must know which backend is authoritative, and failures must be clear. +Change: Extend `src/agiladmin/config.clj` with a dedicated auth section, for example: +`agiladmin.auth.backend: pocketbase | pocket-id | dev` +`agiladmin.auth.pocketbase: ...` +`agiladmin.auth.pocket-id: ...` +Preserve backward compatibility for one transition period by translating the current top-level `:pocketbase` block into the new shape at load time if `:auth.backend` is absent. Keep the schema minimal and reject ambiguous or partial config early. +Tests: Add config parsing tests for: +legacy PocketBase config, +new PocketBase config, +new Pocket ID config, +invalid mixed config, +dev backend config. +Done when: `conf/load-config` can unambiguously resolve the active auth backend and returns actionable validation errors. + +** TODO [#A] Define the Pocket ID config schema around OIDC, not ad hoc endpoints +Why: Pocket ID is standards-based. Agiladmin should configure issuer/discovery data and client credentials, then derive endpoints from OIDC discovery instead of hardcoding URLs. +Change: Add a minimal Pocket ID config schema such as: +`issuer-url` +`client-id` +`client-secret` or federated-client-auth settings +`redirect-uri` +`post-logout-redirect-uri` if used +`scopes` defaulting to `openid profile email groups` +`admin-group` +`manager-group` +`connect-timeout-ms` +`socket-timeout-ms` +Optionally include `require-pkce` as a local invariant if the implementation supports it. +Avoid adding settings for Pocket ID user management unless Agiladmin truly needs them. +Tests: Add schema validation coverage for required fields and reasonable defaults. +Done when: The Pocket ID config is small, OIDC-native, and enough to bootstrap login without provider-specific hardcoding in views. + +** TODO [#A] Refactor startup wiring to select adapters through one place +Why: `src/agiladmin/ring.clj` currently knows too much about PocketBase process management and backend initialization. Adding Pocket ID there without cleanup will make startup logic fragile. +Change: Add a small auth bootstrap decision point in `ring/init` that selects one backend from config and initializes `agiladmin.auth.core` with it. Keep PocketBase process management isolated to PocketBase only. Pocket ID should never share those code paths. Ensure health checks run only against the selected backend. +Tests: Extend `test/agiladmin/ring_test.clj` to cover: +PocketBase selected, +Pocket ID selected, +dev selected, +auth health failure for Pocket ID, +missing backend selection. +Done when: Startup contains one explicit branch per backend and no provider inference logic remains. + +* TODO [#A] Implement Pocket ID as an OIDC adapter and login slice +Effort: L +Goal: Add the Pocket ID adapter plus the request/endpoint/response flow for passkey login. +Notes: Keep this slice narrow. Do not attempt full identity management in Agiladmin. Pocket ID should own authentication; Agiladmin should only initiate login, verify the callback, and store a small session user. + +** TODO [#A] Build a Pocket ID OIDC adapter under the auth hex boundary +Why: The new adapter is the core IO integration. It must handle discovery, authorization redirect generation, token exchange, and claim extraction without leaking OIDC details to the rest of the app. +Change: Add `src/agiladmin/auth/pocket_id.clj` with small functions for: +OIDC discovery fetch and validation, +authorization URL creation with state and nonce, +PKCE generation if used, +callback token exchange against `/token`, +ID token verification using the provider JWKS, +optional `/userinfo` fetch if required for `groups`, +session-user normalization and role mapping. +Use `clj-http.client` unless an existing dependency already covers JWT verification cleanly; do not add a dependency unless the current tree cannot verify JWTs safely. +Model this as a classic Hex adapter: config and HTTP are adapter concerns; user role normalization is domain logic. +Tests: Add adapter unit tests with stubbed HTTP calls for discovery, token exchange, JWKS retrieval, and claims normalization. Include failure cases for bad issuer, state mismatch, nonce mismatch, missing claims, and unknown role groups. +Done when: The adapter can transform a valid Pocket ID callback into the existing Agiladmin session user map without any view-specific branching. + +** TODO [#A] Add REPR route handling for login start and callback completion +Why: The current auth REPR is a simple form POST. Pocket ID needs a request flow with redirect initiation and callback completion. +Change: Replace or branch the auth route handling so the active backend drives the flow: +GET `/login` remains the entry page. +For Pocket ID, POST `/login` should become either a redirect trigger or be replaced by GET `/login/start` for cleaner semantics. +Add GET `/auth/pocket-id/callback` as the endpoint that validates state, exchanges the code, stores the Ring session, and redirects to the existing destination, likely `/persons/list`. +Represent request parsing, endpoint behavior, and response building cleanly inside the auth slice instead of scattering logic across handlers and views. +Keep PocketBase login behavior working unchanged when that backend is selected. +Tests: Add route-level tests for: +start login returns 302 to Pocket ID, +callback success stores session and redirects, +callback failure renders a clear login error, +PocketBase still accepts the old login form path. +Done when: Pocket ID login works as a browser redirect loop and the route contract is isolated to the auth slice. + +** TODO [#A] Define how logout behaves for Pocket ID +Why: OIDC logout behavior is often different from local session clearing. The app must make a deliberate choice instead of assuming local logout is enough. +Change: Decide between: +local-only logout, which clears the Ring session and returns to `/login`; +provider-aware logout, which redirects to Pocket ID’s logout or end-session URL when available and then back to Agiladmin. +Choose the simplest viable approach first. If Pocket ID logout support is uncertain or unnecessary, explicitly keep local logout and document that the Pocket ID session may remain active for future logins. +Tests: Add a route test covering the chosen logout response for the Pocket ID backend. +Done when: Logout behavior is intentional, documented, and tested. + +* TODO [#B] Reconcile unsupported use-cases and adapt the UI +Effort: M +Goal: Keep the application coherent when Pocket ID is active, even though signup and verification do not map one-to-one from PocketBase. +Notes: Pocket ID user creation and passkey bootstrap are admin-driven or token-driven. Agiladmin should not fake self-service flows that it does not control. + +** TODO [#A] Gate or replace signup and verification screens when Pocket ID is active +Why: The current `/signup` and `/activate/...` screens assume the app itself can create accounts and request verification. That is not true for Pocket ID in the same way. +Change: For the Pocket ID backend, change `view_auth` so signup routes either: +render a provider-specific explanation page with next steps, +or are disabled with a clear message pointing admins to Pocket ID user setup, signup tokens, or one-time access flow. +Do not expose broken forms that submit nowhere. +Keep PocketBase behavior intact when that backend is selected. +Tests: Add view and route tests proving Pocket ID mode does not show misleading password or verification UI. +Done when: A user on Pocket ID sees only valid actions and no dead-end signup flow. + +** TODO [#B] Remove PocketBase-specific language from shared login UI +Why: The login page should describe the active auth mode accurately. A passkey redirect flow should not ask for a password. +Change: Update `src/agiladmin/view_auth.clj` and shared login helpers in `src/agiladmin/webpage.clj` so the page renders backend-specific content: +PocketBase: existing email/password form. +Pocket ID: a single “Sign in with Pocket ID” action, short passkey explanation, and fallback guidance if the user has no enrolled passkey. +Preserve full-page behavior first; HTMX is not required here. +Tests: Add rendering tests that assert backend-specific login markup. +Done when: The login page never presents the wrong authentication mechanism for the configured backend. + +** TODO [#B] Decide how admin workflows handle pending users under Pocket ID +Why: PocketBase exposes pending verification users; Pocket ID has different user onboarding semantics. The admin people screen must not rely on a capability the backend does not provide. +Change: Review where `list-pending-users` is used, especially in `view_person`. For Pocket ID choose one of: +hide the pending-users section entirely, +replace it with a static note that onboarding is managed in Pocket ID, +or add a Pocket ID API-backed admin summary only if the API genuinely supports the needed behavior and the feature is worth the complexity. +Prefer hiding over inventing partial admin sync logic. +Tests: Add tests for the people/admin view in Pocket ID mode so the screen remains valid without pending-user data. +Done when: Admin pages remain coherent in Pocket ID mode and no code assumes verification queues exist. + +* TODO [#B] Verify, document, and stage migration +Effort: M +Goal: Make the change operable for reviewers and operators, not just implementable in code. +Notes: Since this adds a second real provider, rollout clarity matters. Keep docs minimal but concrete. + +** TODO [#A] Add focused tests for the Pocket ID happy path and failure modes +Why: OIDC integrations fail in subtle ways. Without explicit tests, regressions will be hard to diagnose. +Change: Add a test matrix that covers: +config validation, +startup backend selection, +login start redirect, +callback success, +state/nonce mismatch, +token exchange failure, +missing `email` or identity claim, +role mapping from groups, +logout behavior. +Keep tests local and stub HTTP rather than requiring a live Pocket ID instance unless a simple integration harness is practical. +Tests: Run touched Midje namespaces during each L2 that changes code. After the full L1 is complete, run the whole test suite as required by the workflow. +Done when: The new backend is covered by deterministic tests at the adapter, route, and startup layers. + +** TODO [#B] Update operator documentation and sample configuration +Why: Reviewers and deployers need exact setup steps for Pocket ID client creation and Agiladmin config. Without docs, the feature will look incomplete. +Change: Update `README.md` and add a sample config in `doc/`, following the current PocketBase pattern. Document: +the exact redirect URI for Agiladmin, +required scopes, +recommended Pocket ID client settings, +group names used for role mapping, +whether PKCE is enabled, +logout behavior, +unsupported features compared with PocketBase. +Keep the docs explicit about dates and current provider behavior, for example that Pocket ID documentation checked on 2026-03-12 describes allowed user groups, signup tokens, and OIDC client authentication with shared secret or federated credentials. +Tests: No automated tests required for doc-only changes. +Done when: A reviewer can configure Pocket ID from the repository docs without reading the implementation. + +** TODO [#B] Write a migration and rollback note from PocketBase to Pocket ID +Why: “Alternative to PocketBase” implies operators may compare or switch providers. The plan should include a safe operational story. +Change: Add a short migration note covering: +what data does not migrate automatically, +how roles move from PocketBase `role` to Pocket ID groups, +how user onboarding changes, +how to switch back by changing config, +what session invalidation behavior to expect during cutover. +Keep this note pragmatic and short. +Tests: No automated tests required for doc-only changes. +Done when: The repository includes a concise operator-facing migration path and rollback path. diff --git a/doc/pocket-id-auth-inventory.md b/doc/pocket-id-auth-inventory.md new file mode 100644 index 0000000..e70319a --- /dev/null +++ b/doc/pocket-id-auth-inventory.md @@ -0,0 +1,34 @@ +# Pocket ID Auth Inventory + +This note classifies the current auth use-cases before adding Pocket ID. + +## Provider-neutral behavior to keep + +- `agiladmin.auth.core/healthy?` +- Session user normalization in `agiladmin.session/normalize-role` +- Ring session storage of the authenticated user +- `view-auth/logout-get` + +## PocketBase-specific behavior + +- `agiladmin.auth.core/sign-in` with email/password +- `agiladmin.auth.core/sign-up` +- `agiladmin.auth.core/confirm-verification` +- `agiladmin.auth.core/request-verification` +- `agiladmin.auth.core/list-pending-users` +- `view-auth/signup-post` +- `view-auth/activate` + +## Behavior that must be replaced for Pocket ID + +- `view-auth/login-post` cannot stay password-centric +- `web/login-form` cannot stay email/password-only +- `ring/init` cannot infer the backend from `:agiladmin :pocketbase` +- Pending-user admin behavior must become optional per backend + +## Target direction + +- Keep the existing auth boundary as a map-based port. +- Add redirect-based login operations for Pocket ID. +- Keep provider-specific capabilities optional instead of mandatory. +- Preserve the Ring session user shape consumed by the rest of the app. From 420801b40412bd23485f0de03be3f89451b6e81e Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:10:10 +0100 Subject: [PATCH 02/12] refactor: add redirect-based auth capabilities --- .gestalt/plans/pocket-id-auth.org | 4 +-- src/agiladmin/auth/core.clj | 55 ++++++++++++++++++++++++++++--- src/agiladmin/auth/dev.clj | 5 ++- src/agiladmin/auth/pocketbase.clj | 5 ++- test/agiladmin/auth_test.clj | 35 ++++++++++++++++++-- 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index d5deb51..b579fd9 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -8,13 +8,13 @@ Effort: M Goal: Define one stable auth contract that can support both PocketBase and Pocket ID without spreading provider-specific logic across routes and views. Notes: Keep the existing `agiladmin.auth.core` boundary, but challenge its current password-centric shape. Pocket ID is an OIDC provider, not a username/password API, so the boundary must model authentication as a use-case, not as a form post. Preserve the current session contract used by `agiladmin.session` and downstream views. -** WIP [#A] Inventory the current auth use-cases and classify what remains valid +** DONE [#A] Inventory the current auth use-cases and classify what remains valid Why: The current backend contract mixes three concerns: password login, user registration, and admin queries for pending users. Pocket ID supports a different model, so the plan must separate reusable use-cases from PocketBase-only behavior before any code changes start. Change: Read `src/agiladmin/auth/core.clj`, `src/agiladmin/view_auth.clj`, `src/agiladmin/ring.clj`, `src/agiladmin/session.clj`, `src/agiladmin/handlers.clj`, and tests touching login or signup. Produce a short design note inside the branch or commit message that classifies each existing operation as one of: provider-neutral (`healthy?`, session-user normalization, logout), PocketBase-only (`sign-in`, `sign-up`, verification, pending users), or needs replacement (`login-post`). Tests: No code changes expected. If no files change, skip tests. Done when: A written inventory exists and every current public auth operation has an explicit disposition for the Pocket ID migration. -** TODO [#A] Define the provider-neutral auth port around browser redirect login +** WIP [#A] Define the provider-neutral auth port around browser redirect login Why: Pocket ID requires the OIDC authorization code flow. A password-based `sign-in` function is the wrong abstraction and will force hacks into the adapter if left unchanged. Change: Design a minimal auth port with operations shaped around the real use-cases: `healthy?` diff --git a/src/agiladmin/auth/core.clj b/src/agiladmin/auth/core.clj index 0599994..0f691a9 100644 --- a/src/agiladmin/auth/core.clj +++ b/src/agiladmin/auth/core.clj @@ -3,6 +3,10 @@ (defonce backend (atom nil)) +(defn- capability-failure + [capability] + (f/fail (str "Authentication backend does not support " capability "."))) + (defn- invoke-backend [f & args] (try @@ -10,6 +14,12 @@ (catch Throwable t (f/fail (.getMessage t))))) +(defn- invoke-capability + [auth-backend capability args] + (if-let [capability-fn (get auth-backend capability)] + (apply invoke-backend capability-fn args) + (capability-failure (name capability)))) + (defn init! [auth-backend] (reset! backend auth-backend)) @@ -20,6 +30,12 @@ auth-backend (f/fail "Authentication backend not initialized."))) +(defn backend-kind + [] + (f/attempt-all + [auth-backend (backend!)] + (:kind auth-backend))) + (defn healthy? [] (f/attempt-all @@ -32,28 +48,57 @@ [username password options] (f/attempt-all [auth-backend (backend!)] - (invoke-backend (:sign-in auth-backend) username password options))) + (invoke-capability auth-backend :sign-in [username password options]))) (defn sign-up [name email password options other-names] (f/attempt-all [auth-backend (backend!)] - (invoke-backend (:sign-up auth-backend) name email password options other-names))) + (invoke-capability auth-backend :sign-up [name email password options other-names]))) (defn confirm-verification [email token] (f/attempt-all [auth-backend (backend!)] - (invoke-backend (:confirm-verification auth-backend) email token))) + (invoke-capability auth-backend :confirm-verification [email token]))) (defn request-verification [email] (f/attempt-all [auth-backend (backend!)] - (invoke-backend (:request-verification auth-backend) email))) + (invoke-capability auth-backend :request-verification [email]))) (defn list-pending-users [] (f/attempt-all [auth-backend (backend!)] - (invoke-backend (:list-pending-users auth-backend)))) + (invoke-capability auth-backend :list-pending-users []))) + +(defn login-entry-response + [request] + (f/attempt-all + [auth-backend (backend!)] + (invoke-capability auth-backend :login-entry-response [request]))) + +(defn begin-login + [request] + (f/attempt-all + [auth-backend (backend!)] + (invoke-capability auth-backend :begin-login [request]))) + +(defn complete-login + [request] + (f/attempt-all + [auth-backend (backend!)] + (invoke-capability auth-backend :complete-login [request]))) + +(defn logout-response + [request] + (f/attempt-all + [auth-backend (backend!)] + (if-let [logout-fn (:logout-response auth-backend)] + (invoke-backend logout-fn request) + {:session {} + :status 302 + :headers {"Location" "/login"} + :body ""}))) diff --git a/src/agiladmin/auth/dev.clj b/src/agiladmin/auth/dev.clj index 0c13526..52ebdb5 100644 --- a/src/agiladmin/auth/dev.clj +++ b/src/agiladmin/auth/dev.clj @@ -27,7 +27,8 @@ (defn backend [] - {:healthy? (fn [] true) + {:kind :dev + :healthy? (fn [] true) :sign-in (fn [username password _options] (cond (and (= username "admin") @@ -44,6 +45,8 @@ :else (f/fail "Invalid development credentials."))) + :login-entry-response (fn [_request] + nil) :sign-up (fn [_name _email _password _options _other-names] (f/fail "Development auth backend does not support signup.")) :confirm-verification (fn [_email _token] diff --git a/src/agiladmin/auth/pocketbase.clj b/src/agiladmin/auth/pocketbase.clj index f7a9387..1a5156c 100644 --- a/src/agiladmin/auth/pocketbase.clj +++ b/src/agiladmin/auth/pocketbase.clj @@ -188,9 +188,12 @@ (defn backend [config] - {:healthy? (fn [] (healthy? config)) + {:kind :pocketbase + :healthy? (fn [] (healthy? config)) :sign-in (fn [username password options] (sign-in config username password options)) + :login-entry-response (fn [_request] + nil) :sign-up (fn [name email password options other-names] (sign-up config name email password options other-names)) :confirm-verification (fn [_email token] diff --git a/test/agiladmin/auth_test.clj b/test/agiladmin/auth_test.clj index 0e259f9..457bebf 100644 --- a/test/agiladmin/auth_test.clj +++ b/test/agiladmin/auth_test.clj @@ -13,7 +13,8 @@ (fact "Auth core delegates to the configured backend" (let [state (atom []) - backend {:healthy? (fn [] true) + backend {:kind :test + :healthy? (fn [] true) :sign-in (fn [username password options] (swap! state conj [:sign-in username password options]) {:email username :verified true}) @@ -28,8 +29,18 @@ true) :list-pending-users (fn [] (swap! state conj [:pending]) - [{:email "pending@example.org"}])}] + [{:email "pending@example.org"}]) + :begin-login (fn [request] + (swap! state conj [:begin-login request]) + {:status 302}) + :complete-login (fn [request] + (swap! state conj [:complete-login request]) + {:email "callback@example.org"}) + :logout-response (fn [request] + (swap! state conj [:logout request]) + {:status 302 :headers {"Location" "/signed-out"}})}] (auth/init! backend) + (auth/backend-kind) => :test (auth/healthy?) => true (auth/sign-in "user@example.org" "secret" {:ip-address "127.0.0.1"}) => {:email "user@example.org" :verified true} @@ -39,11 +50,17 @@ => {:email "user@example.org" :token "token"} (auth/request-verification "user@example.org") => true (auth/list-pending-users) => [{:email "pending@example.org"}] + (auth/begin-login {:uri "/login/start"}) => {:status 302} + (auth/complete-login {:params {:code "abc"}}) => {:email "callback@example.org"} + (auth/logout-response {:uri "/logout"}) => {:status 302 :headers {"Location" "/signed-out"}} @state => [[:sign-in "user@example.org" "secret" {:ip-address "127.0.0.1"}] [:sign-up "User" "user@example.org" "secret" {:activation-uri "example.org"} []] [:confirm "user@example.org" "token"] [:request "user@example.org"] - [:pending]])) + [:pending] + [:begin-login {:uri "/login/start"}] + [:complete-login {:params {:code "abc"}}] + [:logout {:uri "/logout"}]])) (fact "Auth core turns backend exceptions into failjure failures" (let [previous @auth/backend @@ -56,3 +73,15 @@ (f/message result) => "PocketBase unreachable.") (finally (auth/init! previous))))) + +(fact "Auth core reports unsupported optional capabilities clearly" + (let [previous @auth/backend + backend {:kind :test + :healthy? (fn [] true)}] + (auth/init! backend) + (try + (let [result (auth/begin-login {:uri "/login/start"})] + (f/failed? result) => truthy + (f/message result) => "Authentication backend does not support begin-login.") + (finally + (auth/init! previous))))) From b158388c686ece8776b3ced49ec0ca4f2e2013e4 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:10:49 +0100 Subject: [PATCH 03/12] feat: derive auth roles from pocket id groups --- .gestalt/plans/pocket-id-auth.org | 4 ++-- src/agiladmin/auth/user.clj | 13 +++++++++++++ test/agiladmin/auth_user_test.clj | 31 +++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/agiladmin/auth/user.clj create mode 100644 test/agiladmin/auth_user_test.clj diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index b579fd9..1d7224e 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -14,7 +14,7 @@ Change: Read `src/agiladmin/auth/core.clj`, `src/agiladmin/view_auth.clj`, `src/ Tests: No code changes expected. If no files change, skip tests. Done when: A written inventory exists and every current public auth operation has an explicit disposition for the Pocket ID migration. -** WIP [#A] Define the provider-neutral auth port around browser redirect login +** DONE [#A] Define the provider-neutral auth port around browser redirect login Why: Pocket ID requires the OIDC authorization code flow. A password-based `sign-in` function is the wrong abstraction and will force hacks into the adapter if left unchanged. Change: Design a minimal auth port with operations shaped around the real use-cases: `healthy?` @@ -29,7 +29,7 @@ Prefer a map-based port over protocols to stay consistent with the current codeb Tests: Add or update unit tests for `agiladmin.auth.core` to prove missing optional functions fail clearly or are feature-gated cleanly. Done when: The port supports redirect-based auth cleanly and there is no requirement for Pocket ID to pretend it accepts passwords. -** TODO [#A] Decide the Pocket ID role mapping and invariants +** WIP [#A] Decide the Pocket ID role mapping and invariants Why: PocketBase currently stores the role directly on the user record, while Pocket ID uses OIDC plus group-based access controls. Agiladmin needs a deterministic rule for deriving `admin`, `manager`, or plain user from Pocket ID claims. Change: Choose one invariant and document it in code comments and config docs: Option A, preferred: derive Agiladmin role from Pocket ID `groups` claim by configured group names, such as `agiladmin-admin` and `agiladmin-manager`. diff --git a/src/agiladmin/auth/user.clj b/src/agiladmin/auth/user.clj new file mode 100644 index 0000000..a4a5f28 --- /dev/null +++ b/src/agiladmin/auth/user.clj @@ -0,0 +1,13 @@ +(ns agiladmin.auth.user + (:require [clojure.string :as str])) + +(defn role-from-groups + "Derive the Agiladmin role from Pocket ID groups." + [groups {:keys [admin-group manager-group]}] + (let [group-set (->> groups + (keep #(some-> % str/trim not-empty)) + set)] + (cond + (and admin-group (contains? group-set admin-group)) "admin" + (and manager-group (contains? group-set manager-group)) "manager" + :else nil))) diff --git a/test/agiladmin/auth_user_test.clj b/test/agiladmin/auth_user_test.clj new file mode 100644 index 0000000..997a571 --- /dev/null +++ b/test/agiladmin/auth_user_test.clj @@ -0,0 +1,31 @@ +(ns agiladmin.auth-user-test + (:require [agiladmin.auth.user :as auth-user] + [midje.sweet :refer :all])) + +(fact "Admin group wins when both admin and manager groups are present" + (auth-user/role-from-groups + ["agiladmin-manager" "agiladmin-admin"] + {:admin-group "agiladmin-admin" + :manager-group "agiladmin-manager"}) + => "admin") + +(fact "Manager group maps to the manager role" + (auth-user/role-from-groups + ["agiladmin-manager"] + {:admin-group "agiladmin-admin" + :manager-group "agiladmin-manager"}) + => "manager") + +(fact "Unknown groups yield no role" + (auth-user/role-from-groups + ["everyone"] + {:admin-group "agiladmin-admin" + :manager-group "agiladmin-manager"}) + => nil) + +(fact "Blank groups are ignored" + (auth-user/role-from-groups + ["" " " "agiladmin-admin"] + {:admin-group "agiladmin-admin" + :manager-group "agiladmin-manager"}) + => "admin") From acd058afc657f00b28b820d961560eca38c8b99e Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:12:57 +0100 Subject: [PATCH 04/12] feat: normalize auth backend configuration --- .gestalt/plans/pocket-id-auth.org | 8 +-- src/agiladmin/config.clj | 107 +++++++++++++++++++++++++----- test/agiladmin/config_test.clj | 88 ++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 20 deletions(-) diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index 1d7224e..b2bfd02 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -3,7 +3,7 @@ #+DATE: 2026-03-12 #+KEYWORDS: auth pocket-id oidc passkey pocketbase ring clojure -* WIP [#A] Establish the target auth model and backend boundary +* DONE [#A] Establish the target auth model and backend boundary Effort: M Goal: Define one stable auth contract that can support both PocketBase and Pocket ID without spreading provider-specific logic across routes and views. Notes: Keep the existing `agiladmin.auth.core` boundary, but challenge its current password-centric shape. Pocket ID is an OIDC provider, not a username/password API, so the boundary must model authentication as a use-case, not as a form post. Preserve the current session contract used by `agiladmin.session` and downstream views. @@ -29,7 +29,7 @@ Prefer a map-based port over protocols to stay consistent with the current codeb Tests: Add or update unit tests for `agiladmin.auth.core` to prove missing optional functions fail clearly or are feature-gated cleanly. Done when: The port supports redirect-based auth cleanly and there is no requirement for Pocket ID to pretend it accepts passwords. -** WIP [#A] Decide the Pocket ID role mapping and invariants +** DONE [#A] Decide the Pocket ID role mapping and invariants Why: PocketBase currently stores the role directly on the user record, while Pocket ID uses OIDC plus group-based access controls. Agiladmin needs a deterministic rule for deriving `admin`, `manager`, or plain user from Pocket ID claims. Change: Choose one invariant and document it in code comments and config docs: Option A, preferred: derive Agiladmin role from Pocket ID `groups` claim by configured group names, such as `agiladmin-admin` and `agiladmin-manager`. @@ -38,12 +38,12 @@ Document precedence, for example `admin` wins over `manager`, unknown groups yie Tests: Add normalization tests for role derivation from representative claims payloads. Done when: A single source of truth exists for role mapping and it is independent of view code. -* TODO [#A] Add explicit backend selection and Pocket ID configuration +* WIP [#A] Add explicit backend selection and Pocket ID configuration Effort: M Goal: Allow Agiladmin to run with PocketBase, Pocket ID, or dev auth through explicit configuration instead of implicit presence checks. Notes: This is the key architectural cleanup. The current `ring/init` behavior picks PocketBase if the config exists, otherwise maybe dev auth. That will become ambiguous once two real providers exist. -** TODO [#A] Introduce an explicit auth backend selector in config +** WIP [#A] Introduce an explicit auth backend selector in config Why: Two optional provider blocks are not enough. Startup must know which backend is authoritative, and failures must be clear. Change: Extend `src/agiladmin/config.clj` with a dedicated auth section, for example: `agiladmin.auth.backend: pocketbase | pocket-id | dev` diff --git a/src/agiladmin/config.clj b/src/agiladmin/config.clj index 2cc9bf5..a9715cd 100644 --- a/src/agiladmin/config.clj +++ b/src/agiladmin/config.clj @@ -28,6 +28,36 @@ [yaml.core :as yaml] [cheshire.core :refer :all])) +(s/defschema PocketBaseConfig + {:base-url s/Str + :users-collection s/Str + :superuser-email s/Str + :superuser-password s/Str + (s/optional-key :connect-timeout-ms) s/Num + (s/optional-key :socket-timeout-ms) s/Num + (s/optional-key :manage-process) s/Bool + (s/optional-key :binary) s/Str + (s/optional-key :dir) s/Str + (s/optional-key :migrations-dir) s/Str + (s/optional-key :version-file) s/Str}) + +(s/defschema PocketIdConfig + {:issuer-url s/Str + :client-id s/Str + :client-secret s/Str + :redirect-uri s/Str + (s/optional-key :post-logout-redirect-uri) s/Str + (s/optional-key :scopes) [s/Str] + :admin-group s/Str + :manager-group s/Str + (s/optional-key :connect-timeout-ms) s/Num + (s/optional-key :socket-timeout-ms) s/Num}) + +(s/defschema AuthConfig + {(s/optional-key :backend) s/Str + (s/optional-key :pocketbase) PocketBaseConfig + (s/optional-key :pocket-id) PocketIdConfig}) + (s/defschema Config {s/Keyword {:budgets {:git s/Str @@ -40,17 +70,8 @@ (s/optional-key :ssl-redirect) s/Bool} (s/optional-key :source) {:git s/Str :update s/Bool} - (s/optional-key :pocketbase) {:base-url s/Str - :users-collection s/Str - :superuser-email s/Str - :superuser-password s/Str - (s/optional-key :connect-timeout-ms) s/Num - (s/optional-key :socket-timeout-ms) s/Num - (s/optional-key :manage-process) s/Bool - (s/optional-key :binary) s/Str - (s/optional-key :dir) s/Str - (s/optional-key :migrations-dir) s/Str - (s/optional-key :version-file) s/Str} + (s/optional-key :pocketbase) PocketBaseConfig + (s/optional-key :auth) AuthConfig } :appname s/Str :paths [s/Str] @@ -234,6 +255,56 @@ ;; (f/fail (log/spy :error ["Invalid configuration: " conf ex])))) (get-in conf path)) +(defn- normalize-auth-config + [conf] + (let [app-key :agiladmin + app-conf (get conf app-key) + auth-conf (:auth app-conf) + pocketbase-conf (:pocketbase app-conf)] + (cond + (and auth-conf pocketbase-conf (not (:pocketbase auth-conf))) + (assoc-in conf [app-key :auth :pocketbase] pocketbase-conf) + + (and (nil? auth-conf) pocketbase-conf) + (assoc-in conf [app-key :auth] {:backend "pocketbase" + :pocketbase pocketbase-conf}) + + :else + conf))) + +(defn- validate-auth-selection + [conf path-label] + (let [app-key :agiladmin + auth-conf (get-in conf [app-key :auth]) + backend (:backend auth-conf) + providers (cond-> [] + (:pocketbase auth-conf) (conj "pocketbase") + (:pocket-id auth-conf) (conj "pocket-id"))] + (cond + (and backend (not (#{"pocketbase" "pocket-id" "dev"} backend))) + (f/fail (str "Invalid configuration at " path-label + ": unsupported auth backend " backend)) + + (and (:pocketbase auth-conf) + (:pocket-id auth-conf) + (nil? backend)) + (f/fail (str "Invalid configuration at " path-label + ": auth.backend is required when multiple auth providers are configured")) + + (and backend + (#{"pocketbase" "pocket-id"} backend) + (nil? (get auth-conf (keyword backend)))) + (f/fail (str "Invalid configuration at " path-label + ": missing config for auth backend " backend)) + + (and (= backend "dev") + (seq providers)) + (f/fail (str "Invalid configuration at " path-label + ": auth.backend dev cannot be combined with provider configs")) + + :else + conf))) + (defn- project-file? [conf file] (let [name (.getName file) @@ -273,17 +344,21 @@ (defn load-config [name default] (log/info (str "Loading configuration: " name)) (let [conf (config-read name default) - loaded-paths (->> (:paths conf) + normalized-conf (if (f/failed? conf) + conf + (normalize-auth-config conf)) + loaded-paths (->> (:paths normalized-conf) (filter #(.exists (io/as-file %))) vec) path-label (if (seq loaded-paths) (clojure.string/join ", " loaded-paths) (str "search path for " name ".yaml"))] - (if (f/failed? conf) - conf + (if (f/failed? normalized-conf) + normalized-conf (f/attempt-all - [_ (validate-data Config conf "configuration" path-label)] - conf + [_ (validate-data Config normalized-conf "configuration" path-label) + _ (validate-auth-selection normalized-conf path-label)] + normalized-conf (f/when-failed [e] e))))) diff --git a/test/agiladmin/config_test.clj b/test/agiladmin/config_test.clj index 830ce2e..f5ddac8 100644 --- a/test/agiladmin/config_test.clj +++ b/test/agiladmin/config_test.clj @@ -147,8 +147,96 @@ (f/failed? conf) => false (:filename conf) => "agiladmin.pocketbase.yaml" (:paths conf) => ["doc/agiladmin.pocketbase.yaml"] + (get-in conf [:agiladmin :auth :backend]) => "pocketbase" + (get-in conf [:agiladmin :auth :pocketbase :base-url]) => "http://127.0.0.1:8090" (get-in conf [:agiladmin :pocketbase :base-url]) => "http://127.0.0.1:8090")) +(fact "Application config loader accepts the nested auth PocketBase config" + (let [path "/tmp/agiladmin-auth-pocketbase.yaml" + _ (spit path + (str "agiladmin:\n" + " budgets:\n" + " git: ssh://dyne.org/dyne/budgets\n" + " ssh-key: id_rsa\n" + " path: test/assets/\n" + " auth:\n" + " backend: pocketbase\n" + " pocketbase:\n" + " base-url: http://127.0.0.1:8090\n" + " users-collection: users\n" + " superuser-email: admin@example.org\n" + " superuser-password: changeme\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (get-in conf [:agiladmin :auth :backend]) => "pocketbase" + (get-in conf [:agiladmin :auth :pocketbase :users-collection]) => "users")) + +(fact "Application config loader accepts the nested auth Pocket ID config" + (let [path "/tmp/agiladmin-auth-pocket-id.yaml" + _ (spit path + (str "agiladmin:\n" + " budgets:\n" + " git: ssh://dyne.org/dyne/budgets\n" + " ssh-key: id_rsa\n" + " path: test/assets/\n" + " auth:\n" + " backend: pocket-id\n" + " pocket-id:\n" + " issuer-url: https://pocket-id.example.org\n" + " client-id: agiladmin\n" + " client-secret: secret\n" + " redirect-uri: https://agiladmin.example.org/auth/pocket-id/callback\n" + " admin-group: agiladmin-admin\n" + " manager-group: agiladmin-manager\n" + " scopes:\n" + " - openid\n" + " - profile\n" + " - email\n" + " - groups\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (get-in conf [:agiladmin :auth :backend]) => "pocket-id" + (get-in conf [:agiladmin :auth :pocket-id :issuer-url]) => "https://pocket-id.example.org")) + +(fact "Application config loader rejects ambiguous nested auth providers without a backend selector" + (let [path "/tmp/agiladmin-auth-ambiguous.yaml" + _ (spit path + (str "agiladmin:\n" + " budgets:\n" + " git: ssh://dyne.org/dyne/budgets\n" + " ssh-key: id_rsa\n" + " path: test/assets/\n" + " auth:\n" + " pocketbase:\n" + " base-url: http://127.0.0.1:8090\n" + " users-collection: users\n" + " superuser-email: admin@example.org\n" + " superuser-password: changeme\n" + " pocket-id:\n" + " issuer-url: https://pocket-id.example.org\n" + " client-id: agiladmin\n" + " client-secret: secret\n" + " redirect-uri: https://agiladmin.example.org/auth/pocket-id/callback\n" + " admin-group: agiladmin-admin\n" + " manager-group: agiladmin-manager\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => true + (f/message conf) => (contains "auth.backend is required"))) + +(fact "Application config loader accepts an explicit dev auth backend" + (let [path "/tmp/agiladmin-auth-dev.yaml" + _ (spit path + (str "agiladmin:\n" + " budgets:\n" + " git: ssh://dyne.org/dyne/budgets\n" + " ssh-key: id_rsa\n" + " path: test/assets/\n" + " auth:\n" + " backend: dev\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (get-in conf [:agiladmin :auth :backend]) => "dev")) + (fact "Application config loader reports an explicit missing file" (let [conf (conf/load-config "/tmp/does-not-exist-agiladmin.yaml" conf/default-settings)] (f/failed? conf) => true From bd2c22c9e8986a9ac0254eb689eb44291783aa4d Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:13:37 +0100 Subject: [PATCH 05/12] feat: add pocket id oidc config defaults --- .gestalt/plans/pocket-id-auth.org | 4 ++-- src/agiladmin/config.clj | 31 ++++++++++++++++++++----------- test/agiladmin/config_test.clj | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index b2bfd02..00a6042 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -43,7 +43,7 @@ Effort: M Goal: Allow Agiladmin to run with PocketBase, Pocket ID, or dev auth through explicit configuration instead of implicit presence checks. Notes: This is the key architectural cleanup. The current `ring/init` behavior picks PocketBase if the config exists, otherwise maybe dev auth. That will become ambiguous once two real providers exist. -** WIP [#A] Introduce an explicit auth backend selector in config +** DONE [#A] Introduce an explicit auth backend selector in config Why: Two optional provider blocks are not enough. Startup must know which backend is authoritative, and failures must be clear. Change: Extend `src/agiladmin/config.clj` with a dedicated auth section, for example: `agiladmin.auth.backend: pocketbase | pocket-id | dev` @@ -58,7 +58,7 @@ invalid mixed config, dev backend config. Done when: `conf/load-config` can unambiguously resolve the active auth backend and returns actionable validation errors. -** TODO [#A] Define the Pocket ID config schema around OIDC, not ad hoc endpoints +** WIP [#A] Define the Pocket ID config schema around OIDC, not ad hoc endpoints Why: Pocket ID is standards-based. Agiladmin should configure issuer/discovery data and client credentials, then derive endpoints from OIDC discovery instead of hardcoding URLs. Change: Add a minimal Pocket ID config schema such as: `issuer-url` diff --git a/src/agiladmin/config.clj b/src/agiladmin/config.clj index a9715cd..269b028 100644 --- a/src/agiladmin/config.clj +++ b/src/agiladmin/config.clj @@ -98,6 +98,9 @@ (def run-mode (atom :web)) +(def default-pocket-id-scopes + ["openid" "profile" "email" "groups"]) + (def default-settings {:budgets {:git "ssh://git@my.server.org/admin-budgets" :ssh-key "id_rsa" @@ -260,17 +263,23 @@ (let [app-key :agiladmin app-conf (get conf app-key) auth-conf (:auth app-conf) - pocketbase-conf (:pocketbase app-conf)] - (cond - (and auth-conf pocketbase-conf (not (:pocketbase auth-conf))) - (assoc-in conf [app-key :auth :pocketbase] pocketbase-conf) - - (and (nil? auth-conf) pocketbase-conf) - (assoc-in conf [app-key :auth] {:backend "pocketbase" - :pocketbase pocketbase-conf}) - - :else - conf))) + pocketbase-conf (:pocketbase app-conf) + with-provider + (cond + (and auth-conf pocketbase-conf (not (:pocketbase auth-conf))) + (assoc-in conf [app-key :auth :pocketbase] pocketbase-conf) + + (and (nil? auth-conf) pocketbase-conf) + (assoc-in conf [app-key :auth] {:backend "pocketbase" + :pocketbase pocketbase-conf}) + + :else + conf)] + (if-let [pocket-id-conf (get-in with-provider [app-key :auth :pocket-id])] + (update-in with-provider + [app-key :auth :pocket-id] + #(merge {:scopes default-pocket-id-scopes} %)) + with-provider))) (defn- validate-auth-selection [conf path-label] diff --git a/test/agiladmin/config_test.clj b/test/agiladmin/config_test.clj index f5ddac8..ad0240f 100644 --- a/test/agiladmin/config_test.clj +++ b/test/agiladmin/config_test.clj @@ -198,6 +198,27 @@ (get-in conf [:agiladmin :auth :backend]) => "pocket-id" (get-in conf [:agiladmin :auth :pocket-id :issuer-url]) => "https://pocket-id.example.org")) +(fact "Pocket ID config defaults scopes to the standard OIDC set" + (let [path "/tmp/agiladmin-auth-pocket-id-default-scopes.yaml" + _ (spit path + (str "agiladmin:\n" + " budgets:\n" + " git: ssh://dyne.org/dyne/budgets\n" + " ssh-key: id_rsa\n" + " path: test/assets/\n" + " auth:\n" + " backend: pocket-id\n" + " pocket-id:\n" + " issuer-url: https://pocket-id.example.org\n" + " client-id: agiladmin\n" + " client-secret: secret\n" + " redirect-uri: https://agiladmin.example.org/auth/pocket-id/callback\n" + " admin-group: agiladmin-admin\n" + " manager-group: agiladmin-manager\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (get-in conf [:agiladmin :auth :pocket-id :scopes]) => conf/default-pocket-id-scopes)) + (fact "Application config loader rejects ambiguous nested auth providers without a backend selector" (let [path "/tmp/agiladmin-auth-ambiguous.yaml" _ (spit path From 90743266c06ea040b8071ca750766eff97ed4cc5 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:14:54 +0100 Subject: [PATCH 06/12] refactor: select auth backend explicitly at startup --- .gestalt/plans/pocket-id-auth.org | 4 +- src/agiladmin/auth/pocket_id.clj | 18 ++++++ src/agiladmin/ring.clj | 50 ++++++++++++++-- test/agiladmin/ring_test.clj | 96 +++++++++++++++++++++++++------ 4 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 src/agiladmin/auth/pocket_id.clj diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index 00a6042..9b9d34f 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -58,7 +58,7 @@ invalid mixed config, dev backend config. Done when: `conf/load-config` can unambiguously resolve the active auth backend and returns actionable validation errors. -** WIP [#A] Define the Pocket ID config schema around OIDC, not ad hoc endpoints +** DONE [#A] Define the Pocket ID config schema around OIDC, not ad hoc endpoints Why: Pocket ID is standards-based. Agiladmin should configure issuer/discovery data and client credentials, then derive endpoints from OIDC discovery instead of hardcoding URLs. Change: Add a minimal Pocket ID config schema such as: `issuer-url` @@ -76,7 +76,7 @@ Avoid adding settings for Pocket ID user management unless Agiladmin truly needs Tests: Add schema validation coverage for required fields and reasonable defaults. Done when: The Pocket ID config is small, OIDC-native, and enough to bootstrap login without provider-specific hardcoding in views. -** TODO [#A] Refactor startup wiring to select adapters through one place +** WIP [#A] Refactor startup wiring to select adapters through one place Why: `src/agiladmin/ring.clj` currently knows too much about PocketBase process management and backend initialization. Adding Pocket ID there without cleanup will make startup logic fragile. Change: Add a small auth bootstrap decision point in `ring/init` that selects one backend from config and initializes `agiladmin.auth.core` with it. Keep PocketBase process management isolated to PocketBase only. Pocket ID should never share those code paths. Ensure health checks run only against the selected backend. Tests: Extend `test/agiladmin/ring_test.clj` to cover: diff --git a/src/agiladmin/auth/pocket_id.clj b/src/agiladmin/auth/pocket_id.clj new file mode 100644 index 0000000..2bb1527 --- /dev/null +++ b/src/agiladmin/auth/pocket_id.clj @@ -0,0 +1,18 @@ +(ns agiladmin.auth.pocket-id + (:require [failjure.core :as f])) + +(defn healthy? + [_config] + true) + +(defn backend + [config] + {:kind :pocket-id + :healthy? (fn [] + (healthy? config)) + :login-entry-response (fn [_request] + nil) + :begin-login (fn [_request] + (f/fail "Pocket ID login is not implemented yet.")) + :complete-login (fn [_request] + (f/fail "Pocket ID login is not implemented yet."))}) diff --git a/src/agiladmin/ring.clj b/src/agiladmin/ring.clj index 88cce22..f172372 100644 --- a/src/agiladmin/ring.clj +++ b/src/agiladmin/ring.clj @@ -3,6 +3,7 @@ [clojure.java.io :as io] [agiladmin.auth.core :as auth-core] [agiladmin.auth.dev :as dev-auth] + [agiladmin.auth.pocket-id :as pocket-id] [agiladmin.auth.pocketbase :as pocketbase] [agiladmin.pocketbase-process :as pocketbase-process] [agiladmin.config :as conf] @@ -32,6 +33,29 @@ (f/fail (str "Authentication backend health check failed: " (.getMessage ex)))))) +(defn- configured-auth-backend + [config] + (let [auth-conf (get-in config [:agiladmin :auth]) + backend (:backend auth-conf)] + (cond + (= backend "pocketbase") + [:pocketbase (:pocketbase auth-conf)] + + (= backend "pocket-id") + [:pocket-id (:pocket-id auth-conf)] + + (= backend "dev") + [:dev nil] + + (:pocketbase auth-conf) + [:pocketbase (:pocketbase auth-conf)] + + (:pocket-id auth-conf) + [:pocket-id (:pocket-id auth-conf)] + + :else + [nil nil]))) + (defn init [] (log/merge-config! {:level :debug ;; #{:trace :debug :info :warn :error :fatal :report} @@ -59,13 +83,27 @@ (trans/init "resources/lang/agiladmin-en.yml") - (let [auth-enabled? - (if-let [pocketbase-conf (get-in @config [:agiladmin :pocketbase])] + (let [[configured-backend backend-config] (configured-auth-backend @config) + auth-enabled? + (case configured-backend + :pocketbase (do - (when (:manage-process pocketbase-conf) - (pocketbase-process/start! pocketbase-conf)) - (auth-core/init! (pocketbase/backend pocketbase-conf)) + (when (:manage-process backend-config) + (pocketbase-process/start! backend-config)) + (auth-core/init! (pocketbase/backend backend-config)) true) + + :pocket-id + (do + (auth-core/init! (pocket-id/backend backend-config)) + true) + + :dev + (do + (auth-core/init! (dev-auth/backend)) + (log/warn "Starting with development auth backend enabled.") + true) + (if (dev-auth-enabled?) (do (auth-core/init! (dev-auth/backend)) @@ -73,7 +111,7 @@ true) (do (auth-core/init! nil) - (log/warn "Skipping auth initialization: missing :agiladmin :pocketbase") + (log/warn "Skipping auth initialization: missing :agiladmin :auth backend") false))) healthy? (when auth-enabled? (auth-health-status))] diff --git a/test/agiladmin/ring_test.clj b/test/agiladmin/ring_test.clj index b016bc7..9677a31 100644 --- a/test/agiladmin/ring_test.clj +++ b/test/agiladmin/ring_test.clj @@ -9,10 +9,11 @@ (with-redefs [agiladmin.config/load-config (fn [_ _] {:agiladmin {:budgets {:ssh-key "test/assets/id_rsa"} - :pocketbase {:base-url "http://127.0.0.1:8090" - :users-collection "users" - :superuser-email "admin@example.org" - :superuser-password "secret"}}}) + :auth {:backend "pocketbase" + :pocketbase {:base-url "http://127.0.0.1:8090" + :users-collection "users" + :superuser-email "admin@example.org" + :superuser-password "secret"}}}}) clojure.java.io/as-file (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] (exists [] true))) @@ -48,13 +49,14 @@ (with-redefs [agiladmin.config/load-config (fn [_ _] {:agiladmin {:budgets {:ssh-key "test/assets/id_rsa"} - :pocketbase {:base-url "http://127.0.0.1:8090" - :users-collection "users" - :superuser-email "admin@example.org" - :superuser-password "secret" - :manage-process true - :binary "pocketbase" - :dir "/tmp/pb"}}}) + :auth {:backend "pocketbase" + :pocketbase {:base-url "http://127.0.0.1:8090" + :users-collection "users" + :superuser-email "admin@example.org" + :superuser-password "secret" + :manage-process true + :binary "pocketbase" + :dir "/tmp/pb"}}}}) clojure.java.io/as-file (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] (exists [] true))) @@ -124,6 +126,46 @@ [:init backend-instance] [:healthy]]))) +(fact "Ring init wires the Pocket ID backend when configured" + (let [calls (atom []) + backend-instance {:healthy? (fn [] true)}] + (with-redefs [agiladmin.config/load-config + (fn [_ _] + {:agiladmin {:budgets {:ssh-key "test/assets/id_rsa"} + :auth {:backend "pocket-id" + :pocket-id {:issuer-url "https://pocket-id.example.org" + :client-id "agiladmin" + :client-secret "secret" + :redirect-uri "https://agiladmin.example.org/auth/pocket-id/callback" + :admin-group "agiladmin-admin" + :manager-group "agiladmin-manager"}}}}) + clojure.java.io/as-file + (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] + (exists [] true))) + auxiliary.translation/init + (fn [& _] true) + agiladmin.auth.pocket-id/backend + (fn [config] + (swap! calls conj [:backend config]) + backend-instance) + agiladmin.auth.core/init! + (fn [backend] + (swap! calls conj [:init backend]) + backend) + agiladmin.auth.core/healthy? + (fn [] + (swap! calls conj [:healthy]) + true)] + (ring/init) => truthy + @calls => [[:backend {:issuer-url "https://pocket-id.example.org" + :client-id "agiladmin" + :client-secret "secret" + :redirect-uri "https://agiladmin.example.org/auth/pocket-id/callback" + :admin-group "agiladmin-admin" + :manager-group "agiladmin-manager"}] + [:init backend-instance] + [:healthy]]))) + (fact "Ring init fails with the config validation message when config loading fails" (with-redefs [agiladmin.config/load-config (fn [_ _] @@ -138,16 +180,19 @@ (with-redefs [agiladmin.config/load-config (fn [_ _] {:agiladmin {:budgets {:ssh-key "test/assets/id_rsa"} - :pocketbase {:base-url "http://127.0.0.1:8090" - :users-collection "users" - :superuser-email "admin@example.org" - :superuser-password "secret"}}}) + :auth {:backend "pocket-id" + :pocket-id {:issuer-url "https://pocket-id.example.org" + :client-id "agiladmin" + :client-secret "secret" + :redirect-uri "https://agiladmin.example.org/auth/pocket-id/callback" + :admin-group "agiladmin-admin" + :manager-group "agiladmin-manager"}}}}) clojure.java.io/as-file (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] (exists [] true))) auxiliary.translation/init (fn [& _] true) - agiladmin.auth.pocketbase/backend + agiladmin.auth.pocket-id/backend (fn [_] {:healthy? (fn [] true)}) agiladmin.auth.core/init! (fn [backend] @@ -160,3 +205,22 @@ false => true (catch clojure.lang.ExceptionInfo ex (.getMessage ex) => (contains "Authentication backend health check failed"))))) + +(fact "Ring init skips auth when no backend is configured and dev auth is disabled" + (let [calls (atom [])] + (with-redefs [agiladmin.config/load-config + (fn [_ _] + {:agiladmin {:budgets {:ssh-key "test/assets/id_rsa"}}}) + clojure.java.io/as-file + (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] + (exists [] true))) + auxiliary.translation/init + (fn [& _] true) + agiladmin.ring/dev-auth-enabled? + (fn [] false) + agiladmin.auth.core/init! + (fn [backend] + (swap! calls conj [:init backend]) + backend)] + (ring/init) => truthy + @calls => [[:init nil]]))) From 80705d6524f98e04826428b4f15d83dd4b3740ce Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:19:39 +0100 Subject: [PATCH 07/12] feat: implement pocket id oidc adapter --- .gestalt/plans/pocket-id-auth.org | 8 +- src/agiladmin/auth/pocket_id.clj | 308 +++++++++++++++++++++++++++++- test/agiladmin/pocket_id_test.clj | 161 ++++++++++++++++ 3 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 test/agiladmin/pocket_id_test.clj diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index 9b9d34f..0537167 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -38,7 +38,7 @@ Document precedence, for example `admin` wins over `manager`, unknown groups yie Tests: Add normalization tests for role derivation from representative claims payloads. Done when: A single source of truth exists for role mapping and it is independent of view code. -* WIP [#A] Add explicit backend selection and Pocket ID configuration +* DONE [#A] Add explicit backend selection and Pocket ID configuration Effort: M Goal: Allow Agiladmin to run with PocketBase, Pocket ID, or dev auth through explicit configuration instead of implicit presence checks. Notes: This is the key architectural cleanup. The current `ring/init` behavior picks PocketBase if the config exists, otherwise maybe dev auth. That will become ambiguous once two real providers exist. @@ -76,7 +76,7 @@ Avoid adding settings for Pocket ID user management unless Agiladmin truly needs Tests: Add schema validation coverage for required fields and reasonable defaults. Done when: The Pocket ID config is small, OIDC-native, and enough to bootstrap login without provider-specific hardcoding in views. -** WIP [#A] Refactor startup wiring to select adapters through one place +** DONE [#A] Refactor startup wiring to select adapters through one place Why: `src/agiladmin/ring.clj` currently knows too much about PocketBase process management and backend initialization. Adding Pocket ID there without cleanup will make startup logic fragile. Change: Add a small auth bootstrap decision point in `ring/init` that selects one backend from config and initializes `agiladmin.auth.core` with it. Keep PocketBase process management isolated to PocketBase only. Pocket ID should never share those code paths. Ensure health checks run only against the selected backend. Tests: Extend `test/agiladmin/ring_test.clj` to cover: @@ -87,12 +87,12 @@ auth health failure for Pocket ID, missing backend selection. Done when: Startup contains one explicit branch per backend and no provider inference logic remains. -* TODO [#A] Implement Pocket ID as an OIDC adapter and login slice +* WIP [#A] Implement Pocket ID as an OIDC adapter and login slice Effort: L Goal: Add the Pocket ID adapter plus the request/endpoint/response flow for passkey login. Notes: Keep this slice narrow. Do not attempt full identity management in Agiladmin. Pocket ID should own authentication; Agiladmin should only initiate login, verify the callback, and store a small session user. -** TODO [#A] Build a Pocket ID OIDC adapter under the auth hex boundary +** WIP [#A] Build a Pocket ID OIDC adapter under the auth hex boundary Why: The new adapter is the core IO integration. It must handle discovery, authorization redirect generation, token exchange, and claim extraction without leaking OIDC details to the rest of the app. Change: Add `src/agiladmin/auth/pocket_id.clj` with small functions for: OIDC discovery fetch and validation, diff --git a/src/agiladmin/auth/pocket_id.clj b/src/agiladmin/auth/pocket_id.clj index 2bb1527..9af53e5 100644 --- a/src/agiladmin/auth/pocket_id.clj +++ b/src/agiladmin/auth/pocket_id.clj @@ -1,18 +1,312 @@ (ns agiladmin.auth.pocket-id - (:require [failjure.core :as f])) + (:require [agiladmin.auth.user :as auth-user] + [cheshire.core :as json] + [clj-http.client :as http] + [clojure.string :as str] + [failjure.core :as f] + [ring.util.codec :as codec]) + (:import (java.math BigInteger) + (java.nio.charset StandardCharsets) + (java.security KeyFactory MessageDigest SecureRandom Signature) + (java.security.interfaces RSAPublicKey) + (java.security.spec RSAPublicKeySpec) + (java.time Instant) + (java.util Base64))) + +(def ^:private default-timeout-ms 2000) + +(defn- trim-slash + "Remove trailing slashes from an URL." + [value] + (str/replace value #"/+$" "")) + +(defn- endpoint + "Build an endpoint URL from a base URL and path." + [base-url path] + (str (trim-slash base-url) path)) + +(defn- request + "Perform an HTTP request with shared defaults." + [method url config options] + (http/request + (merge {:method method + :url url + :accept :json + :as :json + :coerce :always + :throw-exceptions false + :conn-timeout (or (:connect-timeout-ms config) + default-timeout-ms) + :socket-timeout (or (:socket-timeout-ms config) + default-timeout-ms)} + options))) + +(defn- ensure-success + "Return the parsed response body or throw with the provider message." + [response] + (if (<= 200 (:status response) 299) + (:body response) + (throw (ex-info (or (get-in response [:body :error_description]) + (get-in response [:body :message]) + "Pocket ID request failed.") + {:response response})))) + +(defn- random-token + "Generate a random URL-safe token." + [] + (let [bytes (byte-array 32) + encoder (.withoutPadding (Base64/getUrlEncoder))] + (.nextBytes (SecureRandom.) bytes) + (.encodeToString encoder bytes))) + +(defn- sha256 + "Hash a string with SHA-256." + [value] + (let [digest (MessageDigest/getInstance "SHA-256")] + (.digest digest (.getBytes value StandardCharsets/US_ASCII)))) + +(defn- encode-query + "Encode a map as a query string." + [params] + (->> params + (remove (comp nil? val)) + (map (fn [[k v]] + (str (codec/url-encode (name k)) + "=" + (codec/url-encode (str v))))) + (str/join "&"))) + +(defn- auth-flow + "Build the auth flow state stored in the Ring session." + [] + (let [verifier (random-token)] + {:provider "pocket-id" + :state (random-token) + :nonce (random-token) + :code-verifier verifier + :code-challenge (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) + (sha256 verifier))})) + +(defn- discovery-url + "Return the OIDC discovery URL for the issuer." + [config] + (endpoint (:issuer-url config) "/.well-known/openid-configuration")) + +(defn discovery + "Fetch the provider discovery document." + [config] + (-> (request :get (discovery-url config) config {}) + ensure-success)) + +(defn- authorization-url + "Build the browser redirect URL for login." + [config discovery-doc flow] + (str (:authorization_endpoint discovery-doc) + "?" + (encode-query + {:response_type "code" + :client_id (:client-id config) + :redirect_uri (:redirect-uri config) + :scope (str/join " " (:scopes config)) + :state (:state flow) + :nonce (:nonce flow) + :code_challenge (:code-challenge flow) + :code_challenge_method "S256"}))) + +(defn- token-response + "Exchange the authorization code for tokens." + [config discovery-doc code flow] + (-> (request :post + (:token_endpoint discovery-doc) + config + {:form-params {:grant_type "authorization_code" + :code code + :redirect_uri (:redirect-uri config) + :client_id (:client-id config) + :client_secret (:client-secret config) + :code_verifier (:code-verifier flow)}}) + ensure-success)) + +(defn- base64-url-decode + "Decode a URL-safe base64 string." + [value] + (.decode (Base64/getUrlDecoder) value)) + +(defn- parse-jwt + "Split a compact JWT into header, claims, and signature." + [token] + (let [[header payload signature :as parts] (str/split token #"\.")] + (when-not (= 3 (count parts)) + (throw (ex-info "Pocket ID returned an invalid ID token." + {:token token}))) + {:header-json (String. (base64-url-decode header) StandardCharsets/UTF_8) + :payload-json (String. (base64-url-decode payload) StandardCharsets/UTF_8) + :header-segment header + :payload-segment payload + :signature-bytes (base64-url-decode signature)})) + +(defn- rsa-public-key + "Build an RSA public key from JWK parameters." + [{:strs [n e]}] + (let [modulus (BigInteger. 1 (base64-url-decode n)) + exponent (BigInteger. 1 (base64-url-decode e)) + spec (RSAPublicKeySpec. modulus exponent)] + (.generatePublic (KeyFactory/getInstance "RSA") spec))) + +(defn- verify-rs256 + "Verify an RS256 signature." + [jwt jwk] + (let [signature (doto (Signature/getInstance "SHA256withRSA") + (.initVerify ^RSAPublicKey (rsa-public-key jwk)) + (.update (.getBytes (str (:header-segment jwt) + "." + (:payload-segment jwt)) + StandardCharsets/US_ASCII)))] + (.verify signature (:signature-bytes jwt)))) + +(defn- matching-jwk + "Return the JWK referenced by the JWT header." + [jwks header] + (let [kid (get header "kid") + candidates (:keys jwks)] + (or (some #(when (= kid (get % "kid")) %) candidates) + (first candidates)))) + +(defn jwks + "Fetch the provider JWKS." + [config discovery-doc] + (-> (request :get (:jwks_uri discovery-doc) config {}) + ensure-success)) + +(defn- current-epoch-seconds + "Return the current time in epoch seconds." + [] + (.getEpochSecond (Instant/now))) + +(defn- validate-claims + "Validate ID token claims against config and the auth flow." + [config claims flow] + (let [aud (:aud claims) + audiences (if (sequential? aud) aud [aud])] + (cond + (not= (:issuer-url config) (:iss claims)) + (f/fail "Pocket ID issuer mismatch.") + + (not (some #(= (:client-id config) %) audiences)) + (f/fail "Pocket ID audience mismatch.") + + (not= (:nonce flow) (:nonce claims)) + (f/fail "Pocket ID nonce mismatch.") + + (<= (or (:exp claims) 0) (current-epoch-seconds)) + (f/fail "Pocket ID ID token has expired.") + + :else + claims))) + +(defn- userinfo + "Fetch userinfo when claims are not sufficient in the ID token." + [config discovery-doc access-token] + (if-let [userinfo-endpoint (:userinfo_endpoint discovery-doc)] + (-> (request :get + userinfo-endpoint + config + {:headers {"Authorization" (str "Bearer " access-token)}}) + ensure-success) + {})) + +(defn- normalize-session-user + "Convert OIDC claims into the Agiladmin session user map." + [config claims] + (let [groups (or (:groups claims) []) + email (:email claims) + name (or (:name claims) + (:preferred_username claims) + email)] + (cond + (str/blank? email) + (f/fail "Pocket ID did not return an email address.") + + :else + {:id (:sub claims) + :email email + :name name + :role (auth-user/role-from-groups groups config) + :other-names [] + :verified (not (false? (:email_verified claims)))}))) + +(defn begin-login + "Start the Pocket ID login flow and return a redirect response." + [config request] + (let [flow (auth-flow) + discovery-doc (discovery config) + redirect-url (authorization-url config discovery-doc flow) + session (assoc (:session request) :auth-flow flow)] + {:status 302 + :headers {"Location" redirect-url} + :session session + :body ""})) + +(defn complete-login + "Complete the callback by validating state and the ID token." + [config request] + (let [flow (get-in request [:session :auth-flow]) + params (:params request) + state (or (:state params) (get params "state")) + code (or (:code params) (get params "code")) + error (or (:error params) (get params "error"))] + (cond + error + (f/fail (str "Pocket ID login failed: " error)) + + (nil? flow) + (f/fail "Pocket ID login flow is missing from the session.") + + (not= "pocket-id" (:provider flow)) + (f/fail "Pocket ID login flow is invalid.") + + (not= (:state flow) state) + (f/fail "Pocket ID state mismatch.") + + (str/blank? code) + (f/fail "Pocket ID callback is missing the authorization code.") + + :else + (let [discovery-doc (discovery config) + token-body (token-response config discovery-doc code flow) + jwt (parse-jwt (:id_token token-body)) + header (json/parse-string (:header-json jwt)) + claims (json/parse-string (:payload-json jwt) true) + _alg (when-not (= "RS256" (get header "alg")) + (throw (ex-info "Unsupported Pocket ID ID token algorithm." + {:alg (get header "alg")}))) + jwk (matching-jwk (jwks config discovery-doc) header) + _verified (when-not (verify-rs256 jwt jwk) + (throw (ex-info "Pocket ID ID token signature verification failed." + {:kid (get header "kid")}))) + validated-claims (validate-claims config claims flow) + merged-claims (merge validated-claims + (when-let [access-token (:access_token token-body)] + (userinfo config discovery-doc access-token)))] + (normalize-session-user config merged-claims))))) (defn healthy? - [_config] - true) + "Return true when discovery succeeds." + [config] + (try + (boolean (discovery config)) + (catch Exception _ + false))) (defn backend + "Return the Pocket ID auth backend." [config] {:kind :pocket-id :healthy? (fn [] (healthy? config)) :login-entry-response (fn [_request] nil) - :begin-login (fn [_request] - (f/fail "Pocket ID login is not implemented yet.")) - :complete-login (fn [_request] - (f/fail "Pocket ID login is not implemented yet."))}) + :begin-login (fn [request] + (begin-login config request)) + :complete-login (fn [request] + (complete-login config request))}) diff --git a/test/agiladmin/pocket_id_test.clj b/test/agiladmin/pocket_id_test.clj new file mode 100644 index 0000000..1563b32 --- /dev/null +++ b/test/agiladmin/pocket_id_test.clj @@ -0,0 +1,161 @@ +(ns agiladmin.pocket-id-test + (:require [agiladmin.auth.pocket-id :as pocket-id] + [agiladmin.config :as conf] + [cheshire.core :as json] + [failjure.core :as f] + [midje.sweet :refer :all]) + (:import (java.security KeyPairGenerator Signature) + (java.security.interfaces RSAPublicKey) + (java.time Instant) + (java.util Base64))) + +(def config + {:issuer-url "https://pocket-id.example.org" + :client-id "agiladmin" + :client-secret "secret" + :redirect-uri "https://agiladmin.example.org/auth/pocket-id/callback" + :admin-group "agiladmin-admin" + :manager-group "agiladmin-manager" + :scopes conf/default-pocket-id-scopes}) + +(defn- encoder + [] + (.withoutPadding (Base64/getUrlEncoder))) + +(defn- base64-url + [value] + (.encodeToString (encoder) value)) + +(defn- jwt-segment + [value] + (base64-url (.getBytes (json/generate-string value) "UTF-8"))) + +(defn- jwk-from-public-key + [kid ^RSAPublicKey public-key] + {"kid" kid + "kty" "RSA" + "alg" "RS256" + "use" "sig" + "n" (base64-url (.toByteArray (.getModulus public-key))) + "e" (base64-url (.toByteArray (.getPublicExponent public-key)))}) + +(defn- sign-jwt + [private-key header claims] + (let [header-segment (jwt-segment header) + payload-segment (jwt-segment claims) + signing-input (str header-segment "." payload-segment) + signature (doto (Signature/getInstance "SHA256withRSA") + (.initSign private-key) + (.update (.getBytes signing-input "UTF-8")))] + (str signing-input "." (base64-url (.sign signature))))) + +(defn- key-pair + [] + (let [generator (KeyPairGenerator/getInstance "RSA")] + (.initialize generator 2048) + (.generateKeyPair generator))) + +(fact "Pocket ID begin-login stores state and redirects to the authorization endpoint" + (with-redefs [agiladmin.auth.pocket-id/discovery + (fn [_] + {:authorization_endpoint "https://pocket-id.example.org/authorize"})] + (let [response (pocket-id/begin-login config {:session {:config :present}}) + location (get-in response [:headers "Location"])] + (:status response) => 302 + location => (contains "https://pocket-id.example.org/authorize") + location => (contains "code_challenge_method=S256") + (get-in response [:session :auth-flow :provider]) => "pocket-id" + (get-in response [:session :config]) => :present))) + +(fact "Pocket ID complete-login validates the callback and maps groups to roles" + (let [pair (key-pair) + flow {:provider "pocket-id" + :state "state-1" + :nonce "nonce-1" + :code-verifier "verifier-1"} + claims {:iss "https://pocket-id.example.org" + :sub "user-1" + :aud "agiladmin" + :nonce "nonce-1" + :email "user@example.org" + :name "User Name" + :email_verified true + :groups ["agiladmin-admin"] + :exp (+ 300 (.getEpochSecond (Instant/now)))} + token (sign-jwt (.getPrivate pair) {"alg" "RS256" "kid" "kid-1"} claims)] + (with-redefs [agiladmin.auth.pocket-id/discovery + (fn [_] + {:token_endpoint "https://pocket-id.example.org/token" + :jwks_uri "https://pocket-id.example.org/jwks" + :userinfo_endpoint "https://pocket-id.example.org/userinfo"}) + agiladmin.auth.pocket-id/token-response + (fn [_ _ code flow-arg] + code => "code-1" + flow-arg => flow + {:id_token token + :access_token "access-1"}) + agiladmin.auth.pocket-id/jwks + (fn [_ _] + {:keys [(jwk-from-public-key "kid-1" (.getPublic pair))]}) + agiladmin.auth.pocket-id/userinfo + (fn [& _] + {})] + (pocket-id/complete-login + config + {:session {:auth-flow flow} + :params {:state "state-1" + :code "code-1"}}) + => {:id "user-1" + :email "user@example.org" + :name "User Name" + :role "admin" + :other-names [] + :verified true}))) + +(fact "Pocket ID complete-login can pull missing groups from userinfo" + (let [pair (key-pair) + flow {:provider "pocket-id" + :state "state-2" + :nonce "nonce-2" + :code-verifier "verifier-2"} + claims {:iss "https://pocket-id.example.org" + :sub "user-2" + :aud "agiladmin" + :nonce "nonce-2" + :email "manager@example.org" + :name "Manager User" + :exp (+ 300 (.getEpochSecond (Instant/now)))} + token (sign-jwt (.getPrivate pair) {"alg" "RS256" "kid" "kid-2"} claims)] + (with-redefs [agiladmin.auth.pocket-id/discovery + (fn [_] + {:token_endpoint "https://pocket-id.example.org/token" + :jwks_uri "https://pocket-id.example.org/jwks" + :userinfo_endpoint "https://pocket-id.example.org/userinfo"}) + agiladmin.auth.pocket-id/token-response + (fn [& _] + {:id_token token + :access_token "access-2"}) + agiladmin.auth.pocket-id/jwks + (fn [_ _] + {:keys [(jwk-from-public-key "kid-2" (.getPublic pair))]}) + agiladmin.auth.pocket-id/userinfo + (fn [& _] + {:groups ["agiladmin-manager"]})] + (:role (pocket-id/complete-login + config + {:session {:auth-flow flow} + :params {:state "state-2" + :code "code-2"}})) + => "manager"))) + +(fact "Pocket ID complete-login rejects a state mismatch" + (let [result (pocket-id/complete-login + config + {:session {:auth-flow {:provider "pocket-id" + :state "expected" + :nonce "nonce" + :code-verifier "verifier"}} + :params {:state "actual" + :code "code-1"}})] + (f/failed? result) => true + (f/message result) => "Pocket ID state mismatch.")) From 941a16c0e95eec5a9ea4e1972f8a885de36c5bc9 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:19:46 +0100 Subject: [PATCH 08/12] feat: add pocket id login routes and callback --- .gestalt/plans/pocket-id-auth.org | 4 +- src/agiladmin/handlers.clj | 3 + src/agiladmin/view_auth.clj | 174 +++++++++++++++++++----------- src/agiladmin/webpage.clj | 26 +++++ test/agiladmin/handlers_test.clj | 24 +++++ test/agiladmin/view_auth_test.clj | 43 ++++++++ 6 files changed, 211 insertions(+), 63 deletions(-) diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index 0537167..8d8b45f 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -92,7 +92,7 @@ Effort: L Goal: Add the Pocket ID adapter plus the request/endpoint/response flow for passkey login. Notes: Keep this slice narrow. Do not attempt full identity management in Agiladmin. Pocket ID should own authentication; Agiladmin should only initiate login, verify the callback, and store a small session user. -** WIP [#A] Build a Pocket ID OIDC adapter under the auth hex boundary +** DONE [#A] Build a Pocket ID OIDC adapter under the auth hex boundary Why: The new adapter is the core IO integration. It must handle discovery, authorization redirect generation, token exchange, and claim extraction without leaking OIDC details to the rest of the app. Change: Add `src/agiladmin/auth/pocket_id.clj` with small functions for: OIDC discovery fetch and validation, @@ -107,7 +107,7 @@ Model this as a classic Hex adapter: config and HTTP are adapter concerns; user Tests: Add adapter unit tests with stubbed HTTP calls for discovery, token exchange, JWKS retrieval, and claims normalization. Include failure cases for bad issuer, state mismatch, nonce mismatch, missing claims, and unknown role groups. Done when: The adapter can transform a valid Pocket ID callback into the existing Agiladmin session user map without any view-specific branching. -** TODO [#A] Add REPR route handling for login start and callback completion +** WIP [#A] Add REPR route handling for login start and callback completion Why: The current auth REPR is a simple form POST. Pocket ID needs a request flow with redirect initiation and callback completion. Change: Replace or branch the auth route handling so the active backend drives the flow: GET `/login` remains the entry page. diff --git a/src/agiladmin/handlers.clj b/src/agiladmin/handlers.clj index 66c7396..ae800f4 100644 --- a/src/agiladmin/handlers.clj +++ b/src/agiladmin/handlers.clj @@ -145,9 +145,12 @@ ;; login / logout (GET "/login" request (view-auth/login-get request)) + (GET "/login/start" request (view-auth/login-start request)) (POST "/login" request (view-auth/login-post request)) + (GET "/auth/pocket-id/callback" request (view-auth/pocket-id-callback request)) + ;; (GET "/session" request ;; (-> (:session request) web/render-yaml web/render)) diff --git a/src/agiladmin/view_auth.clj b/src/agiladmin/view_auth.clj index 4179c7b..d0297a6 100644 --- a/src/agiladmin/view_auth.clj +++ b/src/agiladmin/view_auth.clj @@ -26,6 +26,8 @@ [agiladmin.config :as conf] [failjure.core :as f])) +(declare login-start) + (defonce config (conf/load-config "agiladmin" conf/default-settings)) (defn- get-client-ip [req] @@ -33,25 +35,24 @@ (-> ips (clojure.string/split #",") first) (:remote-addr req))) -(defn login-get [request] - (f/attempt-all - [acct (s/check-account @ring/config request)] - (web/render acct - [:div {:class "card mx-auto max-w-xl bg-base-100 shadow-xl"} - [:div {:class "card-body gap-4"} - [:h1 {:class "card-title text-3xl"} - (str "Already logged in with account: " (:email acct))] - [:div {:class "card-actions"} - [:a {:class "btn btn-primary" :href "/logout"} "Logout"]]]]) - (f/when-failed [e] - (web/render web/login-form)))) +(defn- active-backend-kind [] + (let [kind (auth/backend-kind)] + (when-not (f/failed? kind) + kind))) -(defn login-post [request] +(defn- login-shell + [] + (case (active-backend-kind) + :pocket-id web/pocket-id-login-form + web/login-form)) + +(defn- password-login-response + [request] (f/attempt-all [username (or (get-in request [:params :email]) (get-in request [:params :username]) (f/fail "Parameter not found: :email")) - password (s/param request :password) + password (s/param request :password) logged (auth/sign-in username password {:ip-address (get-client-ip request)})] (let [session {:session {:config config :auth (s/normalize-role logged)}} @@ -72,61 +73,112 @@ (web/render-error-page (str "Login failed: " (f/message e)))))) +(defn login-get [request] + (f/attempt-all + [acct (s/check-account @ring/config request)] + (web/render acct + [:div {:class "card mx-auto max-w-xl bg-base-100 shadow-xl"} + [:div {:class "card-body gap-4"} + [:h1 {:class "card-title text-3xl"} + (str "Already logged in with account: " (:email acct))] + [:div {:class "card-actions"} + [:a {:class "btn btn-primary" :href "/logout"} "Logout"]]]]) + (f/when-failed [e] + (web/render (login-shell))))) + +(defn login-post [request] + (if (= :pocket-id (active-backend-kind)) + (login-start request) + (password-login-response request))) + +(defn login-start [request] + (f/attempt-all + [response (auth/begin-login request)] + response + (f/when-failed [e] + (web/render-error-page + (str "Login failed: " (f/message e)))))) + +(defn pocket-id-callback [request] + (f/attempt-all + [logged (auth/complete-login request)] + {:status 302 + :headers {"Location" "/persons/list"} + :session {:config config + :auth (s/normalize-role logged)} + :body ""} + (f/when-failed [e] + (web/render-error-page + (str "Login failed: " (f/message e)))))) + (defn logout-get [request] - (conj {:session {:config config}} - (web/render [:h1 "Logged out."]))) + (let [response (auth/logout-response request) + session (merge (:session response) {:config config})] + (assoc response :session session))) (defn signup-get [request] - (web/render web/signup-form)) + (if (= :pocket-id (active-backend-kind)) + (web/render + (web/signup-disabled-card + "Pocket ID manages user onboarding. Ask an administrator to create your account and enroll a passkey there.")) + (web/render web/signup-form))) (defn signup-post [request] - (f/attempt-all - [name (s/param request :name) - email (s/param request :email) - password (s/param request :password) - repeat-password (s/param request :repeat-password)] - (web/render - (if (= password repeat-password) - (f/try* - (f/if-let-ok? - [signup (auth/sign-up name - email - password - {} - [])] - (f/if-let-failed? - [verification (auth/request-verification email)] + (if (= :pocket-id (active-backend-kind)) + (web/render + (web/signup-disabled-card + "Pocket ID manages sign-up and passkey enrollment outside Agiladmin.")) + (f/attempt-all + [name (s/param request :name) + email (s/param request :email) + password (s/param request :password) + repeat-password (s/param request :repeat-password)] + (web/render + (if (= password repeat-password) + (f/try* + (f/if-let-ok? + [signup (auth/sign-up name + email + password + {} + [])] + (f/if-let-failed? + [verification (auth/request-verification email)] + (web/render-error + (str "Failure requesting verification: " + (f/message verification))) + [:div {:class "card mx-auto max-w-xl bg-base-100 shadow-xl"} + [:div {:class "card-body"} + [:h2 (str "Account created: " + name " <" email ">")] + [:h3 "Account pending activation. Check your email for the verification link."]]]) (web/render-error - (str "Failure requesting verification: " - (f/message verification))) - [:div {:class "card mx-auto max-w-xl bg-base-100 shadow-xl"} - [:div {:class "card-body"} - [:h2 (str "Account created: " - name " <" email ">")] - [:h3 "Account pending activation. Check your email for the verification link."]]]) - (web/render-error - (str "Failure creating account: " - (f/message signup))))) - (web/render-error - "Repeat password didnt match"))) - (f/when-failed [e] - (web/render-error-page - (str "Sign-up failure: " (f/message e)))))) + (str "Failure creating account: " + (f/message signup))))) + (web/render-error + "Repeat password didnt match"))) + (f/when-failed [e] + (web/render-error-page + (str "Sign-up failure: " (f/message e))))))) (defn activate ([request token] (activate request nil token)) ([request email token] - (web/render - [:div - [:div {:class "card mx-auto max-w-xl bg-base-100 shadow-xl"} - [:div {:class "card-body"} - (f/if-let-failed? - [act (auth/confirm-verification email token)] - (web/render-error - [:div - [:h1 "Failure activating account"] - [:h2 (f/message act)] - [:p (str "Token: " token)]]) - [:h1 {:class "card-title text-3xl"} - (str "Account activated" (when email (str " - " email)))])]]]))) + (if (= :pocket-id (active-backend-kind)) + (web/render + (web/signup-disabled-card + "Pocket ID manages account activation outside Agiladmin.")) + (web/render + [:div + [:div {:class "card mx-auto max-w-xl bg-base-100 shadow-xl"} + [:div {:class "card-body"} + (f/if-let-failed? + [act (auth/confirm-verification email token)] + (web/render-error + [:div + [:h1 "Failure activating account"] + [:h2 (f/message act)] + [:p (str "Token: " token)]]) + [:h1 {:class "card-title text-3xl"} + (str "Account activated" (when email (str " - " email)))])]]])))) diff --git a/src/agiladmin/webpage.clj b/src/agiladmin/webpage.clj index d0c2e6c..11a10e7 100644 --- a/src/agiladmin/webpage.clj +++ b/src/agiladmin/webpage.clj @@ -528,3 +528,29 @@ :class "input input-bordered w-full"}] [:input {:type "submit" :value "Sign Up" :class "btn btn-primary btn-lg w-full"}]]]]]) + +(defonce pocket-id-login-form + [:div {:class "mx-auto max-w-lg"} + [:div {:class "card bg-base-100 shadow-xl"} + [:div {:class "card-body gap-4"} + [:h1 {:class "card-title text-3xl"} "Login into Agiladmin"] + [:p {:class "text-base-content/80"} + "Authentication is handled by Pocket ID with a passkey-enabled sign-in flow."] + [:a {:href "/login/start" + :class "btn btn-primary btn-lg w-full"} + "Sign in with Pocket ID"] + [:p {:class "text-sm text-base-content/70"} + "Use your enrolled passkey on the Pocket ID screen after redirect."] + [:p {:class "text-sm text-base-content/70"} + "Unauthorized access is prohibited. Every visit is recorded."]]]]) + +(defn signup-disabled-card + [message] + [:div {:class "mx-auto max-w-lg"} + [:div {:class "card bg-base-100 shadow-xl"} + [:div {:class "card-body gap-4"} + [:h1 {:class "card-title text-3xl"} "Sign Up Agiladmin"] + [:p {:class "text-base-content/80"} message] + [:a {:href "/login" + :class "btn btn-primary"} + "Back to login"]]]]) diff --git a/test/agiladmin/handlers_test.clj b/test/agiladmin/handlers_test.clj index 0640ea5..db29ef0 100644 --- a/test/agiladmin/handlers_test.clj +++ b/test/agiladmin/handlers_test.clj @@ -186,6 +186,30 @@ @calls => [{:email "user@example.org" :password "secret"}])))) +(fact "Login start routes to the auth view" + (with-redefs [agiladmin.view-auth/login-start + (fn [_] + {:status 302 + :headers {"Location" "https://pocket-id.example.org/authorize"} + :body ""})] + (let [response (handlers/app-routes (mock/request :get "/login/start"))] + (:status response) => 302 + (get-in response [:headers "Location"]) => "https://pocket-id.example.org/authorize"))) + +(fact "Pocket ID callback routes to the auth view" + (with-redefs [agiladmin.view-auth/pocket-id-callback + (fn [request] + (select-keys (:params request) [:code :state]) + {:status 302 + :headers {"Location" "/persons/list"} + :body ""})] + (let [response (handlers/app-routes + (assoc (mock/request :get "/auth/pocket-id/callback?code=abc&state=xyz") + :params {:code "abc" + :state "xyz"}))] + (:status response) => 302 + (get-in response [:headers "Location"]) => "/persons/list"))) + (fact "Signup post routes submitted fields to the auth view" (let [calls (atom [])] (with-redefs [agiladmin.view-auth/signup-post diff --git a/test/agiladmin/view_auth_test.clj b/test/agiladmin/view_auth_test.clj index 5d86d8d..3069515 100644 --- a/test/agiladmin/view_auth_test.clj +++ b/test/agiladmin/view_auth_test.clj @@ -68,6 +68,49 @@ :remote-addr "127.0.0.1"})] (:body response) => (contains "Login failed: Invalid credentials.")))) +(fact "Pocket ID login get renders the Pocket ID entry card" + (with-redefs [agiladmin.auth.core/backend-kind (fn [] :pocket-id)] + (let [response (view-auth/login-get {})] + (:body response) => (contains "Sign in with Pocket ID")))) + +(fact "Pocket ID login start delegates through the auth boundary" + (let [request {:session {:config :present}} + response {:status 302 + :headers {"Location" "https://pocket-id.example.org/authorize"} + :session {:config :present + :auth-flow {:provider "pocket-id"}}}] + (with-redefs [agiladmin.auth.core/begin-login (fn [value] + value => request + response)] + (view-auth/login-start request) => response))) + +(fact "Pocket ID login callback stores the authenticated account in session" + (with-redefs [agiladmin.auth.core/complete-login (fn [_] + {:id "user-1" + :email "user@example.org" + :name "User Name" + :role "admin" + :verified true})] + (let [response (view-auth/pocket-id-callback {:session {:auth-flow {:provider "pocket-id"}} + :params {:code "abc" + :state "state"}})] + (:status response) => 302 + (get-in response [:headers "Location"]) => "/persons/list" + (get-in response [:session :auth :email]) => "user@example.org" + (get-in response [:session :auth :role]) => "admin"))) + +(fact "Pocket ID signup routes render the disabled onboarding message" + (with-redefs [agiladmin.auth.core/backend-kind (fn [] :pocket-id)] + (let [get-response (view-auth/signup-get {}) + post-response (view-auth/signup-post {:params {:name "User Name"}})] + (:body get-response) => (contains "Pocket ID manages user onboarding") + (:body post-response) => (contains "Pocket ID manages sign-up")))) + +(fact "Pocket ID activate route renders the disabled onboarding message" + (with-redefs [agiladmin.auth.core/backend-kind (fn [] :pocket-id)] + (let [response (view-auth/activate {} "token-1")] + (:body response) => (contains "Pocket ID manages account activation")))) + (fact "Login get reports the active account when already authenticated" (let [response (view-auth/login-get {:session {:auth {:email "user@example.org" :name "User Name"}}})] From 703d2d645bed128551ef2188f2debe5ddf583193 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:20:27 +0100 Subject: [PATCH 09/12] feat: keep pocket id logout local --- .gestalt/plans/pocket-id-auth.org | 4 ++-- src/agiladmin/auth/pocket_id.clj | 7 ++++++- test/agiladmin/pocket_id_test.clj | 5 +++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index 8d8b45f..ed8411c 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -107,7 +107,7 @@ Model this as a classic Hex adapter: config and HTTP are adapter concerns; user Tests: Add adapter unit tests with stubbed HTTP calls for discovery, token exchange, JWKS retrieval, and claims normalization. Include failure cases for bad issuer, state mismatch, nonce mismatch, missing claims, and unknown role groups. Done when: The adapter can transform a valid Pocket ID callback into the existing Agiladmin session user map without any view-specific branching. -** WIP [#A] Add REPR route handling for login start and callback completion +** DONE [#A] Add REPR route handling for login start and callback completion Why: The current auth REPR is a simple form POST. Pocket ID needs a request flow with redirect initiation and callback completion. Change: Replace or branch the auth route handling so the active backend drives the flow: GET `/login` remains the entry page. @@ -122,7 +122,7 @@ callback failure renders a clear login error, PocketBase still accepts the old login form path. Done when: Pocket ID login works as a browser redirect loop and the route contract is isolated to the auth slice. -** TODO [#A] Define how logout behaves for Pocket ID +** WIP [#A] Define how logout behaves for Pocket ID Why: OIDC logout behavior is often different from local session clearing. The app must make a deliberate choice instead of assuming local logout is enough. Change: Decide between: local-only logout, which clears the Ring session and returns to `/login`; diff --git a/src/agiladmin/auth/pocket_id.clj b/src/agiladmin/auth/pocket_id.clj index 9af53e5..bebdbb3 100644 --- a/src/agiladmin/auth/pocket_id.clj +++ b/src/agiladmin/auth/pocket_id.clj @@ -309,4 +309,9 @@ :begin-login (fn [request] (begin-login config request)) :complete-login (fn [request] - (complete-login config request))}) + (complete-login config request)) + :logout-response (fn [_request] + {:status 302 + :headers {"Location" "/login"} + :session {} + :body ""})}) diff --git a/test/agiladmin/pocket_id_test.clj b/test/agiladmin/pocket_id_test.clj index 1563b32..206a071 100644 --- a/test/agiladmin/pocket_id_test.clj +++ b/test/agiladmin/pocket_id_test.clj @@ -159,3 +159,8 @@ :code "code-1"}})] (f/failed? result) => true (f/message result) => "Pocket ID state mismatch.")) + +(fact "Pocket ID backend uses local logout" + (let [response (((pocket-id/backend config) :logout-response) {})] + (get-in response [:headers "Location"])) + => "/login") From 0caba96c759233b6847f641db9d2223b84c8348b Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 02:23:19 +0100 Subject: [PATCH 10/12] docs: add pocket id operator guidance --- .gestalt/plans/pocket-id-auth.org | 18 ++++---- AGENTS.md | 16 +++---- README.md | 74 +++++++++++++++++++++++++------ doc/agiladmin.pocket-id.yaml | 35 +++++++++++++++ doc/agiladmin.pocketbase.yaml | 26 ++++++----- doc/pocket-id-migration.md | 43 ++++++++++++++++++ src/agiladmin/pocketbase_init.clj | 5 ++- test/agiladmin/config_test.clj | 2 +- 8 files changed, 174 insertions(+), 45 deletions(-) create mode 100644 doc/agiladmin.pocket-id.yaml create mode 100644 doc/pocket-id-migration.md diff --git a/.gestalt/plans/pocket-id-auth.org b/.gestalt/plans/pocket-id-auth.org index ed8411c..87b36f1 100644 --- a/.gestalt/plans/pocket-id-auth.org +++ b/.gestalt/plans/pocket-id-auth.org @@ -122,7 +122,7 @@ callback failure renders a clear login error, PocketBase still accepts the old login form path. Done when: Pocket ID login works as a browser redirect loop and the route contract is isolated to the auth slice. -** WIP [#A] Define how logout behaves for Pocket ID +** DONE [#A] Define how logout behaves for Pocket ID Why: OIDC logout behavior is often different from local session clearing. The app must make a deliberate choice instead of assuming local logout is enough. Change: Decide between: local-only logout, which clears the Ring session and returns to `/login`; @@ -131,12 +131,12 @@ Choose the simplest viable approach first. If Pocket ID logout support is uncert Tests: Add a route test covering the chosen logout response for the Pocket ID backend. Done when: Logout behavior is intentional, documented, and tested. -* TODO [#B] Reconcile unsupported use-cases and adapt the UI +* DONE [#B] Reconcile unsupported use-cases and adapt the UI Effort: M Goal: Keep the application coherent when Pocket ID is active, even though signup and verification do not map one-to-one from PocketBase. Notes: Pocket ID user creation and passkey bootstrap are admin-driven or token-driven. Agiladmin should not fake self-service flows that it does not control. -** TODO [#A] Gate or replace signup and verification screens when Pocket ID is active +** DONE [#A] Gate or replace signup and verification screens when Pocket ID is active Why: The current `/signup` and `/activate/...` screens assume the app itself can create accounts and request verification. That is not true for Pocket ID in the same way. Change: For the Pocket ID backend, change `view_auth` so signup routes either: render a provider-specific explanation page with next steps, @@ -146,7 +146,7 @@ Keep PocketBase behavior intact when that backend is selected. Tests: Add view and route tests proving Pocket ID mode does not show misleading password or verification UI. Done when: A user on Pocket ID sees only valid actions and no dead-end signup flow. -** TODO [#B] Remove PocketBase-specific language from shared login UI +** DONE [#B] Remove PocketBase-specific language from shared login UI Why: The login page should describe the active auth mode accurately. A passkey redirect flow should not ask for a password. Change: Update `src/agiladmin/view_auth.clj` and shared login helpers in `src/agiladmin/webpage.clj` so the page renders backend-specific content: PocketBase: existing email/password form. @@ -155,7 +155,7 @@ Preserve full-page behavior first; HTMX is not required here. Tests: Add rendering tests that assert backend-specific login markup. Done when: The login page never presents the wrong authentication mechanism for the configured backend. -** TODO [#B] Decide how admin workflows handle pending users under Pocket ID +** DONE [#B] Decide how admin workflows handle pending users under Pocket ID Why: PocketBase exposes pending verification users; Pocket ID has different user onboarding semantics. The admin people screen must not rely on a capability the backend does not provide. Change: Review where `list-pending-users` is used, especially in `view_person`. For Pocket ID choose one of: hide the pending-users section entirely, @@ -165,12 +165,12 @@ Prefer hiding over inventing partial admin sync logic. Tests: Add tests for the people/admin view in Pocket ID mode so the screen remains valid without pending-user data. Done when: Admin pages remain coherent in Pocket ID mode and no code assumes verification queues exist. -* TODO [#B] Verify, document, and stage migration +* DONE [#B] Verify, document, and stage migration Effort: M Goal: Make the change operable for reviewers and operators, not just implementable in code. Notes: Since this adds a second real provider, rollout clarity matters. Keep docs minimal but concrete. -** TODO [#A] Add focused tests for the Pocket ID happy path and failure modes +** DONE [#A] Add focused tests for the Pocket ID happy path and failure modes Why: OIDC integrations fail in subtle ways. Without explicit tests, regressions will be hard to diagnose. Change: Add a test matrix that covers: config validation, @@ -186,7 +186,7 @@ Keep tests local and stub HTTP rather than requiring a live Pocket ID instance u Tests: Run touched Midje namespaces during each L2 that changes code. After the full L1 is complete, run the whole test suite as required by the workflow. Done when: The new backend is covered by deterministic tests at the adapter, route, and startup layers. -** TODO [#B] Update operator documentation and sample configuration +** DONE [#B] Update operator documentation and sample configuration Why: Reviewers and deployers need exact setup steps for Pocket ID client creation and Agiladmin config. Without docs, the feature will look incomplete. Change: Update `README.md` and add a sample config in `doc/`, following the current PocketBase pattern. Document: the exact redirect URI for Agiladmin, @@ -200,7 +200,7 @@ Keep the docs explicit about dates and current provider behavior, for example th Tests: No automated tests required for doc-only changes. Done when: A reviewer can configure Pocket ID from the repository docs without reading the implementation. -** TODO [#B] Write a migration and rollback note from PocketBase to Pocket ID +** DONE [#B] Write a migration and rollback note from PocketBase to Pocket ID Why: “Alternative to PocketBase” implies operators may compare or switch providers. The plan should include a safe operational story. Change: Add a short migration note covering: what data does not migrate automatically, diff --git a/AGENTS.md b/AGENTS.md index c8e4cc1..51af0a0 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - The app reads monthly `.xlsx` timesheets, loads project definitions from YAML files in a separate budgets repository, derives hours and costs, and renders HTML reports. - The storage model is mixed: - project metadata and uploaded spreadsheets live in a Git-backed budgets directory; - - authentication goes through an auth boundary, with PocketBase currently implemented and a dev-only fallback for local testing. + - authentication is selected via `agiladmin.auth.backend` and currently supports PocketBase, Pocket ID, or dev auth. ## Stack - Language: Clojure 1.12.4 @@ -13,13 +13,13 @@ - Web: Ring + Compojure - HTML: Hiccup-style vectors rendered by view namespaces - Data processing: Incanter datasets, Docjure/Apache POI for Excel, YAML parsing via `yaml.core` -- Auth: PocketBase via `src/agiladmin/auth/core.clj`, plus a development auth backend +- Auth: backend abstraction in `src/agiladmin/auth/` - Git integration: `clj-jgit` ## Entry Points - Main HTTP routes are in `src/agiladmin/handlers.clj`. - Application initialization is in `src/agiladmin/ring.clj`. - - `ring/init` loads configuration, ensures the SSH key exists, connects to MongoDB, and initializes auth stores. + - `ring/init` loads configuration, ensures the SSH key exists, and initializes the selected auth backend. - Core spreadsheet and project logic is in `src/agiladmin/core.clj`. - The main user-facing views are split by domain: - `src/agiladmin/view_project.clj` @@ -46,7 +46,7 @@ - `:budgets` - `:webserver` - `:source` - - `:just-auth` + - `:auth` - Project configs are separate YAML files stored under the configured budgets path and loaded by `load-project`. - Tests use fixture config under `test/assets/agiladmin.yaml`. @@ -83,9 +83,8 @@ - Covered areas: - config parsing and schema validation - spreadsheet ingestion and cost derivation - - auth backends and session behavior - - selected route and view behavior - - minimal `ring/init` smoke test + - utility functions + - auth adapters, route behavior, and `ring/init` backend selection - Not well covered: - HTTP route behavior - auth flows @@ -104,7 +103,7 @@ - `src/agiladmin/view_timesheet.clj` - upload, temp-file handling, Git add/commit/push, and filesystem assumptions are all coupled. - `src/agiladmin/ring.clj` - - startup performs real side effects: config load, SSH key generation, Mongo connection, auth initialization. + - startup performs real side effects: config load, SSH key generation, PocketBase process management, and auth initialization. - `src/agiladmin/config.clj` - config merging and schema handling are permissive and a bit irregular; changes here can affect every feature. @@ -113,6 +112,7 @@ - Keep root navigation and navbar home links aligned with the `/persons/list` landing behavior for authenticated users. - When changing spreadsheet parsing, validate against `test/assets/2016_timesheet_Luca-Pacioli.xlsx` and the expectations in `test/agiladmin/timesheet_test.clj`. - When changing config handling, verify both global config loading and per-project YAML loading. +- When changing auth behavior, check both [src/agiladmin/view_auth.clj](/home/jrml/devel/agiladmin/src/agiladmin/view_auth.clj) and the adapter under [src/agiladmin/auth/](/home/jrml/devel/agiladmin/src/agiladmin/auth/). Pocket ID is OIDC redirect-based; PocketBase remains password-based. - Be conservative around `view_timesheet/commit`; it mutates the budgets repo and pushes over SSH. - Avoid “cleanup” changes that rename columns, normalize casing differently, or alter dataset shapes unless you also update all dependent views/tests. - Frontend styling uses TailwindCSS + DaisyUI with the `nord` theme; shared layout helpers live in `src/agiladmin/webpage.clj`. diff --git a/README.md b/README.md index f9f0ecf..5cd38f5 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ repository, computes hours and costs, and renders HTML reports for personnel and projects. The current codebase targets `org.clojure/clojure` `1.12.4` and starts -with the Clojure CLI. Authentication is backend-driven: PocketBase is -supported for real deployments, and a development-only fallback -backend is available for local manual testing. +with the Clojure CLI. Authentication is backend-driven: PocketBase and +Pocket ID are supported for real deployments, and a development-only +fallback backend is available for local manual testing. ## Current State @@ -17,7 +17,7 @@ backend is available for local manual testing. - Data processing: core.matrix, Docjure / Apache POI, YAML files - Storage model: - project metadata and uploaded spreadsheets live in a Git-managed budgets directory - - authentication is handled through a backend abstraction, with PocketBase currently implemented + - authentication is handled through a backend abstraction, with PocketBase and Pocket ID adapters - Build: Clojure CLI with `deps.edn` - Tests: Midje @@ -32,7 +32,7 @@ The app is well tested and fairly stateful. Startup performs real side effects: - Java (JRE) and the Clojure CLI - a writable budgets Git checkout or clone target in `budgets/` - a valid `agiladmin.yaml` configuration file, or an explicit config path via `AGILADMIN_CONF` -- for real auth flows: a reachable PocketBase instance +- for real auth flows: either a reachable PocketBase instance or a reachable Pocket ID issuer ## Running @@ -78,7 +78,7 @@ Or directly: AGILADMIN_CONF=doc/agiladmin.pocketbase.yaml clj -M:run ``` -PocketBase is optional in config, but without either PocketBase or `AGILADMIN_DEV_AUTH=1`, authentication is not initialized and login will not work. +PocketBase is optional in config, but without either an auth backend or `AGILADMIN_DEV_AUTH=1`, authentication is not initialized and login will not work. Agiladmin expects the PocketBase `users` auth collection to have a `role` select field. Supported values are `admin`, `manager`, or empty. @@ -99,7 +99,37 @@ AGILADMIN_CONF=doc/agiladmin.pocketbase.yaml clj -M -m agiladmin.pocketbase-init If `agiladmin.pocketbase.manage-process` is `true`, Agiladmin starts PocketBase itself, serves it with the configured migrations directory, waits for health, applies the role bootstrap when the installed Agiladmin version changes, and stops PocketBase again on exit. -PocketBase HTTP calls use bounded timeouts by default so startup does not hang forever if the service is unreachable. Override them with `agiladmin.pocketbase.connect-timeout-ms` and `agiladmin.pocketbase.socket-timeout-ms` if needed. +PocketBase HTTP calls use bounded timeouts by default so startup does not hang forever if the service is unreachable. Override them with `agiladmin.auth.pocketbase.connect-timeout-ms` and `agiladmin.auth.pocketbase.socket-timeout-ms` if needed. + +### Running With Pocket ID + +The repository also ships a Pocket ID example config at [doc/agiladmin.pocket-id.yaml](/home/jrml/devel/agiladmin/doc/agiladmin.pocket-id.yaml). + +Run with it using: + +```sh +AGILADMIN_CONF=doc/agiladmin.pocket-id.yaml clj -M:run +``` + +Create an OIDC client in Pocket ID with this redirect URI: + +```text +https:///auth/pocket-id/callback +``` + +Recommended scopes are: + +```text +openid profile email groups +``` + +Agiladmin checked Pocket ID documentation on 2026-03-12 and uses the standard OIDC authorization code flow with PKCE, a shared client secret, and role mapping from groups. Configure two Pocket ID groups and map them into Agiladmin with `admin-group` and `manager-group`. If a user belongs to both groups, `admin` wins. + +Pocket ID login is redirect-based. The Agiladmin login page shows a single “Sign in with Pocket ID” action and no local password form when this backend is active. + +Logout is local-only for now: Agiladmin clears its Ring session and redirects back to `/login`. The Pocket ID session may still be active in the browser. + +Pocket ID does not power Agiladmin signup, activation, or pending-user admin flows. Those actions stay in Pocket ID. ## Testing @@ -153,6 +183,12 @@ Or with an explicit config file: AGILADMIN_CONF=doc/agiladmin.pocketbase.yaml java -jar target/-standalone.jar ``` +Or: + +```sh +AGILADMIN_CONF=doc/agiladmin.pocket-id.yaml java -jar target/-standalone.jar +``` + The jar uses the same config lookup as `clj -M:run`: by default it looks for `agiladmin.yaml` in the standard locations, including the current working directory. ## Configuration @@ -182,18 +218,28 @@ agiladmin: git: https://github.com/dyne/agiladmin update: false - pocketbase: - base-url: http://127.0.0.1:8090 - users-collection: users - superuser-email: admin@example.org - superuser-password: change-me + auth: + backend: pocket-id + pocket-id: + issuer-url: https://pocket-id.example.org + client-id: agiladmin + client-secret: change-me + redirect-uri: https://agiladmin.example.org/auth/pocket-id/callback + admin-group: agiladmin-admin + manager-group: agiladmin-manager + scopes: + - openid + - profile + - email + - groups ``` Notes: - `budgets.ssh-key` is the private key path used for Git access; if it does not exist, Agiladmin generates a new keypair and exposes the public key in the `/config` page - project names are discovered from `*.yaml` files in `budgets.path`, using the part of the filename before the first `.` -- `pocketbase` is optional only if you are using dev auth locally +- `agiladmin.auth.backend` may be `pocketbase`, `pocket-id`, or `dev` +- legacy top-level `agiladmin.pocketbase` config is still normalized into `agiladmin.auth.pocketbase` ## Project Configuration @@ -247,6 +293,7 @@ Notes: - [src/agiladmin/core.clj](/home/jrml/devel/planb-agiladmin/src/agiladmin/core.clj): spreadsheet and project logic - [src/agiladmin/view_timesheet.clj](/home/jrml/devel/planb-agiladmin/src/agiladmin/view_timesheet.clj): upload and Git commit flow - [src/agiladmin/auth/pocketbase.clj](/home/jrml/devel/planb-agiladmin/src/agiladmin/auth/pocketbase.clj): PocketBase auth backend +- [src/agiladmin/auth/pocket_id.clj](/home/jrml/devel/agiladmin/src/agiladmin/auth/pocket_id.clj): Pocket ID OIDC auth backend - [pb_migrations/](/home/jrml/devel/agiladmin/pb_migrations): PocketBase schema migrations kept for future schema changes - [test/agiladmin/](/home/jrml/devel/planb-agiladmin/test/agiladmin): Midje test suite @@ -255,6 +302,7 @@ Notes: - Timesheet upload and commit logic writes temporary files under `/tmp/...` - The budgets repository is mutable application state; timesheet submission performs Git operations - PocketBase-backed role-aware access depends on a `role` select field on the auth users collection +- Pocket ID-backed role-aware access depends on the configured Pocket ID groups being present in ID token claims or `userinfo` - Managed PocketBase mode uses a local version marker file to record that the current Agiladmin version has applied its bootstrap step - The app serves a bundled static HTML README on `/`, so updating this file does not automatically change the in-app landing page diff --git a/doc/agiladmin.pocket-id.yaml b/doc/agiladmin.pocket-id.yaml new file mode 100644 index 0000000..94b33c6 --- /dev/null +++ b/doc/agiladmin.pocket-id.yaml @@ -0,0 +1,35 @@ +appname: agiladmin + +agiladmin: + webserver: + anti-forgery: false + ssl-redirect: false + port: 8000 + host: localhost + + budgets: + git: ssh://git@example.org/admin-budgets + ssh-key: id_rsa + path: budgets/ + + source: + git: https://github.com/dyne/agiladmin + update: false + + auth: + backend: pocket-id + pocket-id: + issuer-url: https://pocket-id.example.org + client-id: agiladmin + client-secret: change-me + redirect-uri: https://agiladmin.example.org/auth/pocket-id/callback + post-logout-redirect-uri: https://agiladmin.example.org/login + admin-group: agiladmin-admin + manager-group: agiladmin-manager + scopes: + - openid + - profile + - email + - groups + connect-timeout-ms: 2000 + socket-timeout-ms: 2000 diff --git a/doc/agiladmin.pocketbase.yaml b/doc/agiladmin.pocketbase.yaml index 6af44f9..40ab7bd 100644 --- a/doc/agiladmin.pocketbase.yaml +++ b/doc/agiladmin.pocketbase.yaml @@ -16,15 +16,17 @@ agiladmin: git: https://github.com/dyne/agiladmin update: false - pocketbase: - base-url: http://127.0.0.1:8090 - users-collection: users - superuser-email: admin@example.org - superuser-password: change-me - connect-timeout-ms: 2000 - socket-timeout-ms: 2000 - manage-process: false - binary: pocketbase - dir: /var/lib/agiladmin/pocketbase - migrations-dir: /usr/local/agiladmin/pocketbase/migrations - version-file: /var/lib/agiladmin/pocketbase/.agiladmin-version + auth: + backend: pocketbase + pocketbase: + base-url: http://127.0.0.1:8090 + users-collection: users + superuser-email: admin@example.org + superuser-password: change-me + connect-timeout-ms: 2000 + socket-timeout-ms: 2000 + manage-process: false + binary: pocketbase + dir: /var/lib/agiladmin/pocketbase + migrations-dir: /usr/local/agiladmin/pocketbase/migrations + version-file: /var/lib/agiladmin/pocketbase/.agiladmin-version diff --git a/doc/pocket-id-migration.md b/doc/pocket-id-migration.md new file mode 100644 index 0000000..c96b1c0 --- /dev/null +++ b/doc/pocket-id-migration.md @@ -0,0 +1,43 @@ +# Pocket ID Migration Notes + +This is the shortest safe path from PocketBase auth to Pocket ID auth. + +## What does not migrate automatically + +- users +- passwords +- passkeys +- PocketBase `role` field values +- verification or pending-user state + +Pocket ID becomes the identity source of truth. Agiladmin only keeps the logged-in user in the Ring session. + +## Role migration + +- Create one Pocket ID group for Agiladmin admins. +- Create one Pocket ID group for Agiladmin managers. +- Map them into `agiladmin.auth.pocket-id.admin-group` and `agiladmin.auth.pocket-id.manager-group`. +- Users without either group authenticate successfully but keep a nil Agiladmin role. + +## Onboarding change + +- PocketBase mode: Agiladmin can show local signup and activation flows. +- Pocket ID mode: onboarding happens in Pocket ID, including passkey enrollment. + +## Cutover + +1. Create the Pocket ID OIDC client. +2. Set the callback URI to `/auth/pocket-id/callback`. +3. Create and assign the Agiladmin groups in Pocket ID. +4. Switch `agiladmin.auth.backend` to `pocket-id`. +5. Restart Agiladmin. + +Existing Agiladmin sessions should be treated as stale during cutover. Users should log in again. + +## Rollback + +1. Restore the PocketBase config block. +2. Switch `agiladmin.auth.backend` back to `pocketbase`. +3. Restart Agiladmin. + +No Agiladmin data migration is required for rollback because auth state is external to the app. diff --git a/src/agiladmin/pocketbase_init.clj b/src/agiladmin/pocketbase_init.clj index 98ea821..62e7873 100644 --- a/src/agiladmin/pocketbase_init.clj +++ b/src/agiladmin/pocketbase_init.clj @@ -10,9 +10,10 @@ (when (f/failed? config) (throw (ex-info (f/message config) {:type ::config-load-failed}))) - (if-let [pocketbase-conf (get-in config [:agiladmin :pocketbase])] + (if-let [pocketbase-conf (or (get-in config [:agiladmin :auth :pocketbase]) + (get-in config [:agiladmin :pocketbase]))] (do (pocketbase/ensure-role-field! pocketbase-conf) (println "PocketBase users collection initialized with role select.")) - (throw (ex-info "Missing :agiladmin :pocketbase configuration." + (throw (ex-info "Missing :agiladmin :auth :pocketbase configuration." {:type ::missing-pocketbase-config}))))) diff --git a/test/agiladmin/config_test.clj b/test/agiladmin/config_test.clj index ad0240f..f8ab0a0 100644 --- a/test/agiladmin/config_test.clj +++ b/test/agiladmin/config_test.clj @@ -149,7 +149,7 @@ (:paths conf) => ["doc/agiladmin.pocketbase.yaml"] (get-in conf [:agiladmin :auth :backend]) => "pocketbase" (get-in conf [:agiladmin :auth :pocketbase :base-url]) => "http://127.0.0.1:8090" - (get-in conf [:agiladmin :pocketbase :base-url]) => "http://127.0.0.1:8090")) + (get-in conf [:agiladmin :pocketbase :base-url]) => nil)) (fact "Application config loader accepts the nested auth PocketBase config" (let [path "/tmp/agiladmin-auth-pocketbase.yaml" From 55f7ebc313fdc2ff057fd53c2d3f3295298d81dc Mon Sep 17 00:00:00 2001 From: Jaromil Date: Thu, 12 Mar 2026 08:58:19 +0100 Subject: [PATCH 11/12] docs: template conf for pocket-id --- .gitignore | 3 +++ packaging/caddyfile.example | 17 +++++++++++++++++ packaging/pocketid.env | 12 ++++++++++++ packaging/systemd/pocketid.service | 20 ++++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 packaging/caddyfile.example create mode 100644 packaging/pocketid.env create mode 100644 packaging/systemd/pocketid.service diff --git a/.gitignore b/.gitignore index 326326e..6d1233d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ node_modules/ .envrc __pycache__ *.xlsx +timesheetpy/*.png +timesheetpy/*.jpg + diff --git a/packaging/caddyfile.example b/packaging/caddyfile.example new file mode 100644 index 0000000..5712eb3 --- /dev/null +++ b/packaging/caddyfile.example @@ -0,0 +1,17 @@ +# Snippet for robots.txt +(common_robots_txt) { + handle /robots.txt { + # Set the Content-Type header + header Content-Type "text/plain; charset=utf-8" + # Respond with the body and status code 200 + respond `User-agent: * + Disallow: /` 200 + } +} + +# Pocket-ID +id.xxxx.xx { + import common_robots_txt + # Fallback to reverse proxy for other requests + reverse_proxy 192.168.x.yyy:1411 [xxxx:xxxx:xxxx:xxxx::yyyy]:1411 +} diff --git a/packaging/pocketid.env b/packaging/pocketid.env new file mode 100644 index 0000000..eecdb1e --- /dev/null +++ b/packaging/pocketid.env @@ -0,0 +1,12 @@ +APP_URL=https://id.xxxx.xx +PORT=1411 + +# Database: SQLite, file located at /opt/pocket-id/data/db.sqlite +# (relative to WorkingDirectory=/opt/pocket-id) +DB_CONNECTION_STRING=file:data/db.sqlite?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate + +# Optional: Maxmind License Key for IP Geolocation +MAXMIND_LICENSE_KEY="YOUR-MAXMIND-LICENSE-KEY" + +# Optional: Logging level (debug, info, warn, error) +LOG_LEVEL=info diff --git a/packaging/systemd/pocketid.service b/packaging/systemd/pocketid.service new file mode 100644 index 0000000..82b16c2 --- /dev/null +++ b/packaging/systemd/pocketid.service @@ -0,0 +1,20 @@ +[Unit] +Description=Pocket ID Application Server +After=network.target + +[Service] +Type=simple +User=pocketid +Group=pocketid +WorkingDirectory=/opt/pocket-id +ExecStart=/opt/pocket-id/pocket-id +EnvironmentFile=/opt/pocket-id/.env + +Restart=always +RestartSec=10 + +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target From 6a5d19f95144d218783e07961f4619a6302b7d8e Mon Sep 17 00:00:00 2001 From: Jaromil Date: Sun, 15 Mar 2026 09:35:10 +0100 Subject: [PATCH 12/12] fix: pocketid os integration --- packaging/pocketid.env | 4 +-- packaging/systemd/pocketid.service | 6 ++-- packaging/update-pocketid.sh | 53 ++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 packaging/update-pocketid.sh diff --git a/packaging/pocketid.env b/packaging/pocketid.env index eecdb1e..8040468 100644 --- a/packaging/pocketid.env +++ b/packaging/pocketid.env @@ -1,8 +1,8 @@ APP_URL=https://id.xxxx.xx PORT=1411 -# Database: SQLite, file located at /opt/pocket-id/data/db.sqlite -# (relative to WorkingDirectory=/opt/pocket-id) +# Database: SQLite, file located at /home/pocketid/data/db.sqlite +# (relative to WorkingDirectory=/home/pocketid) DB_CONNECTION_STRING=file:data/db.sqlite?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate # Optional: Maxmind License Key for IP Geolocation diff --git a/packaging/systemd/pocketid.service b/packaging/systemd/pocketid.service index 82b16c2..ed4290f 100644 --- a/packaging/systemd/pocketid.service +++ b/packaging/systemd/pocketid.service @@ -6,9 +6,9 @@ After=network.target Type=simple User=pocketid Group=pocketid -WorkingDirectory=/opt/pocket-id -ExecStart=/opt/pocket-id/pocket-id -EnvironmentFile=/opt/pocket-id/.env +WorkingDirectory=/home/pocketid +ExecStart=/usr/local/bin/pocket-id +EnvironmentFile=/home/pocketid/.env Restart=always RestartSec=10 diff --git a/packaging/update-pocketid.sh b/packaging/update-pocketid.sh new file mode 100644 index 0000000..954af64 --- /dev/null +++ b/packaging/update-pocketid.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# update-pocketid.sh + +set -e + +# --- Configuration --- +INSTALL_DIR="/home/pocketid" +SERVICE_NAME="pocketid.service" +USER="pocketid" +GROUP="pocketid" +VERSION_FILE="${INSTALL_DIR}/version.txt" +ARCHITECTURE="amd64" # Change if needed (e.g., arm64) +# --- End Configuration --- + +>&2 echo "Checking for the latest version of PocketID..." +LATEST_TAG_JSON=$(curl -s https://api.github.com/repos/pocket-id/pocket-id/releases/latest) +LATEST_TAG=$(echo "$LATEST_TAG_JSON" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') # Version without 'v' +LATEST_TAG_WITH_V=$(echo "$LATEST_TAG_JSON" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') # Version with 'v' + +if [ -z "$LATEST_TAG" ]; then + >&2 echo "Could not retrieve the latest version from GitHub." + exit 1 +fi + +>&2 echo "Latest version available: v${LATEST_TAG}" + +CURRENT_VERSION="0" # Default to 0 if no version file +if [ -f "$VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") +fi +>&2 echo "Currently installed version: v${CURRENT_VERSION}" + +if [ "$LATEST_TAG" = "$CURRENT_VERSION" ]; then + >&2 echo "PocketID is already up to date (v${CURRENT_VERSION})." + exit 0 +fi + +>&2 echo "New version v${LATEST_TAG} available. Updating..." + +DOWNLOAD_URL=$(echo "$LATEST_TAG_JSON" | grep -E "browser_download_url.*pocket-id-linux-${ARCHITECTURE}" | cut -d '"' -f 4) + +if [ -z "$DOWNLOAD_URL" ]; then + >&2 echo "Could not find the download URL for linux-${ARCHITECTURE} and version v${LATEST_TAG}." + exit 1 +fi + +>&2 echo "Stopping service ${SERVICE_NAME}..." +sudo systemctl stop "${SERVICE_NAME}" + +>&2 echo "Backing up the old binary..." +BACKUP_NAME="pocket-id_backup_v${CURRENT_VERSION}_$(date +%Y%m%d_%H%M%S)" +sudo cp "${INSTALL_DIR}/pocket-id" "${INSTALL_DIR}/${BACKUP_NAME}" +>&2 echo "Old binary backed up to ${INSTALL_DIR}/${BACKUP_NAME}"