Skip to content

feat(auth): sign agents in from guard's identity (COR-1084) - #8

Open
malpou wants to merge 1 commit into
mainfrom
malthe/cor-1084-libredesk-fork-login-through-guard-identity
Open

malpou wants to merge 1 commit into
mainfrom
malthe/cor-1084-libredesk-fork-login-through-guard-identity

Conversation

@malpou

@malpou malpou commented Oct 30, 2026

Copy link
Copy Markdown
Owner

What and why

Linear COR-1084 (project "Central auth in guard"). With this change, the fork can trust guard's signed identity instead of running its own SSO. Guard runs the Zitadel login for support.metsa.app (client libredesk, roles admin|agent) and passes every signed-in request upstream as a 60 s ES256 JWT in x-bastion-identity. Libredesk checks that JWT with platform-kit edgeauth (Verifier.Verify on the header value, because Libredesk runs on fasthttp and the net/http middleware doesn't apply). It then signs the agent in by email, the same way the OIDC callback does.

The feature is off by default: [auth] guard_identity = true / LIBREDESK_AUTH__GUARD_IDENTITY=true turns it on, together with PLATFORM_KIT_EDGEAUTH_AUDIENCES (required) and PLATFORM_KIT_EDGEAUTH_JWKS_URL (optional, defaults to guard's in-cluster JWKS).

Behaviour

Order in authenticateUser (auth, tryAuth, perm, authOrSignedURL) and in validatePageSession (authPage, notAuthPage):

  1. API key first, unchanged. libredesk-bridge calls the in-cluster Service with its key and never goes through guard. A test shows the key still wins even when a valid or invalid identity is also on the request.
  2. CSRF is still checked on writes. I kept the check rather than skipping it for identity requests. The SPA already gets csrf_token from serveIndexPage without any session login, so nothing changes for the frontend, and we don't have to rely on SameSite reasoning.
  3. Guard identity, when the header is present:
    • invalid token → 401; guard's keys unreachable → 503. It never falls back to anonymous.
    • the identity must hold admin or agent, the libredesk project's roles. This repeats guard's own check so the app also fails closed. Missing role → 403 / oidc_access_denied.
    • the email is lowercased and looked up with GetAgent(0, email); after the first request the agent cache by id is used, re-checked against the email.
    • No auto-provisioning, as with OIDC today. An unknown or empty email gets oidc_no_account. Accounts are still created by hand and the agent's Libredesk role is still what grants permissions; a Zitadel grant only lets someone through the door.
    • disabled or non-agent users are refused (oidc_account_disabled / oidc_no_account).
    • no Libredesk session is created. Last login, the agent-cache invalidation and the agent_login activity entry happen once per guard session (sid), tracked in a small bounded in-process set (168 h, at most 10k entries). A restart records each session once more.
  4. No header → the session path, unchanged. Password login stays as break-glass for the System user.

Pages: a refused identity sends the user to /?error=<code>&next=.... The login page shows that error instead of redirecting to itself, and a signed-in agent on / goes to the inbox. /api/v1/config now returns auth.guard_identity. When it is true, the login page shows a single "Sign in" button that goes to /.well-known/bastion/login?return_to=<next>. After a refused sign-in it also shows a "Logout" button to /.well-known/bastion/logout, so the user can switch account. Logout still calls /api/v1/auth/logout (which records the activity) and then goes to guard's logout. No new i18n keys.

Removed / kept

The upstream OIDC client code (handlers, provider admin, oidc.client_* config) is kept. Removing it would make the fork patch large and painful to rebase onto each upstream tag. After cutover the DB provider row gets disabled instead (deploy notes). The patch is one new file (cmd/guard_identity.go), about 30 marked (// metsa) lines in cmd/middlewares.go, one line each in main.go / config.go, a sample-config key, and a small frontend change.

go.mod: adds platform-kit; MVS lifts nats-server 2.12.8→2.15.1, nats.go 1.53.1→1.54.0, go-jose 4.1.4→4.1.7 and a few indirect modules.

ENG-1067 (NATS KV): what could shrink

Identity-authenticated requests never create a Libredesk session, so LIBREDESK_SESSIONS would only hold break-glass password sessions and OIDC state, which goes away once the provider row is disabled. The bucket could be made smaller, but the fork still binds it at boot and password login uses it, so I did not remove it. The other buckets (widget sessions, help-center cache, rate limits, OTPs, OAuth state for inbox mail) have nothing to do with agent login and stay.

Upstream

A proposal issue for a trusted-proxy identity mode (CONTRIBUTING asks for "propose first") is drafted for the maintainer to post. I did not post it. If upstream accepts it, this patch can shrink to configuration.

Tests

  • go test -count=1 ./... passes against a local Postgres 16 (testutil, port 5433). New cmd/guard_identity_test.go uses edgeauthtest and covers: a known agent (email case-insensitive, no session cookie, auth_method=guard_identity), the login recorded once per sid, CSRF still enforced, unknown email, empty email, disabled agent, missing role, a garbage token, another host's audience and another issuer's key (401), keys unavailable (503), the page redirect plus the error page being served without a loop, / → inbox, and an API key still authenticating ahead of any identity.
  • pnpm test:run: 66 files, 1100 tests pass. New utils/guard-auth.test.js and a guard case in useLogout.test.js. pnpm build:main succeeds.
  • Not run here: Cypress e2e (no browser binary) and a live run against guard.

Pins platform-kit at the COR-1076 branch commit; bump to v0.3.0 once MetsaApp/platform-kit#6 is released, before merging.

Depends on: guard COR-1074 (login, sessions), the deploy-repo guard config for this host.

Cutover

  1. Ship the image with the flag off.
  2. In one rollout: add the Zitadel redirect URI https://support.metsa.app/.well-known/bastion/callback, move secretRef to guard-production/libredesk-oidc, drop LIBREDESK_OIDC__CLIENT_ID/_SECRET, set LIBREDESK_AUTH__GUARD_IDENTITY=true and PLATFORM_KIT_EDGEAUTH_AUDIENCES=support.metsa.app, and add the guard client and rules. The public widget, help-center, CSAT and webhook paths are auth: none; media and assets are optional; everything else is required admin|agent.
  3. Everyone signs in once through guard.
  4. Then disable the SSO provider row (id 1) in Admin → Security → SSO and drop the old /api/v1/oidc/1/finish redirect URI and the auth.metsa.app egress.

Generated by Claude Code

With auth.guard_identity on (LIBREDESK_AUTH__GUARD_IDENTITY), the API and
page middlewares verify guard's x-bastion-identity JWT with platform-kit
edgeauth (PLATFORM_KIT_EDGEAUTH_AUDIENCES / _JWKS_URL) and sign the agent
in by lower-cased email, as the OIDC callback does: no auto-provisioning
(unknown email gets the no-account login error), disabled and non-agent
users refused, the identity must hold the libredesk project's admin or
agent role, and the agent's Libredesk role stays its permissions. No
Libredesk session is minted; CSRF stays checked. API keys are still
checked first, and without the header the session path is unchanged.
An invalid token is 401, unreachable keys 503. The login is recorded
(last login, activity log) once per guard session.

The login page signs in through /.well-known/bastion/login and logout
ends guard's session at /.well-known/bastion/logout when the server
reports auth.guard_identity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MwB9uUtkK8BFT7ZGoXwX9u
@github-actions

Copy link
Copy Markdown

Thanks for opening your first pull request in Libredesk.

Please give CONTRIBUTING.md a read if you haven't already, it covers the setup, code style and the checks this PR needs to pass. A maintainer will review this soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants