Skip to content

feat(auth): Phases 1b + 2 + 3 of #2 — bearer validator (multi-aud per #173), Auth.js v5, my-team-member REST#172

Merged
JohnRDOrazio merged 7 commits into
mainfrom
feat/zitadel-bearer-validator
Jun 5, 2026
Merged

feat(auth): Phases 1b + 2 + 3 of #2 — bearer validator (multi-aud per #173), Auth.js v5, my-team-member REST#172
JohnRDOrazio merged 7 commits into
mainfrom
feat/zitadel-bearer-validator

Conversation

@JohnRDOrazio
Copy link
Copy Markdown
Member

@JohnRDOrazio JohnRDOrazio commented Jun 5, 2026

Summary

Phase 1b of issue #2. Adds a determine_current_user filter that accepts Authorization: Bearer <access_token> headers issued by the umbrella Zitadel at https://auth.catholicdigitalcommons.org, validates them against the userinfo endpoint, and resolves the WP user by the verified-email claim.

Pure infrastructure — no new REST endpoints, no new UI, no behavior change for any existing caller. The bearer validator only fires when a Bearer token is present and no earlier filter (cookie auth, Application Password) has already populated $user_id. Application Passwords stay the canonical auth method for the Python CLI and other server-side callers.

Auth flow

  1. Next.js (via Auth.js v5) holds a Zitadel access token in the server-side session
  2. A user-initiated write — e.g. PATCH /cdcf/v1/my-team-member/{lang} once Phase 3 lands — sends it as Authorization: Bearer <token> to WP
  3. This filter POSTs the token to auth.catholicdigitalcommons.org/oidc/v1/userinfo with a 5s timeout
  4. On 200 + email_verified=true + matching WP user → $user_id set, REST request proceeds under that user's caps
  5. Accepted token mapped to (sha256(token) → user_id) in a 60s transient — raw tokens never enter the WP options table

Fail-closed semantics

Every unhappy path returns $user_id unchanged:

  • No Authorization header, or non-Bearer scheme → no-op (Application Passwords still work)
  • Userinfo non-200 (typical: 401 expired/revoked) → fall through
  • Network error / WP_Error from wp_remote_post → fall through
  • Malformed JSON in userinfo body → fall through
  • email_verified=false or email claim missing → fall through
  • Email claim doesn't match any WP user → fall through (no auto-provisioning; admin creates the WP user out-of-band)

Files

File Purpose
wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php (new) The filter, three constants (CDCF_ZITADEL_USERINFO_URL, CDCF_ZITADEL_BEARER_CACHE_TTL=60, CDCF_ZITADEL_USERINFO_TIMEOUT=5), header extraction helper covering HTTP_AUTHORIZATION + REDIRECT_HTTP_AUTHORIZATION + getallheaders() fallback
wordpress/themes/cdcf-headless/tests/ZitadelBearerTest.php (new) 13 tests covering every branch — see Tests section
wordpress/themes/cdcf-headless/functions.php require_once + add_filter(..., 20) registration. Priority 20 keeps this AFTER core's cookie and Application Password resolvers.
wordpress/themes/cdcf-headless/tests/bootstrap.php New include in the PHPUnit bootstrap require-stack
AGENTS.md New "Zitadel bearer authentication" subsection in the cdcf/v1 chapter (mirrored to CLAUDE.md via the symlink)

Why hook registration is in functions.php, not the include itself

Convention every other handler file follows: includes only define functions; add_filter / add_action / register_rest_route calls live in functions.php. Keeps the include test-loadable without stubbing Brain Monkey's WP hook shim in bootstrap.

Tests

13 new PHPUnit tests in ZitadelBearerTest.php:

  • Early-exit: already authenticated by prior filter (no userinfo call); no Authorization header (no userinfo call); Basic scheme (no userinfo call)
  • Cache hit: returns cached user ID, no userinfo call
  • Happy path: 200 + verified email + WP user found → returns user ID, transient set with TTL 60
  • Cache-key non-leakage: token string never appears in the transient key (only its SHA-256 hash)
  • Unhappy paths: email_verified=false falls through; missing email claim falls through; no matching WP user falls through; userinfo 401 falls through; malformed JSON falls through; WP_Error from wp_remote_post falls through
  • Header extraction: token recovered from REDIRECT_HTTP_AUTHORIZATION when HTTP_AUTHORIZATION is empty (Apache+FCGI case)

Full theme suite locally:

$ composer test --working-dir=wordpress/themes/cdcf-headless
OK (406 tests, 950 assertions)

(Was 393 tests before Phase 1b; +13 new = 406.)

Future-proofing not done here

  • Local JWKS-cached signature verification — would save the userinfo round-trip per request. LitCal uses this pattern (per memory: LiturgicalCalendarAPI/src/Services/ZitadelService.php does JWKS validation). For issue frontend/backend login via Zitadel provider #2's hot path (human clicks Save on a bio edit form, not a high-RPS API), the round-trip + 60s cache is fine. Worth revisiting if/when the bearer path serves higher-frequency reads.
  • Configurable issuer URL — hardcoded as a constant to the prod Zitadel. Local dev that wants to point at a different instance would need to override. Acceptable for now since dev points at the same prod Zitadel via dev-mode redirect URIs (umbrella architecture decision).

Dependencies

Deploy

Theme change — needs gh workflow run deploy.yml -f environment=production to ship. A staging-target run skips theme/plugin steps.

Test plan

  • PHPUnit theme job green on CI
  • After production deploy: hit an existing cdcf/v1 endpoint with Authorization: Bearer junk — confirm 401 (token validation fails, no other auth attempted)
  • Hit an existing endpoint with Authorization: Basic <appwd> — confirm Application Password auth still works as before (bearer validator no-ops on non-Bearer schemes)
  • Hit an existing endpoint with no auth header — confirm anonymous-user behavior unchanged

Summary by CodeRabbit

  • New Features

    • Sign-in / sign-out UI in the header with session-aware controls.
    • User-facing “my team member” bio self-editing (per-language edits with background re-translation).
    • Multi-language auth UI strings added (EN, DE, ES, FR, IT, PT).
  • Documentation

    • API docs updated to describe new auth options (Application Password or OIDC bearer tokens), token validation behavior, and new auth-related env variables.

Phase 1b of cdcf-website issue #2 (team-member bio self-edit). Hooks
determine_current_user at priority 20 to accept Authorization: Bearer
<access_token> headers issued by the umbrella Zitadel instance at
https://auth.catholicdigitalcommons.org. Validates each token against
the /oidc/v1/userinfo endpoint and resolves the WP user by the
email_verified claim.

Behaviour:

- Falls through (returns the prior filter's $user_id unchanged) on any
  unhappy path — non-200 userinfo, malformed JSON, unverified email,
  no matching WP user, network error. Application Passwords, cookies,
  and other auth methods keep working untouched.
- Caches accepted (sha256(token) → user_id) pairs in a 60-second
  transient. The cache key is the SHA-256 hash so raw tokens never
  land in the WP options table.
- No auto-provisioning: a Zitadel user whose email doesn't match an
  existing WP user falls through. A Zitadel admin manually creates the
  WP user (already supported via /cdcf/v1/create-user) and the
  author_team_member link (already supported via PR #160's endpoint)
  before the user can log in to WP REST.

The wp_remote_post → /oidc/v1/userinfo round-trip is the trust anchor
for now. A future PR could switch to local JWKS-cached signature
verification (the pattern LitCal uses per ZitadelService.php) to save
the round-trip — out of scope for issue #2's hot path, which is a
human clicking Save on a bio edit form.

13 PHPUnit tests cover: early-exit (already authenticated, no header,
non-Bearer scheme), cache hit, valid token + verified email + existing
WP user, email_verified=false, missing email claim, no WP user match,
userinfo 401, malformed JSON, network WP_Error, sha256 cache-key
non-leakage of raw token, REDIRECT_HTTP_AUTHORIZATION fallback. Full
theme suite still green (406 tests, 950 assertions).

Hook wiring lives in functions.php (alongside the require_once)
rather than in the include itself — that's the convention every other
handler follows, and it keeps the include test-loadable without
needing the Brain Monkey wp-hook shim.

AGENTS.md (and CLAUDE.md via the symlink) documents the new auth
mechanism in a "Zitadel bearer authentication" subsection of the
existing cdcf/v1 endpoints chapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0e5e054c-8388-45cb-a7e2-cda2b19ddcc3

📥 Commits

Reviewing files that changed from the base of the PR and between c502a37 and 2a83216.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • .env.local.example
  • AGENTS.md
  • components/AuthButton.tsx
  • lib/auth.ts
  • package.json
  • wordpress/themes/cdcf-headless/functions.php
  • wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php
  • wordpress/themes/cdcf-headless/includes/handlers/my-team-member.php
  • wordpress/themes/cdcf-headless/tests/MyTeamMemberHandlerTest.php
🚧 Files skipped from review as they are similar to previous changes (6)
  • package.json
  • .env.local.example
  • lib/auth.ts
  • components/AuthButton.tsx
  • wordpress/themes/cdcf-headless/includes/handlers/my-team-member.php
  • wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php

📝 Walkthrough

Walkthrough

Adds Auth.js v5 NextAuth setup with JWT refresh and session helpers, a client AuthButton integrated into the header, WordPress Zitadel bearer-token authenticator with audience allow-list and caching, and GET/PATCH REST endpoints for users to view/edit their team-member bio across Polylang translations, with tests and docs.

Changes

Zitadel OIDC Authentication and Team Member Self-Edit

Layer / File(s) Summary
Auth type extensions and NextAuth configuration
lib/auth.ts, app/api/auth/[...nextauth]/route.ts, package.json
Session and JWT types are augmented with accessToken, refreshToken, expiresAt, roles, locale, and RefreshAccessTokenError. NextAuth instance is configured with Zitadel provider, implements JWT refresh on token expiry, and callbacks to project token fields into the session. Route handler re-exports GET/POST from handlers.
Session access utilities and tests
lib/auth-utils.ts, lib/auth-utils.test.ts
Three helpers—requireSession(), requireRole(role), and getAccessToken(session)—provide server-side session access with type safety. requireSession redirects to signin when unauthenticated; requireRole enforces role membership with 403 errors; getAccessToken handles refresh errors. Vitest suite mocks auth() and redirect to validate all three functions.
Frontend session provider and layout
app/[lang]/layout.tsx
LangLayout fetches i18n messages and NextAuth session in parallel via Promise.all(), then wraps the rendered tree with SessionProvider to expose session context to client components.
AuthButton component and header integration
components/AuthButton.tsx, components/Header.tsx
AuthButton shows loading state, a "Sign in" button (triggering Zitadel OIDC), and an authenticated dropdown with user name/email and sign-out menu. Dropdown interaction is controlled via keyboard/mouse handlers and a delayed-close timer. AuthButton is integrated into Header alongside LanguageSwitcher.
Authentication UI translations
messages/en.json, messages/de.json, messages/es.json, messages/fr.json, messages/it.json, messages/pt.json
Each message file receives a new Auth namespace with signIn, signOut, and loading strings translated to the target language.
WordPress Zitadel bearer token authentication
wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php, wordpress/themes/cdcf-headless/tests/ZitadelBearerTest.php
A determine_current_user filter extracts Authorization: Bearer tokens, decodes the JWT payload (no signature verification), enforces audience allow-list validation via CDCF_ZITADEL_EXPECTED_AUD, and validates tokens against Zitadel's userinfo endpoint requiring email_verified and email presence. Transient cache (60s TTL, keyed by token hash) avoids repeated userinfo calls. All failure paths transparently fall through. PHPUnit test suite covers extraction, audience verification, caching, userinfo validation, and email-to-WP-user mapping.
WordPress team-member self-edit endpoints
wordpress/themes/cdcf-headless/includes/handlers/my-team-member.php, wordpress/themes/cdcf-headless/tests/MyTeamMemberHandlerTest.php, wordpress/themes/cdcf-headless/functions.php
GET /cdcf/v1/my-team-member discovers the authenticated user's linked team_member post and its Polylang translation group. PATCH /cdcf/v1/my-team-member/{lang} edits that language version, updates post_content and ACF fields (member_title, member_linkedin_url, member_github_url), validates URL host allowlists, enforces ownership, and enqueues re-translation jobs for other languages. PHPUnit tests cover link resolution, translation group collection, URL validation, permission checks, GET discovery, and comprehensive PATCH behavior including enqueue error handling and ownership invariant.
WordPress bootstrap and test setup
wordpress/themes/cdcf-headless/tests/bootstrap.php
PHPUnit bootstrap adds a minimal WP_Post class stub for test loading, requires my-team-member.php handler, and defines CDCF_ZITADEL_EXPECTED_AUD before loading auth/zitadel-bearer.php to support multi-audience test scenarios. functions.php requires zitadel-bearer module and registers routes and auth filter.
Environment configuration and documentation
.env.local.example, AGENTS.md
.env.local.example adds placeholders for AUTH_ZITADEL_ID, AUTH_ZITADEL_SECRET, AUTH_ZITADEL_ISSUER, and AUTH_SECRET. AGENTS.md documents the new REST endpoints, their authorization behavior, Zitadel bearer authentication (audience checks, userinfo validation, caching, fail-closed behavior), and environment variable relationships.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • #173: Multi-audience parsing/validation helpers and tests for CDCF_ZITADEL_EXPECTED_AUD.

🐰 A rabbit hops through OIDC streams so fine,
Zitadel tokens dance in time,
Team members edit bios across the lands,
Auth flows flourish by gentle hands!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary changes: authentication phases (1b, 2, 3), bearer validator with multi-audience support, Auth.js v5 integration, and REST endpoints for team member editing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/zitadel-bearer-validator

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Jun 5, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 173 complexity · 0 duplication

Metric Results
Complexity 173
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Jun 5, 2026

Codecov Report

❌ Patch coverage is 90.94828% with 21 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...cdcf-headless/includes/handlers/my-team-member.php 92.51% 11 Missing ⚠️
...mes/cdcf-headless/includes/auth/zitadel-bearer.php 88.23% 10 Missing ⚠️

📢 Thoughts on this report? Let us know!

…property replay

Address HIGH-severity security review finding on PR #172: the bearer
validator was accepting any Zitadel-issued token whose userinfo claim
matched a WP user's email — even tokens minted for sibling umbrella
properties (LiturgicalCalendar, OntoKit, BibleGet).

Attack chain blocked by this commit:
  1. Attacker authenticates against LitCal (same Zitadel instance),
     receives an access token with aud=<litcal-client-id>.
  2. Attacker replays the token to cdcf-website:
       Authorization: Bearer <litcal-token>
  3. Pre-fix: /oidc/v1/userinfo returns 200 with email_verified=true
     and the attacker's email. The umbrella uses
     UserEmailAsUsername=true so login names are globally unique
     emails. If a WP user exists with that email, the attacker is
     authenticated as them.
  4. Post-fix: audience check at the top of the validator rejects
     the token before the userinfo round-trip because aud
     ≠ CDCF_ZITADEL_EXPECTED_AUD.

Implementation:

- New constant CDCF_ZITADEL_EXPECTED_AUD (defaults to '', defined in
  zitadel-bearer.php). Must be set in wp-config.php to the CDCF Website
  Web app's client ID emitted by cdcf-infra's
  setup-zitadel.sh --provision-cdcf-website handoff. Empty value fails
  every audience check closed — no Bearer auth works at all until the
  constant is configured.
- New helper cdcf_zitadel_bearer_decode_jwt_payload() decodes the
  unverified JWT payload (signature verification stays delegated to
  the userinfo round-trip — a tampered payload would have failed the
  upstream signature check, so the unverified payload's claims are
  integrity-safe).
- New helper cdcf_zitadel_bearer_audience_ok() does the actual match,
  accepting either a string or string[] aud claim per RFC 7519, using
  hash_equals for constant-time comparison.
- Audience check runs BEFORE the cache lookup and userinfo POST —
  invalid-audience tokens never even consume a userinfo call.
- Opaque (non-JWT) access tokens are now also rejected, since the
  audience check requires a parseable payload. cdcf-infra is already
  configured for OIDC_TOKEN_TYPE_JWT so this is the expected case.

Tests:

- New buildJwt helper in ZitadelBearerTest mints realistic test JWTs
  (header.payload.signature shape, payload base64url-encoded JSON).
- All existing happy-path tests updated to use minted JWTs with
  matching aud so they continue to exercise the full pipeline.
- 6 new tests:
   - wrong audience → fall through without userinfo call
   - missing aud claim → fall through
   - aud as string[] containing match → accepted (RFC 7519 array form)
   - opaque non-JWT token → fall through
   - audience helper rejects with empty expected (misconfigured deploy)
   - audience helper does exact-string match (no prefix/substring)
- Test bootstrap pins CDCF_ZITADEL_EXPECTED_AUD before requiring the
  validator so the in-file defined()|define default no-ops. The
  helper-direct test covers the empty-expected branch since PHP
  constants can't be redefined at runtime.

AGENTS.md (mirrored to CLAUDE.md via the symlink) documents the
audience-verification step + the wp-config.php constant requirement
prominently as the first bullet of the auth section.

Theme suite: 412 tests, 959 assertions (up from 406 / 950).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnRDOrazio
Copy link
Copy Markdown
Member Author

Follow-up commit 01f797d — addresses HIGH-severity security review finding (cross-property token replay).

The original validator only checked the userinfo response — which accepts any valid token issued by the umbrella Zitadel instance, including tokens minted for sibling properties (LiturgicalCalendar / OntoKit / BibleGet). Combined with UserEmailAsUsername=true at the umbrella layer (login names = emails globally), a token issued for LitCal could have been replayed against cdcf-website's REST and authenticated the attacker as whatever WP user holds the same email.

Fix: Audience verification of the JWT's aud claim before the userinfo round-trip.

  • New constant CDCF_ZITADEL_EXPECTED_AUD (defaults to '') must be set in wp-config.php to the CDCF Website Web app's client ID — emitted by cdcf-infra's setup-zitadel.sh --provision-cdcf-website handoff (cdcf-infra PR Please remove atomicmail.io from disposable list (legitimate permanent email service) #11).
  • Empty value fails closed: no Bearer token is accepted at all until the constant is configured. Safe default.
  • New cdcf_zitadel_bearer_decode_jwt_payload() helper reads the unverified payload (signature verification stays delegated to the userinfo round-trip — a tampered payload would have failed the upstream signature check, so reading aud from the unverified payload is integrity-safe).
  • New cdcf_zitadel_bearer_audience_ok() helper accepts string-or-array aud per RFC 7519, uses hash_equals for constant-time compare.
  • Check happens before the cache lookup and userinfo POST — wrong-audience tokens never consume a userinfo call.
  • Opaque non-JWT tokens are now also rejected; cdcf-infra is already configured for OIDC_TOKEN_TYPE_JWT so this is the expected token shape.

Tests: 6 new tests covering wrong-aud, missing-aud, aud array form, opaque token, helper with empty expected (covers the misconfigured-deploy branch since PHP constants can't be redefined at runtime), and exact-string match.

Suite: 412 tests, 959 assertions — was 406 / 950 in the original commit.

Follow-up needed on cdcf-infra side: PR #11's auth/handoffs/cdcf-website.md should mention adding define('CDCF_ZITADEL_EXPECTED_AUD', '<client_id>'); to wp-config.php at deploy time. I'll patch that in a separate commit to PR #11.

Implements Phase 2 of cdcf-bio-edit-zitadel plan. The Next.js frontend
can now initiate the Zitadel OIDC sign-in flow, holds an access/refresh
token pair in a JWT session, exposes it to server components via auth(),
and surfaces sign-in/out controls in the header.

No user-facing protected routes yet — those land in Phase 4 with the
/my-bio page. Phase 2's UI contribution is just the header button
flipping between "Sign in" (unauthenticated) and an email/sign-out
dropdown (authenticated).

Files added
-----------
- lib/auth.ts                              — NextAuth({...}) config: Zitadel provider with
                                              offline_access + the project:roles scope, JWT
                                              session strategy, refresh-token rotation against
                                              /oauth/v2/token, roles + locale claim extraction.
                                              Session type augmentation inline so this file is
                                              the single source of truth for the exposed shape.
- lib/auth-utils.ts                        — Server-only helpers: requireSession() (redirects
                                              to /api/auth/signin), requireRole(role) (throws
                                              403-shaped Error), getAccessToken(session)
                                              (returns undefined when session has refresh
                                              error).
- lib/auth-utils.test.ts                   — 10 Vitest tests covering all three helpers.
- app/api/auth/[...nextauth]/route.ts      — `export { GET, POST } from handlers`.
- components/AuthButton.tsx                — Client component salvaged from the deferred
                                              feature/zitadel-integration branch with
                                              CodeRabbit-fix-equivalent cleanups already
                                              applied: stable closeTimeout cleanup, ARIA on
                                              the dropdown, keyboard nav, no /profile link
                                              (the bio editor route lands in Phase 4).

Files modified
--------------
- app/[lang]/layout.tsx                    — auth() called server-side in parallel with
                                              getMessages(), session passed into
                                              <SessionProvider> wrapping the existing
                                              NextIntlClientProvider.
- components/Header.tsx                    — <AuthButton /> rendered between
                                              <LanguageSwitcher /> and the donate CTA.
- messages/{en,it,es,fr,pt,de}.json        — New top-level "Auth" block:
                                                signIn / signOut / loading.
- .env.local.example                       — AUTH_ZITADEL_ID/SECRET/ISSUER + AUTH_SECRET
                                              with provenance + generation hints.
- AGENTS.md (→ CLAUDE.md symlink)          — Environment-variables section now lists the
                                              new AUTH_* vars and cross-links the bearer-
                                              validator section's CDCF_ZITADEL_EXPECTED_AUD
                                              constant (same client_id value, different
                                              file: wp-config.php vs Next.js env).
- package.json / package-lock.json         — next-auth@5.0.0-beta.31 added.

Design choices and what was deliberately NOT done
-------------------------------------------------
- Dropped the deferred branch's `events.signIn` WP user sync — that hit
  a non-existent /cdcf/v1/sync-user endpoint and used Application
  Password auth. Per locked decision #1 of the plan, a Zitadel admin
  manually creates WP users on email drift; no auto-provisioning.
- No teamMemberId on the session yet. The plan called for a
  cdcfWpProfile JWT callback that hits /cdcf/v1/my-team-member to
  cache the linked post id — but that endpoint doesn't exist yet
  (Phase 3). Phase 4's edit page will resolve it server-side per
  request. The session carries accessToken + roles + locale, which is
  all that's needed for the sign-in flow.
- AuthButton only shows "Sign in" / email-dropdown / sign-out. The
  "Edit my bio" entry lands in Phase 4 once we have a route to point
  it at.

Verification
------------
- npm run build: clean, /api/auth/[...nextauth] route registered.
- npm run lint: clean.
- npm test: 134 pass (was 124 — +10 auth-utils tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnRDOrazio JohnRDOrazio changed the title feat(theme): Zitadel bearer-token authenticator for WP REST (Phase 1b of #2) feat(auth): Zitadel bearer validator + Next.js Auth.js v5 (Phases 1b + 2 of #2) Jun 5, 2026
@JohnRDOrazio
Copy link
Copy Markdown
Member Author

Phase 2 added in commit 73880b7 — Next.js Auth.js v5 wiring on top of the bearer-validator from Phases 1b. The PR now covers two phases of the bio-edit plan:

Phase What Commits
1b WP bearer-token validator + audience check 0ff6f89, 01f797d
2 Next.js Auth.js v5 wiring (Zitadel provider, JWT session, SessionProvider in layout, AuthButton in header, env scaffolding) 73880b7

Phase 2 surface area:

  • lib/auth.ts — NextAuth config with Zitadel provider, offline_access + project-roles scope, JWT session + refresh-token rotation, roles + locale claim extraction. Session augmentation kept inline so this file is the single source of truth for the exposed shape.
  • lib/auth-utils.tsrequireSession(), requireRole(role), getAccessToken(session) for server components.
  • lib/auth-utils.test.ts — 10 Vitest tests covering all three helpers.
  • app/api/auth/[...nextauth]/route.tsexport { GET, POST } from handlers.
  • components/AuthButton.tsx — salvaged from the deferred feature/zitadel-integration branch with CodeRabbit-fix-equivalent cleanups already applied (stable cleanup, ARIA, keyboard nav). Header now renders it between LanguageSwitcher and the donate CTA.
  • app/[lang]/layout.tsxauth() called server-side in parallel with getMessages(), session passed into <SessionProvider> wrapping the existing <NextIntlClientProvider>.
  • messages/{en,it,es,fr,pt,de}.json — new top-level Auth block (signIn / signOut / loading).
  • .env.local.exampleAUTH_ZITADEL_ID/SECRET/ISSUER + AUTH_SECRET with provenance + generation hints.

Deliberately not done in Phase 2 (per plan, deferred to later phases):

  • The deferred branch's events.signIn WP user sync — dropped because it hits a non-existent /cdcf/v1/sync-user. Per locked decision send notifications to list of subscribers for new projects #1 a Zitadel admin manually creates WP users on email drift; no auto-provisioning.
  • teamMemberId on the session — needs the GET /cdcf/v1/my-team-member endpoint that lands in Phase 3.
  • "Edit my bio" entry in the AuthButton dropdown — wires up in Phase 4 with the /[lang]/my-bio route.

Verification:

npm run build  # clean, /api/auth/[...nextauth] registered
npm run lint   # clean
npm test       # 134 pass (was 124 — +10 auth-utils tests)

PR title updated to reflect the wider scope.

…s + interactive-element a11y

Two findings raised by Codacy on PR #172:

[MEDIUM, noExplicitAny] lib/auth-utils.test.ts
  Two `as any` casts on session-shaped object literals inside the
  getAccessToken tests. Replaced with `Session`-typed declarations
  using the actual exported type from next-auth (now imported as a
  type-only import). The literal now includes the required `expires`
  field so it's a real Session, not a partially-typed cast. eslint-
  disable lines for no-explicit-any removed; they're no longer needed.

[HIGH, jsx-a11y/no-static-element-interactions] components/AuthButton.tsx
  The wrapping <div className="relative"> carried onMouseEnter +
  onMouseLeave handlers — a static <div> with interactive behaviour
  fails the a11y rule. Moved both handlers onto the <button> (already
  interactive) and added a paired set to the <div role="menu"> (the
  menu role makes that element interactive too). Also added onFocus +
  onBlur to the button so keyboard users get the same open-on-focus /
  close-on-blur behaviour as mouse users.

Functional behaviour is preserved on hover: cursor over button opens
the menu, cursor over menu keeps it open (because the menu's own
mouseEnter cancels the scheduled-close timer), cursor leaving either
schedules close after 150ms.

Verification: npm run build / lint / test all clean. 134 vitest tests
still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnRDOrazio
Copy link
Copy Markdown
Member Author

Codacy findings addressed in 6ca4c8e.

Severity File Finding Fix
MEDIUM lib/auth-utils.test.ts Two as any casts on session-shaped object literals Imported Session type from next-auth, declared real Session-typed locals (including the required expires field). eslint-disable lines removed.
HIGH components/AuthButton.tsx <div> wrapper carried onMouseEnter/onMouseLeave (static element with interactive behaviour — jsx-a11y/no-static-element-interactions) Moved mouse handlers to the <button> (already interactive) + the <div role="menu"> (menu role is interactive). Added onFocus/onBlur to the button so keyboard users get the same open-on-focus / close-on-blur behaviour.

Functional behaviour is preserved: hovering the trigger opens the menu, hovering the menu keeps it open (the menu's own mouseEnter cancels the close timer the button's mouseLeave scheduled), cursor leaving either schedules close after 150ms.

npm run build / lint / test all clean. 134 vitest tests still pass.

JohnRDOrazio and others added 2 commits June 5, 2026 03:42
cdcf-infra PR #11 ultimately split the CDCF Website OIDC client into
TWO confidential apps under the CDCF Org:

  - production: aud=<prod_client_id>,  origin catholicdigitalcommons.org, devMode=false
  - non-prod:   aud=<nonprod_client_id>, origins staging.* + http://localhost:3000, devMode=true

The shared WP backend serves both frontends, so the single-value
audience pin from PR #172 would have 401'd whichever client_id wasn't
configured. Switch CDCF_ZITADEL_EXPECTED_AUD to a comma-separated
allow-list and accept any token whose aud claim intersects with it.

Validator changes
-----------------
- New cdcf_zitadel_bearer_parse_allowed_auds($raw) parses the constant
  into a string[] — comma-split, whitespace-trimmed, empty entries
  dropped. Empty / whitespace-only / all-comma input returns [].
- cdcf_zitadel_bearer_audience_ok() signature changed from
  (claims, string $expected) to (claims, array $allowed). Inner
  matcher still uses hash_equals for constant-time compare per entry;
  outer logic now does intersect-non-empty.
- Filter callback parses the constant on every call and short-circuits
  on empty allow-list with the existing error_log line ("...
  not configured ... rejecting all bearer tokens"). Misconfigured-
  deploy behaviour unchanged: fail closed.
- Header docblock on the constant explains the prod + non-prod split
  and gives the exact wp-config.php line shape.

Tests
-----
- bootstrap.php now defines the constant as the comma-separated form
  `'<prod>, <nonprod>'` so multi-aud integration tests work.
- All existing audience_ok tests now pass the allow-list as an array.
- New tests:
   * match against second allow-list entry (nonprod)
   * reject when token aud matches NONE of the allow-list
   * RFC 7519 aud-as-array + allow-list-as-array intersection
   * empty allow-list fails closed
   * parser: comma split, whitespace trim, empty/double-comma drop
   * parser: '', '   ', ',,,'  all return []
   * integration: nonprod aud token authenticates through full filter
- Bearer-test count: 19 → 27. Full theme suite: 412 → 420.

AGENTS.md (+CLAUDE.md symlink) updates the Zitadel bearer auth section
to document the multi-aud allow-list semantics and the exact
wp-config.php line shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ints

GET  /cdcf/v1/my-team-member
PATCH /cdcf/v1/my-team-member/{lang}

The authenticated user's bio self-edit surface. Discovery resolves the
caller's author_team_member ACF link, pulls the full Polylang group,
and reports which language versions are editable. Edit accepts a
partial PATCH against any language in that group, persists to the
target post, and fans out re-translation to the OTHER 5 langs from
the just-saved source — reusing cdcf_enqueue_post_translation() so
all the existing redis-queue + Polylang-link + advisory-lock infra
works as-is.

Authorization model
-------------------
- Bearer-validated session via the Phase 1b authenticator (or cookie
  / Application Password for non-frontend callers).
- author_team_member ACF user-field is the canonical ownership signal.
  It's admin-managed (set via /cdcf/v1/author-team-member, PR #160),
  so end users can never widen their own access from inside this
  endpoint.
- Permission gate: logged-in AND has any link → admits.
- Per-language gate inside the handler: the link must point at SOME
  post in the {lang} target's Polylang group. Covers the case where
  the admin set the link at the EN post but the user edits the DE
  sibling.

Translation strategy (issue #2 locked decision #3)
--------------------------------------------------
- Edit in any language — no privileged "primary language". Whichever
  language gets saved becomes the new source-of-truth for that cycle.
- Re-translation fan-out covers the other 5 langs in the group, NOT
  including the just-saved one. Each enqueue is independent; a single
  enqueue failure is recorded in `errors` without aborting siblings
  (the just-saved source-language post is already persisted at that
  point — we don't roll it back for a downstream queue blip).
- The OpenAI prompt reads source-lang from the source post (PR #171
  fix), so editing in any locale produces correct translations.

URL host allowlist
------------------
- member_linkedin_url: linkedin.com (or any subdomain) or empty.
- member_github_url:   github.com   (or any subdomain) or empty.
- esc_url_raw sanitization runs first via the args block; the
  hostname constraint can't be expressed there so it's enforced in
  the handler body, returning 400 on violation.
- Suffix match is dot-boundary anchored — linkedin.com.evil.org
  does NOT pass.

Partial PATCH semantics
-----------------------
- A field is updated only when the request actually carried a string
  value for it (caught via `is_string($request->get_param(…))`). A
  PATCH that only changes member_title does NOT clobber post_content.
- Empty string IS a valid value and clears the field (used by URL
  fields and member_title alike).

Tests (27 new in MyTeamMemberHandlerTest)
-----------------------------------------
- resolve_link:    scalar id / array of ids / WP_Post / false / anonymous
- collect_group:   normal / fallback to pll_get_post_language / invalid
- url_host_ok:     empty / exact / subdomain / unrelated / suffix-
                   lookalike / malformed
- permission:      401 anonymous / 403 no link / true linked
- GET happy:       full group with two languages
- GET filter:      polluted group with non-team_member post stripped
- PATCH happy:     DE edit fans out to en/it/es/fr/pt, all three ACF
                   fields written to the DE post, wp_update_post
                   carries the right ID + content
- PATCH partial:   member_title-only PATCH leaves wp_update_post
                   uncalled
- PATCH 404:       lang not in Polylang group
- PATCH 400:       LinkedIn URL pointing at evil.example.org
- PATCH 400:       GitHub URL pointing at gitlab.com
- PATCH 500:       wp_update_post WP_Error bubbles up
- PATCH partial-fail: one enqueue WP_Error recorded, siblings still
                      queued, no exception
- PATCH ownership: link points outside the resolved Polylang group →
                   403

Test bootstrap also gains a minimal WP_Post shim (4 fields) and the
new handler include is required-up-front like the others.

Theme suite: 420 → 447 tests (+27), 970 → 1033 assertions (+63).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnRDOrazio JohnRDOrazio changed the title feat(auth): Zitadel bearer validator + Next.js Auth.js v5 (Phases 1b + 2 of #2) feat(auth): Phases 1b + 2 + 3 of #2 — bearer validator (multi-aud per #173), Auth.js v5, my-team-member REST Jun 5, 2026
@JohnRDOrazio
Copy link
Copy Markdown
Member Author

Phase 3 + issue #173 added. PR now covers:

Phase / Issue Commits What
1b 0ff6f89, 01f797d, 6ca4c8e WP bearer validator, audience verification, Codacy fixes
2 73880b7 Next.js Auth.js v5: Zitadel provider, JWT session + refresh, AuthButton, layout wiring, i18n keys ×6
#173 99ebefe Bearer validator accepts comma-separated allow-list of audiences — supports the prod + non-prod CDCF client split landed in cdcf-infra setup-zitadel.sh
3 c502a37 GET /cdcf/v1/my-team-member (discovery) + PATCH /cdcf/v1/my-team-member/{lang} (edit)

Issue #173 fix (99ebefe)

  • CDCF_ZITADEL_EXPECTED_AUD is now a comma-separated allow-list. New cdcf_zitadel_bearer_parse_allowed_auds() helper handles the parse (trim, drop empties, fail-closed on whitespace).
  • cdcf_zitadel_bearer_audience_ok() signature now (claims, string[] $allowed); inner matcher unchanged (hash_equals per entry).
  • wp-config.php shape:
    define('CDCF_ZITADEL_EXPECTED_AUD', '<prod_client_id>,<nonprod_client_id>');
  • 8 new tests: multi-aud accept (second entry), RFC 7519 array-aud intersect, parser variants (trim / drop empties / ,,, returns []), end-to-end integration with a non-prod aud token authenticating through the full filter.

Phase 3 endpoints (c502a37)

  • Discovery: GET /cdcf/v1/my-team-member → resolves the caller's author_team_member link, returns the post id + every Polylang sibling. 401 anon / 403 no-link / 200 with available languages.
  • Edit: PATCH /cdcf/v1/my-team-member/{lang} → writes the {lang} post (partial PATCH semantics — only supplied fields change) + queues re-translation to the other 5 langs from the just-saved source. Ownership invariant: the user's link must point at SOME post in the resolved Polylang group.
  • URL host allowlist: linkedin.com and github.com (+ subdomains), dot-anchored suffix match. Empty clears the field.
  • 27 new tests in MyTeamMemberHandlerTest. Theme suite: 420 → 447 tests, 970 → 1033 assertions.

Tally

  • Theme PHPUnit: 447 ✓ (1033 assertions)
  • Vitest: 134 ✓
  • Build + lint: clean

Next per the plan: Phase 4 — Next.js bio editor UI at /[lang]/my-bio (TipTap-based, calls these endpoints). Want me to roll it into this same PR, or split — this PR is already large?

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
AGENTS.md (1)

196-204: 💤 Low value

Fix list formatting.

Add a blank line before the parameter list to satisfy Prettier and Codacy markdown linting rules. This will be auto-fixed by running prettier --write AGENTS.md (as indicated by the pipeline failure).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` around lines 196 - 204, The markdown in AGENTS.md is failing lint
because the parameter list (the bullet list of `content`, `member_title`,
`member_linkedin_url`, `member_github_url`, etc.) lacks a blank line before it;
open AGENTS.md and insert a single blank line above the parameters bullet list
so Prettier/Codacy markdown rules are satisfied, then run `prettier --write
AGENTS.md` (or stage the file) to apply formatting and clear the pipeline
failure.
package.json (1)

25-25: ⚡ Quick win

Prefer exact pin for next-auth beta to avoid accidental upgrade drift.

Using ^5.0.0-beta.31 can pull newer prerelease/stable versions unexpectedly. Pinning the exact beta helps keep auth behavior deterministic until you intentionally upgrade.

Suggested change
-    "next-auth": "^5.0.0-beta.31",
+    "next-auth": "5.0.0-beta.31",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 25, The dependency entry for next-auth uses a caret
range ("next-auth": "^5.0.0-beta.31") which allows accidental prerelease/stable
upgrades; update the package.json entry for next-auth to an exact version string
("next-auth": "5.0.0-beta.31") and then regenerate the lockfile (run npm install
or yarn install) so the lockfile reflects the pinned beta; locate the dependency
in package.json to make this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.local.example:
- Around line 52-65: Update the .env comment near
AUTH_ZITADEL_ID/AUTH_ZITADEL_SECRET to add a short cross-reference that
wp-config.php must set the CDCF_ZITADEL_EXPECTED_AUD constant to a
comma-separated allow-list of client IDs (multi-audience) rather than a single
AUTH_ZITADEL_ID; mention wp-config.php and reference AGENTS.md (around line 212)
for details so deployers know to include prod+nonprod client IDs.

In `@AGENTS.md`:
- Around line 298-299: The doc incorrectly instructs setting
CDCF_ZITADEL_EXPECTED_AUD to a single AUTH_ZITADEL_ID; instead update the
guidance so CDCF_ZITADEL_EXPECTED_AUD is documented as a comma-separated
allow-list of all client IDs (e.g., prod and nonprod) that the shared WordPress
backend should accept; mention that each frontend still uses its own
AUTH_ZITADEL_ID in its .env.* files and that wp-config.php must include the
combined CDCF_ZITADEL_EXPECTED_AUD value to avoid rejecting valid bearer tokens
from other environments.

In `@components/AuthButton.tsx`:
- Around line 89-90: The blur handler currently calls scheduleClose which starts
a timer and never gets cleared when focus moves into the dropdown, so keyboard
users can lose the menu before activating items; add a corresponding focus
handler (e.g., onFocus or onMouseEnter) that cancels the scheduled close
(implement/rename functions like scheduleClose and cancelScheduledClose or
clearCloseTimer) and attach it to both the trigger and the dropdown/menu
container (including the menu item list used around handleKeyDown) so any focus
transition into the menu clears the timer and prevents premature closing; ensure
cancelScheduledClose is invoked on focus and that scheduleClose is still used on
blur to preserve delayed close behavior.

In `@lib/auth.ts`:
- Around line 46-55: The refresh-token HTTP request in lib/auth.ts (the fetch
call that posts to `${issuer}/oauth/v2/token` using token.refreshToken) needs an
explicit timeout: create an AbortController, start a timer (e.g., setTimeout)
that calls controller.abort() after the desired timeout, pass controller.signal
into the fetch options, and clear the timer after fetch completes; ensure fetch
aborts propagate so the existing error handling falls back to
RefreshAccessTokenError. Update the fetch invocation and surrounding logic (the
block that builds headers/body and awaits the response) to use this
AbortController pattern.

In `@wordpress/themes/cdcf-headless/functions.php`:
- Around line 701-713: Add a 'lang' entry to the args array passed to
register_rest_route for the 'cdcf/v1' PATCH '/my-team-member/(?P<lang>[a-z]{2})'
route and supply a sanitize_callback (e.g., sanitize_text_field or a stricter
two-letter validator) so the path param is validated like other request fields;
update the args block alongside 'content', 'member_title', etc., ensuring
cdcf_rest_update_my_team_member continues to read $request['lang'] but now
receives a sanitized value via the route declaration.

In `@wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php`:
- Around line 216-218: The transient cache check uses is_int($cached) so numeric
strings (common from DB-backed transients) miss hits; update the logic around
get_transient and $cached to treat numeric-string hits as valid by checking
is_numeric($cached) (or ctype_digit if you prefer) and then cast to int before
returning — e.g., replace the is_int($cached) && $cached > 0 guard with a
numeric check and return (int)$cached to restore the intended 60s caching
behavior in the get_transient/$cached branch.

In `@wordpress/themes/cdcf-headless/includes/handlers/my-team-member.php`:
- Around line 197-217: The Polylang group entry at $group[$requested_lang] must
be validated before trusting it for updates: fetch the post with
get_post($group[$requested_lang]) (or get_post($target_post_id)) and verify it
exists and its post_type === 'team_member' (and optionally
current_user_can('edit_post', $target_post_id)) before proceeding to call
wp_update_post(); if the post is missing or has the wrong post_type, return a
WP_Error (similar to the existing rest_no_translation_for_lang/rest_forbidden
responses) to reject polluted/stale translation entries; keep the existing
ownership in_array($linked_id, $group, true) check as an additional guard.
- Around line 239-283: This handler currently enqueues re-translation jobs even
when the PATCH supplies no mutable fields; detect whether any of the target
fields were actually provided before persisting or queuing: check
request->get_param('content') and each of request->get_param for 'member_title',
'member_linkedin_url', 'member_github_url' and set a boolean like $did_update
(or $has_changes) if any is a string and an update will be performed (or
succeeds); after applying updates with wp_update_post and update_field
(functions referenced in this diff), if $did_update is false then skip the
fan-out loop that calls cdcf_enqueue_post_translation and simply return early
(or return success without queuing), preventing no-op PATCHes from enqueueing
translation jobs for other entries in $group for $other_lang !==
$requested_lang.

---

Nitpick comments:
In `@AGENTS.md`:
- Around line 196-204: The markdown in AGENTS.md is failing lint because the
parameter list (the bullet list of `content`, `member_title`,
`member_linkedin_url`, `member_github_url`, etc.) lacks a blank line before it;
open AGENTS.md and insert a single blank line above the parameters bullet list
so Prettier/Codacy markdown rules are satisfied, then run `prettier --write
AGENTS.md` (or stage the file) to apply formatting and clear the pipeline
failure.

In `@package.json`:
- Line 25: The dependency entry for next-auth uses a caret range ("next-auth":
"^5.0.0-beta.31") which allows accidental prerelease/stable upgrades; update the
package.json entry for next-auth to an exact version string ("next-auth":
"5.0.0-beta.31") and then regenerate the lockfile (run npm install or yarn
install) so the lockfile reflects the pinned beta; locate the dependency in
package.json to make this change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1f5e460e-75ed-41f1-97fb-f7c9a185bf1b

📥 Commits

Reviewing files that changed from the base of the PR and between cf73abc and c502a37.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (22)
  • .env.local.example
  • AGENTS.md
  • app/[lang]/layout.tsx
  • app/api/auth/[...nextauth]/route.ts
  • components/AuthButton.tsx
  • components/Header.tsx
  • lib/auth-utils.test.ts
  • lib/auth-utils.ts
  • lib/auth.ts
  • messages/de.json
  • messages/en.json
  • messages/es.json
  • messages/fr.json
  • messages/it.json
  • messages/pt.json
  • package.json
  • wordpress/themes/cdcf-headless/functions.php
  • wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php
  • wordpress/themes/cdcf-headless/includes/handlers/my-team-member.php
  • wordpress/themes/cdcf-headless/tests/MyTeamMemberHandlerTest.php
  • wordpress/themes/cdcf-headless/tests/ZitadelBearerTest.php
  • wordpress/themes/cdcf-headless/tests/bootstrap.php

Comment thread .env.local.example
Comment thread AGENTS.md Outdated
Comment thread components/AuthButton.tsx
Comment thread lib/auth.ts
Comment thread wordpress/themes/cdcf-headless/functions.php
Comment thread wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php Outdated
Comment thread wordpress/themes/cdcf-headless/includes/handlers/my-team-member.php
Comment thread wordpress/themes/cdcf-headless/includes/handlers/my-team-member.php
Eight findings resolved, all verified against current code; one was
already an addressed-elsewhere placeholder so the instruction note
below records what's unchanged.

[lint-markdown] AGENTS.md
  MD032 (blanks-around-lists) on the Phase 3 Parameters list. Inserted
  the required blank line and ran prettier --write to converge the
  Method/Route column widths the new PATCH row had widened.

[multi-aud docs miss] AGENTS.md L298
  The Environment Variables section still said "the same AUTH_ZITADEL_ID
  value also needs to be set as CDCF_ZITADEL_EXPECTED_AUD" — predates
  the multi-aud commit 99ebefe. Now correctly documents the comma-
  separated allow-list and that each frontend pins its OWN
  AUTH_ZITADEL_ID while wp-config.php's CDCF_ZITADEL_EXPECTED_AUD
  carries BOTH client IDs. Same fix applied to .env.local.example with
  a back-pointer to AGENTS.md.

[a11y] components/AuthButton.tsx
  onBlur on the trigger button scheduled a 150ms close timer, but the
  menu <div role="menu"> only had mouse handlers — keyboard tabbing
  from button into a menu item let the timer fire before any onFocus
  could cancel it. Added onFocus/onBlur to the menu div; React's
  onFocus bubbles so item focus reaches the wrapper and clears the
  timer. Mouse paths unchanged.

[auth resilience] lib/auth.ts
  Refresh-token POST to /oauth/v2/token had no client-side timeout —
  an unreachable Zitadel could stall a full page render at every
  expiry. Wrapped in an AbortController with a 5s cap; the existing
  catch path covers timeout + network errors uniformly via
  RefreshAccessTokenError. clearTimeout in finally so the timer
  doesn't keep the event loop alive after success.

[sanitization convention] functions.php
  The PATCH route's {lang} URL param was being validated only by the
  inline regex in the path pattern. Declared it in the args block per
  AGENTS.md's "Every cdcf/v1 route declares its sanitize_callback per
  field" convention: sanitize_callback => sanitize_key,
  validate_callback => /^[a-z]{2}$/ regex. Defense in depth — the
  URL pattern already filters, but the args block is the canonical
  point of validation declaration.

[cache regression] includes/auth/zitadel-bearer.php
  is_int($cached) on the get_transient hit missed numeric-string
  shapes the default DB-backed transients can return for int values
  (object-cache deployments preserve the int type; the default doesn't).
  Replaced with is_numeric($cached) && (int) $cached > 0 → return
  (int) $cached, restoring the 60s cache in the no-object-cache case.

[stale-group safety] includes/handlers/my-team-member.php (PATCH)
  $group[$requested_lang] was trusted as a valid team_member id. A
  polluted Polylang group (post deleted, or wrong CPT linked into the
  same translation term) would route an edit to a stale post id.
  Added a get_post + post_type check after the in_array ownership
  invariant; mismatch returns rest_no_translation_for_lang/404.
  Mirrors the GET endpoint's existing filter.

[no-op fan-out] includes/handlers/my-team-member.php (PATCH)
  A PATCH with no mutable field supplied was still enqueueing five
  re-translation jobs that would produce identical OpenAI output.
  Track $did_update across the content + ACF-field writes; when no
  field was actually supplied, return the same envelope shape (with
  empty queued + errors) and skip the fan-out loop entirely.

[stable pin] package.json
  next-auth caret range "^5.0.0-beta.31" was admitting accidental
  prerelease/stable upgrades — risky on a pinned beta. Switched to
  the exact "5.0.0-beta.31" and regenerated package-lock.json.

Tests added (3 new in MyTeamMemberHandlerTest, 450 total)
- patch_rejects_when_target_post_missing_or_wrong_type
- patch_rejects_when_target_post_is_wrong_cpt
- patch_no_op_skips_fan_out

stubCommon() now stubs get_post with a fake team_member default so
the existing PATCH tests (which don't care about the validity branch)
keep passing without per-test stub additions.

Verification
- Theme PHPUnit:  450/450, 1041 assertions
- Vitest:         134/134
- ESLint:         clean
- markdownlint:   0 errors
- prettier check: clean
- next build:     clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnRDOrazio
Copy link
Copy Markdown
Member Author

CodeRabbit findings + markdown-lint addressed in 2a83216.

Verified each finding against current code; all eight were still valid. One follow-on doc fix (AGENTS.md L298 was outdated post-multi-aud) caught in the same pass.

Severity Spot Fix
lint-markdown AGENTS.md parameter list Inserted blank line above bullets (MD032); prettier --write to converge Method/Route column widths the new PATCH row widened
doc AGENTS.md L298, .env.local.example Both said "set AUTH_ZITADEL_ID as CDCF_ZITADEL_EXPECTED_AUD" — predates 99ebefe. Now describes the comma-separated allow-list + that each frontend pins its OWN AUTH_ZITADEL_ID while wp-config.php carries BOTH client IDs
a11y components/AuthButton.tsx Added onFocus/onBlur to the menu <div> so keyboard tab from button into a menu item cancels the 150ms close timer (React's onFocus bubbles)
resilience lib/auth.ts refresh fetch Wrapped in AbortController with 5s timeout; clearTimeout in finally; existing RefreshAccessTokenError catch covers both timeout + network
convention functions.php PATCH args Declared lang in the args block per AGENTS.md sanitization convention: sanitize_key + regex validate_callback (URL pattern still filters first)
cache zitadel-bearer.php get_transient hit is_int missed numeric-string returns from DB-backed transients; switched to is_numeric($cached) && (int) $cached > 0
stale group my-team-member.php PATCH Added get_post + post_type === 'team_member' check after the in_array ownership invariant; mismatch → 404 (mirrors GET)
no-op fan-out my-team-member.php PATCH Track $did_update across content + ACF writes; PATCH with no mutable field returns same envelope shape but skips the 5-job enqueue
pin package.json next-auth ^5.0.0-beta.315.0.0-beta.31; lockfile regenerated

3 new tests in MyTeamMemberHandlerTest cover the validity check + no-op skip branches. stubCommon() now stubs get_post with a fake team_member default so existing PATCH tests don't need per-test additions.

Verification:

Theme PHPUnit:  450 / 450  (1041 assertions, +3 from this pass)
Vitest:         134 / 134
ESLint:         clean
markdownlint:   0 errors
prettier:       clean
next build:     clean

@JohnRDOrazio
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@JohnRDOrazio JohnRDOrazio merged commit a6a12e4 into main Jun 5, 2026
15 checks passed
@JohnRDOrazio JohnRDOrazio deleted the feat/zitadel-bearer-validator branch June 5, 2026 16:28
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