feat(auth): add Authentik JWT authentication middleware #521

Merged
fuzzy merged 2 commits from feat/auth into main 2026-08-04 12:54:30 +00:00
Owner

What

Add Authentik JWT authentication middleware and owner-based authorization to the daemon's API.

  • JWT validation (#116) — new internal/auth package: RS256 signature verification against Authentik's JWKS (fetched and cached via keyfunc), optional iss/aud claim checks, exp required. Middleware.Require() rejects requests without a valid Authorization: Bearer token with 401.
  • Authorization (#117) — the site endpoints are scoped by user and owner: non-admin users may register for and view sites owned by their preferred_username only; members of authentik.admin_group may act on any owner. Cross-owner access returns 403.
  • Configuration — new authentik.{issuer,audience,jwks_url,admin_group} section, injected as THWAP_PAGESD_AUTHENTIK_* env vars and added to the Helm chart values. Auth is disabled (endpoints stay open) when jwks_url is unset, preserving existing behavior.
  • Health, metrics, and webhook endpoints remain public.
  • New dependencies vendored per THWAP policy: golang-jwt/jwt/v5, MicahParks/keyfunc/v3.
  • README updated with the config reference, an API authentication section, and 401/403 response codes.

Why

Phase 9 task #118 — production readiness: the registration API is now secured against Authentik-issued tokens.

Testing

  • go build ./...
  • go vet ./...
  • go test -race -count=1 ./... — full suite passes (new: auth unit tests + handler-level auth tests)
  • gofmt -l ./cmd ./internal ./pkg clean
  • golangci-lint run — 0 issues
  • helm lint + helm template render authentik env keys
  • pre-commit hooks all pass

Breaking Changes

None when auth is unconfigured. When authentik.jwks_url is set, the site endpoints require a valid bearer token.

Notes

  • Issues #116, #117, #118 closed via the Forgejo API as part of this task per the workflow.

Closes #116
Closes #117
Closes #118

## What Add Authentik JWT authentication middleware and owner-based authorization to the daemon's API. - **JWT validation (#116)** — new `internal/auth` package: RS256 signature verification against Authentik's JWKS (fetched and cached via keyfunc), optional `iss`/`aud` claim checks, `exp` required. `Middleware.Require()` rejects requests without a valid `Authorization: Bearer` token with 401. - **Authorization (#117)** — the site endpoints are scoped by user and owner: non-admin users may register for and view sites owned by their `preferred_username` only; members of `authentik.admin_group` may act on any owner. Cross-owner access returns 403. - **Configuration** — new `authentik.{issuer,audience,jwks_url,admin_group}` section, injected as `THWAP_PAGESD_AUTHENTIK_*` env vars and added to the Helm chart values. Auth is disabled (endpoints stay open) when `jwks_url` is unset, preserving existing behavior. - Health, metrics, and webhook endpoints remain public. - New dependencies vendored per THWAP policy: `golang-jwt/jwt/v5`, `MicahParks/keyfunc/v3`. - README updated with the config reference, an API authentication section, and 401/403 response codes. ## Why Phase 9 task #118 — production readiness: the registration API is now secured against Authentik-issued tokens. ## Testing - [x] `go build ./...` - [x] `go vet ./...` - [x] `go test -race -count=1 ./...` — full suite passes (new: auth unit tests + handler-level auth tests) - [x] `gofmt -l ./cmd ./internal ./pkg` clean - [x] `golangci-lint run` — 0 issues - [x] `helm lint` + `helm template` render authentik env keys - [x] pre-commit hooks all pass ## Breaking Changes None when auth is unconfigured. When `authentik.jwks_url` is set, the site endpoints require a valid bearer token. ## Notes - Issues #116, #117, #118 closed via the Forgejo API as part of this task per the workflow. Closes #116 Closes #117 Closes #118
feat(auth): add Authentik JWT authentication middleware
Some checks failed
Test and Release / lint (pull_request) Failing after 11m51s
Test and Release / test (pull_request) Successful in 17m28s
aa522e1682
Add an internal/auth package that validates bearer tokens signed by
Authentik's OIDC provider against its JSON Web Key Set, and wire it into
the HTTP router.

- JWT validation (#116): RS256 signature verification via a cached JWKS
  (keyfunc), with optional iss and aud claim checks, exp required;
  Middleware.Require() rejects requests without a valid Authorization:
  Bearer token
- Authorization (#117): site endpoints are scoped by user and owner —
  non-admin users may register for and view sites owned by their
  preferred_username; members of authentik.admin_group act on any owner
- Configuration: new authentik.{issuer,audience,jwks_url,admin_group}
  section, injected as THWAP_PAGESD_AUTHENTIK_* env vars; auth is
  disabled (endpoints remain open) when jwks_url is unset, preserving
  existing behavior
- Health, metrics, and webhook endpoints stay public
- Update the Helm chart values/README and the root README API reference

Tests cover valid/invalid signatures, wrong issuer/audience, expired
tokens, bearer parsing, and handler-level owner/admin authorization.

closes #116
closes #117
closes #118
the.auditor requested changes 2026-08-04 11:35:41 +00:00
Dismissed
the.auditor left a comment

Blocking Issues

  1. cmd/thwap-pagesd/main.go:144 — auth is enabled when JWKSURL != "" || Issuer != "". If an operator sets only authentik.issuer (documented in the README as an optional "empty = skip check" value, with jwks_url being the enable switch), keyfunc.NewDefaultCtx(ctx, []string{""}) fails at startup with failed to parse given URL "": parse "": empty url, so the daemon refuses to boot. This contradicts the README's "Auth is disabled (endpoints stay open) when jwks_url is unset". Gate the middleware on JWKSURL != "" only; issuer/audience/admin_group are meaningless without a JWKS source anyway. Verified with a small standalone test against the vendored keyfunc.

Suggestions

  1. internal/auth/auth.go:88jwt.WithLeeway(0) rejects valid tokens near expiry under clock skew. Consider a small leeway (e.g. 30s). Filed as #523.
  2. internal/auth/auth.go:64 — failed auth (missing/invalid token) returns 401 with no log line, so rejected attempts are invisible. Consider logging via the centralized logger. Filed as #522.
  3. internal/httpapi/sites.go:109deployments resolves the owner via a Status() call, then calls Deployments() again: two downstream calls per request plus a small TOCTOU window. Filed as #525.
  4. cmd/thwap-pagesd/main.go — the auth middleware's Close() is never called; the keyfunc JWKS refresher goroutine leaks for the process lifetime. Filed as #524 (ties into graceful shutdown #120).

Praise

  • Solid test coverage: real RSA JWKS served over HTTP, table-driven bearerToken cases, and handler-level tests exercising 401/403/200 for owner, cross-owner, and admin paths.
  • Fail-closed authorization: missing/empty preferred_username maps to 403 for non-admins, and WithValidMethods(RS256) plus WithExpirationRequired tighten the default parser appropriately.
  • Helm chart wiring works because configmap.yaml renders the config section generically, so the new authentik block becomes THWAP_PAGESD_AUTHENTIK_* env vars without template changes; the config env test confirms the mapping.
## Blocking Issues 1. `cmd/thwap-pagesd/main.go:144` — auth is enabled when `JWKSURL != "" || Issuer != ""`. If an operator sets only `authentik.issuer` (documented in the README as an optional "empty = skip check" value, with `jwks_url` being the enable switch), `keyfunc.NewDefaultCtx(ctx, []string{""})` fails at startup with `failed to parse given URL "": parse "": empty url`, so the daemon refuses to boot. This contradicts the README's "Auth is disabled (endpoints stay open) when `jwks_url` is unset". Gate the middleware on `JWKSURL != ""` only; `issuer`/`audience`/`admin_group` are meaningless without a JWKS source anyway. Verified with a small standalone test against the vendored keyfunc. ## Suggestions 1. `internal/auth/auth.go:88` — `jwt.WithLeeway(0)` rejects valid tokens near expiry under clock skew. Consider a small leeway (e.g. 30s). Filed as #523. 2. `internal/auth/auth.go:64` — failed auth (missing/invalid token) returns 401 with no log line, so rejected attempts are invisible. Consider logging via the centralized logger. Filed as #522. 3. `internal/httpapi/sites.go:109` — `deployments` resolves the owner via a `Status()` call, then calls `Deployments()` again: two downstream calls per request plus a small TOCTOU window. Filed as #525. 4. `cmd/thwap-pagesd/main.go` — the auth middleware's `Close()` is never called; the keyfunc JWKS refresher goroutine leaks for the process lifetime. Filed as #524 (ties into graceful shutdown #120). ## Praise - Solid test coverage: real RSA JWKS served over HTTP, table-driven `bearerToken` cases, and handler-level tests exercising 401/403/200 for owner, cross-owner, and admin paths. - Fail-closed authorization: missing/empty `preferred_username` maps to 403 for non-admins, and `WithValidMethods(RS256)` plus `WithExpirationRequired` tighten the default parser appropriately. - Helm chart wiring works because `configmap.yaml` renders the `config` section generically, so the new `authentik` block becomes `THWAP_PAGESD_AUTHENTIK_*` env vars without template changes; the config env test confirms the mapping.
fix(auth): gate middleware on JWKS URL only
All checks were successful
Test and Release / lint (pull_request) Successful in 4m27s
Test and Release / test (pull_request) Successful in 23m31s
fb5bbdcc5f
authEnabled() now enables the middleware only when authentik.jwks_url is
set. Previously an operator setting just authentik.issuer (an optional
claim check) would make the daemon fail to boot, because keyfunc was
given an empty JWKS URL to parse. issuer, audience, and admin_group are
meaningless without a JWKS source, so they alone never enable auth.

Adds a regression test covering jwks-only, all-set, issuer-only,
audience-only, and unset configurations.
Author
Owner

Addressed the blocking review item.

Blocking fix: authentication is now gated on authentik.jwks_url alone via a new authEnabled() helper (cmd/thwap-pagesd/main.go). Setting only authentik.issuer (or audience/admin_group) no longer enables the middleware, so an operator can't trigger the empty-URL JWKS boot failure. Added a regression test covering jwks-only, all-set, issuer-only, audience-only, and unset configurations.

Suggestions #522–#525 were filed as issues by the auditor and are tracked there — they are out of scope for this fix (the leeway, logging, and Close() items tie into follow-up work).

Verification: go build ./..., go vet ./..., go test -race -count=1 ./... (all 12 packages pass), gofmt -l clean, golangci-lint run 0 issues, pre-commit hooks pass. Re-requesting review.

Addressed the blocking review item. **Blocking fix:** authentication is now gated on `authentik.jwks_url` alone via a new `authEnabled()` helper (`cmd/thwap-pagesd/main.go`). Setting only `authentik.issuer` (or audience/admin_group) no longer enables the middleware, so an operator can't trigger the empty-URL JWKS boot failure. Added a regression test covering jwks-only, all-set, issuer-only, audience-only, and unset configurations. **Suggestions #522–#525** were filed as issues by the auditor and are tracked there — they are out of scope for this fix (the leeway, logging, and Close() items tie into follow-up work). Verification: `go build ./...`, `go vet ./...`, `go test -race -count=1 ./...` (all 12 packages pass), `gofmt -l` clean, `golangci-lint run` 0 issues, pre-commit hooks pass. Re-requesting review.
the.auditor left a comment

Re-review

Blocking issue resolved:

  • cmd/thwap-pagesd/main.go now gates middleware construction on authEnabled(cfg), which returns cfg.Authentik.JWKSURL != "" only. Setting just issuer/audience/admin_group no longer enables auth and no longer crashes startup with the empty-URL parse error. The rationale is documented in a comment, and TestAuthEnabled covers jwks-only, all-set, issuer-only, audience-only, and unset configs.

Verified locally: go build ./..., go vet ./..., and go test -race -count=1 ./... all pass, including the new cmd/thwap-pagesd tests.

Non-blocking suggestions remain tracked as #522, #523, #524, #525. No new findings from the fix commit.

Approving.

## Re-review Blocking issue resolved: - `cmd/thwap-pagesd/main.go` now gates middleware construction on `authEnabled(cfg)`, which returns `cfg.Authentik.JWKSURL != ""` only. Setting just `issuer`/`audience`/`admin_group` no longer enables auth and no longer crashes startup with the empty-URL parse error. The rationale is documented in a comment, and `TestAuthEnabled` covers jwks-only, all-set, issuer-only, audience-only, and unset configs. Verified locally: `go build ./...`, `go vet ./...`, and `go test -race -count=1 ./...` all pass, including the new `cmd/thwap-pagesd` tests. Non-blocking suggestions remain tracked as #522, #523, #524, #525. No new findings from the fix commit. Approving.
fuzzy merged commit fb5bbdcc5f into main 2026-08-04 12:54:30 +00:00
fuzzy deleted branch feat/auth 2026-08-04 12:54:30 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
thwap/thwap-pagesd!521
No description provided.