# Changelog All notable changes to this project will be documented in this file. ## [0.11] - 2026-05-30 Fixed PREREQ_PM in Makefile.PL ## [0.10] - 2026-05-30 (Security Fixes: NEW-HIGH-1, NEW-HIGH-2, NEW-MED-1, NEW-MED-2, NEW-LOW-1) ### Security - **NEW-HIGH-1 fixed — Open redirect via unvalidated `redirect_uri` in pre-validation error responses** (`Controller::Root`). The `authorize` action previously called `_error_response` (which issues an HTTP redirect) with the client-supplied `redirect_uri` before that URI had been validated against the client's registered list. The action is now split into two explicit phases: Phase 1 validates `client_id`, looks up the client, and checks `redirect_uri` against the registered list, returning direct HTTP 400 responses (`_json_error`) for any failure. Only after Phase 1 succeeds are redirect-based error responses used. This conforms with RFC 6749 §4.1.2.1. - **NEW-HIGH-2 fixed — Cross-client authorization code redemption at the token endpoint** (`Controller::Root`). After resolving `client_id` at the token endpoint, the handler now immediately asserts that the resolved value equals the `client_id` stored inside the authorization code. A mismatch returns `invalid_grant` before redirect URI verification or client authentication, preventing a confidential client from redeeming a code that was issued to a different client (RFC 6749 §4.1.3). - **NEW-MED-1 fixed — Token type confusion at the UserInfo endpoint** (`Controller::Root`). Access tokens now carry `typ: at+JWT` (RFC 9068) at both issuance paths: the authorization code grant and the refresh token grant. The `userinfo` action rejects any bearer token whose `typ` claim is absent or not `at+JWT`, preventing ID tokens and refresh tokens from being accepted as access tokens. - **NEW-MED-2 fixed — Requested scope not validated against registered client scopes** (`Controller::Root`). The `authorize` action now intersects the requested scope with the client's registered scope list (RFC 6749 §3.3). An empty intersection returns `invalid_scope`. A missing `openid` scope (OIDC Core §3.1.2.1 mandatory) also returns `invalid_scope`. The effective (narrowed) scope overwrites `$scope` so all downstream code — the session, `create_authorization_code`, and the `scp` claim in issued access tokens — reflects only permitted scopes. - **NEW-LOW-1 fixed — PKCE `code_challenge` not validated for format** (`Controller::Root`). When a `code_challenge` is supplied, the `authorize` action now validates that the value matches `\A[A-Za-z0-9\-_]{43}\z` — exactly 43 BASE64URL characters with no padding, as required by RFC 7636 §4.2 for S256 challenges. A malformed value returns `invalid_request` before the code reaches the store. ## [0.09] - 2026-05-27 POD review and initial CPAN release ### POD documentation - All perldoc stanzas reviewed and reformatted as needed for clarity. - Standard perldoc tests now included. - Updated MANIFEST to include all required files. ## [0.08] - 2026-04-29 (Security Fix: MED-6) ### Security - **MED-6 fixed — Missing HTTP security headers on all responses** (`Controller::Root`). A `begin : Private` action now runs before every OIDC endpoint and sets five response headers: - `Cache-Control: no-store` — mandatory per RFC 6749 §5.1 on token responses; applied globally so future endpoints cannot accidentally omit it. - `Pragma: no-cache` — HTTP/1.0 compatibility. - `X-Content-Type-Options: nosniff` — prevents MIME-type sniffing on all OIDC responses. - `X-Frame-Options: DENY` — guards the authorization endpoint HTML page against clickjacking. - `Content-Security-Policy: frame-ancestors 'none'` — modern equivalent of `X-Frame-Options` for browsers that support CSP Level 2+. ## [0.07] - 2026-04-29 (Security Fix: MED-1) ### Security - **MED-1 fixed — Non-revocable refresh tokens** (`Controller::Root`, `Utils::Store`, `Utils::Store::Redis`, `Role::Store`). Refresh tokens are now bound to a unique JTI (UUID v4) that is stored server-side with a TTL matching the 30-day token lifetime. On every use the JTI is atomically consumed from the store (Perl `delete` for the in-memory backend; Redis `GETDEL` for the Redis backend) and a new JTI + refresh token are issued immediately (refresh token rotation). A second attempt to use the same refresh token receives `invalid_grant`. On logout, all outstanding JTIs for the user are deleted via a secondary per-subject index, invalidating any stolen tokens immediately. The `Role::Store` interface gains three new required methods: `store_refresh_token`, `consume_refresh_token`, and `revoke_refresh_tokens_for_user`. ## [0.06] - 2026-04-29 (Security Fixes: MED-2, MED-3, MED-4, MED-5) ### Security - **MED-2 fixed — Sensitive claims logged at debug level** (`Utils::JWT`). `sign_token` previously serialised the entire JWT payload (which may include PII such as email addresses, names, and user identifiers) to the debug log. The log statement now emits only non-sensitive metadata: `sub`, `aud`, and `exp`. PII-bearing claims are never written to any log output. - **MED-3 fixed — Package-level global state shared across application classes** (`Catalyst::Plugin::OpenIDConnect`). The JWT handler and store were stored in `our $_oidc_jwt_instance` / `our $_oidc_store_instance` — package-level globals shared across all Catalyst applications in the same interpreter. These are replaced by per-application-class lexical hashes (`%_oidc_jwt_by_class`, `%_oidc_store_by_class`) keyed by the consuming class name (`ref($self) || $self`). Multiple Catalyst apps in the same process now each hold their own isolated JWT and store instances. - **MED-4 fixed — Implicit grant/response types advertised in discovery** (`Context`). The discovery document listed `implicit` in `grant_types_supported` and `id_token`/`token` values in `response_types_supported`. The implicit flow is deprecated by OAuth 2.0 Security BCP (RFC 9700) and removed from OAuth 2.1. Both lists now advertise only the flows this server actually implements: `authorization_code` and `refresh_token` grants, and `code` as the sole response type. - **MED-5 fixed — Session copy of authorization code never cleaned up** (`Controller::Root`). The authorize endpoint wrote a copy of each issued code and its associated claims/scope/nonce into `$c->session->{oidc_code}`. This entry was never removed, causing stale PII to accumulate in the session store indefinitely. `_handle_authorization_code_grant` now calls `delete $c->session->{oidc_code}->{$code}` immediately after the code is successfully consumed. ### Tests - **`t/01_jwt.t`** (4 new tests, 24 total) — MED-2: capturing logger verifies the `sign_token` debug message does not contain email or name fields, and does contain `sub` and `aud`. - **`t/03_plugin.t`** (7 new tests) — MED-3: two distinct "application class" objects verified to hold isolated JWT instances; MED-4: discovery document verified to not contain `implicit` in grant types or implicit response types, and to still contain `authorization_code` / `code`. --- ## [0.05] - 2026-04-29 (Security Fixes: HIGH-1 through HIGH-5) ### Security - **HIGH-1 fixed — Open Redirect in example login action** (`example/app.pl`). The `back` parameter was forwarded to `$c->response->redirect` without validation, allowing an attacker to craft a login URL that redirected the victim to an arbitrary external site after authentication. The parameter is now validated with `m{^/[^/]}` (must start with `/` followed by a non-`/` character) to reject both absolute URLs and protocol-relative `//` paths, and the redirect is issued via `$c->uri_for($back)`. - **HIGH-2 fixed — Missing mandatory JWT claim validation** (`Utils::JWT`). `verify_token` previously only checked `exp` and `iss` when those claims were present. Both are now mandatory: tokens missing `exp` or `iss` are rejected; an expired `exp` is always rejected; an `iss` that does not match the configured issuer URL is rejected; `nbf` (not-before), when present, is enforced. An optional `expected_audience` parameter was also added: when supplied, the `aud` claim must be present and must match. - **HIGH-3 fixed — Timing-vulnerable client secret comparison** (`Controller::Root`). The `eq` operator was used to compare client secrets at the token endpoint, leaking secret length and prefix information through timing side-channels. Both the authorization-code grant and the refresh-token grant now use `Crypt::Misc::slow_eq()` for constant-time comparison. `Crypt::Misc` added to `cpanfile`. - **HIGH-4 fixed — TOCTOU race in authorization code redemption** (`Utils::Store`, `Utils::Store::Redis`, `Controller::Root`). The previous implementation called `get_authorization_code` followed by a separate `consume_authorization_code`, creating a window where two concurrent requests could both read the same code before either deleted it. `consume_authorization_code` is now a single atomic operation that fetches and deletes in one step (Perl `delete` for the in-memory store; Redis `GETDEL` (≥ 6.2) for the Redis store) and returns the code data hashref. The controller now calls only `consume_authorization_code`; the two-step pattern has been removed. `Role::Store` updated accordingly. - **HIGH-5 fixed — No PKCE support** (`Controller::Root`, `Utils::Store`, `Utils::Store::Redis`, `Role::Store`). Full RFC 7636 PKCE implementation added: - **Authorize endpoint**: reads `code_challenge` and `code_challenge_method` from request parameters; persists them in the session so they survive the login redirect; enforces that public clients (those without a `client_secret`) **must** supply `code_challenge`; rejects any method other than `S256` (`plain` is not supported per OAuth 2.1 / security BCP); stores the challenge with the authorization code in both store backends. - **Token endpoint**: reads `code_verifier` from the POST body; after atomically consuming the code, verifies the challenge with `BASE64URL(SHA256(ASCII(code_verifier))) == code_challenge` using a constant-time comparison (`Crypt::Misc::slow_eq`); returns `invalid_grant` on failure. - **`_verify_pkce($verifier, $challenge)`** — private helper enforces verifier format (43–128 unreserved URI characters: `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`) before computing and comparing the S256 challenge. - Both `Utils::Store` and `Utils::Store::Redis` accept an optional `$pkce` hashref in `create_authorization_code` and persist `code_challenge` / `code_challenge_method` with the code entry. ### Tests - **`t/01_jwt.t`** (10 new tests, 20 total) — tests for `verify_token` mandatory claim enforcement: missing `exp`, expired token, missing `iss`, wrong issuer, future `nbf`, past `nbf`, `expected_audience` match, wrong audience, missing `aud` with `expected_audience`, missing `aud` without `expected_audience`. - **`t/02_store.t`** (updated) — `consume_authorization_code` verified to return code data; second consume verified to return `undef`. Added PKCE round-trip tests: `code_challenge`/`code_challenge_method` stored and returned; no-PKCE case verified to leave those fields absent. - **`t/04_store_redis.t`** (updated) — `MockRedis` gained a `getdel` method; tests confirm `GETDEL` is used (not `del`), consume returns data, second consume returns `undef`. Added PKCE round-trip tests through JSON serialization. - **`t/06_pkce.t`** (new, 11 tests) — unit tests for `_verify_pkce`: correct verifier/challenge pair accepted; wrong verifier rejected; verifier too short (< 43) rejected; verifier too long (> 128) rejected; verifier with disallowed characters rejected; `undef` verifier rejected; `undef` challenge rejected; minimum (43-char) and maximum (128-char) length cases accepted; all unreserved char types accepted; tampered challenge rejected. ### Documentation - **`API_REFERENCE.md`** — Authorization endpoint parameter table updated with `code_challenge` (Conditional) and `code_challenge_method` rows; token endpoint authorization-code grant table updated with `code_verifier` (Conditional) row and `client_secret` changed from Required to Conditional. New "PKCE-Protected Authorization Code Flow" example section added. - **`IMPLEMENTATION_GUIDE.md`** — Authorization Code Flow steps updated with PKCE parameters; State Store module docs updated with accurate signatures and atomic-operation note; login action example updated with safe `back` validation; new PKCE subsection added under Security Considerations. - **`QUICKSTART.md`** — Login action example updated with validated `back` redirect pattern. --- ## [0.04] - 2026-04-29 (Security Fix: Open Redirect in Logout Endpoint) ### Security - **CRIT-1 fixed — Open Redirect in logout endpoint** (`Controller::Root`, `Utils::JWT`). The `post_logout_redirect_uri` parameter was previously forwarded without any validation, allowing an attacker to redirect victims to an arbitrary external URL after logout (phishing / credential harvesting). The logout flow now enforces the following rules, in line with OpenID Connect RP-Initiated Logout 1.0: 1. `post_logout_redirect_uri` is rejected with `invalid_request` unless `id_token_hint` is also supplied. 2. The hint token's RSA signature is verified to confirm it was genuinely issued by this server. Expiry is intentionally **not** checked — hint tokens are frequently expired at logout time by design. 3. The `aud` claim of the verified hint identifies the requesting client. The `post_logout_redirect_uri` is then compared by **exact string match** against that client's registered `post_logout_redirect_uris` list. Prefix matching and host-only matching are not permitted. 4. Any mismatch returns an `invalid_request` OAuth error; no redirect is issued. 5. When a redirect is permitted, the optional `state` parameter is appended verbatim to the redirect URI as required by the specification. ### Added - **`JWT::decode_id_token_hint($token)`** — new method on `Catalyst::Plugin::OpenIDConnect::Utils::JWT`. Verifies the token signature against the configured public key and returns the decoded claims hashref, or `undef` if the token is malformed or the signature is invalid. Distinct from `verify_token` in that it does not reject expired tokens. - **`Controller::Root::_allowed_post_logout_uris($client)`** — private helper that normalises the `post_logout_redirect_uris` client config field from either an arrayref (YAML/JSON config) or a whitespace-delimited string (Config::General-style config) into a flat list of URIs. - **`post_logout_redirect_uris` client config key** — each client may now declare a list of permitted post-logout redirect URIs. This key is required for clients that use `post_logout_redirect_uri` at the logout endpoint. ### Tests - **`t/05_logout.t`** (new, 19 tests) — covers `decode_id_token_hint` for valid tokens, expired tokens, tampered tokens, wrong-key tokens, and structurally invalid JWTs; and `_allowed_post_logout_uris` for arrayref config, string config, missing config, and exact-match security semantics (prefix-of-registered and extended-path attacks). ### Documentation - **`API_REFERENCE.md`** — Logout endpoint section rewritten with updated parameter table (marking `id_token_hint` as conditionally required), security note on exact-match validation, split request/response examples, full error response examples, and a client registration code snippet. - **`README.md`** — Client configuration reference updated with the new `post_logout_redirect_uris` field. - **`IMPLEMENTATION_GUIDE.md`** — Client configuration example and field list updated with `post_logout_redirect_uris`. - **`DEPLOYMENT.md`** — Production `catalyst.conf` example updated with `post_logout_redirect_uris`. - **`QUICKSTART.md`** — Quick-start Perl config example updated with `post_logout_redirect_uris`. - **`example/app.pl`** — Both example clients now include `post_logout_redirect_uris`. --- ## [0.03] - 2026-04-24 (FastCGI / Multi-Process Store Support) ### Added - **Catalyst::Plugin::OpenIDConnect::Role::Store** - New Moose role defining the pluggable store interface. Any store backend must `with` this role and implement three methods: `create_authorization_code`, `get_authorization_code`, `consume_authorization_code`. This decouples the plugin from a specific backend implementation. - **Catalyst::Plugin::OpenIDConnect::Utils::Store::Redis** - Redis-backed store implementation for multi-process deployments (FastCGI, pre-forking servers). - Stores authorization codes in Redis with native TTL expiry via `SETEX` - Lazy Redis connection (opened after `fork()` so each worker has its own socket) - Supports `Redis::Fast` (preferred) or `Redis` client, auto-detected at runtime - Configurable key prefix for namespace isolation on shared Redis instances - Configurable code TTL (default 600 s) - Optional Redis `AUTH` password support - Blessed user objects serialised via `convert_blessed` JSON encoding - **Configurable store class in plugin setup** - `Plugin::OpenIDConnect` config now accepts `store_class` and `store_args` keys, allowing any Role::Store consumer to be used as the backend without touching application code. - **`Module::Runtime::require_module`** used for dynamic store class loading, replacing manual `s{::}{/}g` path mangling. Works correctly regardless of `@INC` ordering or non-filesystem module sources. - **`Bytes::Random::Secure`** used for authorization code generation in both the memory and Redis store backends, replacing the previous `rand`-based generator. Codes are now drawn from the OS CSPRNG (`/dev/urandom`) and are safe to generate after `fork()`. ### Changed - **`Catalyst::Plugin::OpenIDConnect::Utils::Store`** now consumes `Role::Store`, documents its multi-process limitation, and uses `Bytes::Random::Secure` for code generation. - **`_oidc_store` accessor** now validates via `DOES('Role::Store')` instead of `isa('Utils::Store')`, accepting any conforming backend. - **`Catalyst::Plugin::OpenIDConnect::Context::store()`** lazy initialisation now respects `store_class`/`store_args` config rather than always instantiating the in-memory store. - **`Module::Runtime`** added as a declared dependency in `cpanfile`. `Bytes::Random::Secure` added as a core dependency. `Redis::Fast`/`Redis` listed as optional recommended dependencies under the `redis` feature. ### Tests - **`t/02_store.t`** extended with: Role::Store compliance check, expiry enforcement, double-consume safety, CSPRNG uniqueness across 20 codes, `created_at`/`expires_at` field assertions, and missing-code undef check. - **`t/04_store_redis.t`** (new) — 46 tests for the Redis store using an in-process `MockRedis` stub (no live Redis required). Covers: role compliance, create/get/consume lifecycle, `setex`/`del` call verification, key prefix and TTL configuration, code uniqueness, and corrupt-JSON graceful handling. ### Documentation - **`DEPLOYMENT.md`** updated with a new "Redis Store (FastCGI and Multi-Process Deployments)" section covering installation, `catalyst.conf` and Perl hash config examples, production Redis hardening checklist (auth, TLS, memory policy, AOF persistence, namespacing), fork-safety explanation, custom backend table, updated Docker Compose example with a `redis:7-alpine` service, and three new troubleshooting entries. --- ## [0.02] - 2026-04-16 (Bug Fixes & Integration Improvements) ### Changed - **Controller Integration**: Plugin now requires applications to create an extending controller in the app's namespace for proper route discovery. This ensures compatibility with Catalyst::Plugin::ACL and other route-processing plugins. - The plugin's controller (`Catalyst::Plugin::OpenIDConnect::Controller::Root`) is now a base class - Applications must create `lib/MyApp/Controller/OpenIDConnect.pm` that extends the plugin controller - This allows Catalyst to properly auto-discover routes and prevents dispatcher conflicts - **Plugin Namespace Configuration**: Moved namespace configuration from extending controller to the base plugin controller - Base controller now sets `namespace => 'openidconnect'` by default - Extending controllers automatically inherit this configuration - Simplifies application setup - **Simplified Plugin Lifecycle**: Changed from `setup_component`/`finalize_setup` to `after 'setup'` method modifier - Uses proper Moose role syntax for plugin hooks - Ensures correct execution order with other plugins like ACL ### Fixed - Fixed "traversal hit a dead end" error when using plugin with existing apps that have route-processing plugins (ACL, etc.) - Fixed plugin initialization to gracefully handle missing configuration - Improved error handling for missing private key configuration ### Documentation - Updated QUICKSTART.md with controller setup instructions - Updated README.md with extending controller example - Updated IMPLEMENTATION_GUIDE.md with detailed integration steps - Updated DEPLOYMENT.md with production controller setup --- ## [0.01] - 2026-04-10 (Initial Release) ### Added #### Core Implementation - **Catalyst::Plugin::OpenIDConnect** - Main plugin module - Moose role for seamless Catalyst integration - Configuration management via catalyst.conf - Automatic JWT handler initialization - State store management - OIDC context object for controllers - **Catalyst::Plugin::OpenIDConnect::Utils::JWT** - JWT utilities - RS256 (RSA SHA-256) signing algorithm - Token verification with signature validation - Support for ID tokens, access tokens, refresh tokens - URL-safe Base64 encoding (RFC 4648) - Standard claims handling (iss, aud, exp, iat, sub) - Debug decoding without verification - **Catalyst::Plugin::OpenIDConnect::Utils::Store** - State management - In-memory authorization code storage - User session management - UUID-based session IDs - Automatic expiration handling - Code consumption (one-time use) - Cleanup utilities for expired entries - **Catalyst::Plugin::OpenIDConnect::Controller::Root** - Protocol endpoints - Authorization endpoint (GET /openidconnect/authorize) - Token endpoint (POST /openidconnect/token) - UserInfo endpoint (GET /openidconnect/userinfo) - Discovery endpoint (GET /.well-known/openid-configuration) - JWKS endpoint (GET /openidconnect/jwks) - Logout endpoint (POST /openidconnect/logout) #### OAuth 2.0 & OpenID Connect Features - Authorization Code Flow (full implementation) - Token Exchange - authorization_code grant type - refresh_token grant type - State parameter (CSRF protection) - Nonce binding - PKCE-ready (for future implementation) - Standard claims support - Profile claims (name, email, picture, etc.) - Email verification - Phone verification - Address claims - Token types - ID tokens (with user claims) - Access tokens (for API access) - Refresh tokens (for token refresh) #### Configuration - YAML-based configuration via catalyst.conf - Issuer configuration - URL for iss claim - RSA private/public key loading - Key ID for JWT headers - Client configuration - client_id and client_secret - redirect_uris (multiple allowed) - response_types and grant_types - Scope declarations - User claims mapping - Flexible attribute mapping to OIDC claims - Nested attribute support via dot notation - Optional claim definitions #### Security Features - HTTPS support (via reverse proxy) - CSRF protection (state parameter) - Authorization code expiration (10 minutes) - One-time code consumption - Session management with expiration - Bearer token authentication - JWT signature verification - Client secret validation - Redirect URI validation #### Documentation - **README.md** - Feature overview and quick start - **QUICKSTART.md** - 5-minute getting started guide - **IMPLEMENTATION_GUIDE.md** - Architecture and design decisions - **API_REFERENCE.md** - Complete endpoint documentation - **DEPLOYMENT.md** - Production deployment guide - Inline POD documentation in all modules #### Tests - JWT functionality tests (01_jwt.t) - Token signing validation - Token verification validation - Token decoding - Invalid token rejection - Payload matching - Store functionality tests (02_store.t) - Authorization code creation - Code retrieval and validation - Code consumption - Session management #### Example Application - **example/app.pl** - Working Catalyst application - Login page (demo login without password) - Protected resource example - Logout functionality - User session management - Three configured example clients - **example/generate_keys.sh** - RSA key generation script - **example/root/** - HTML templates - index.html (home page) - login.html (login form) - protected.html (protected resource) #### Project Files - **cpanfile** - Comprehensive dependency declarations - Catalyst and related modules - Cryptography libraries - JSON processing - Testing dependencies - **dist.ini** - Distribution configuration for CPAN publishing - Project structure ready for publication ### Implementation Details #### Algorithm Support - RS256 (RSA SHA-256) for all JWT operations - 2048-bit RSA keys (4096-bit recommended for production) #### Token Lifetimes - Authorization codes: 10 minutes - ID tokens: 1 hour - Access tokens: 1 hour - Refresh tokens: 30 days - Sessions: 24 hours (configurable) #### Standard Claims - Supported: sub, name, given_name, family_name, email, picture, phone_number, etc. - User-configurable mapping from application models - Optional claims support #### Endpoints - All endpoints return JSON except authorization (redirects) - Proper HTTP status codes (200, 302, 400, 401, 500) - RFC 6749 & RFC 6750 compliance - OpenID Connect 1.0 Core compliance ### Known Limitations - In-memory state store (database integration requires extension) - Single key at a time (key rotation requires restart) - No HS256 support (RS256 only) - No Implicit or Hybrid flows - No PKCE (for public clients) - No form_post response mode - No client registration endpoint - No introspection endpoint ### Requirements - Perl 5.20 or higher - Catalyst 5.90100 or higher - Moose and related modules - Crypt::OpenSSL modules - JSON::MaybeXS - HTTP::Request and LWP stack ### Testing All modules have unit test coverage. Run tests with: ```bash prove -l t/ ``` ### Future Roadmap - [ ] PKCE support for public clients - [ ] Implicit and Hybrid flow support - [ ] Multiple simultaneous keys - [ ] Database-backed session store - [ ] Introspection endpoint - [ ] Revocation endpoint - [ ] Client metadata endpoint - [ ] HS256 algorithm support - [ ] Multi-signature support - [ ] Request object support - [ ] Pushed Authorization Requests (PAR) - [ ] OpenID Connect Federation support ### Author Tim F. Rayner ### License This library is available under The Artistic License 2.0 (GPL Compatible). See LICENSE file for details.