view release on metacpan or search on metacpan
API_REFERENCE.md view on Meta::CPAN
# OpenID Connect API Reference
Complete API documentation for the Catalyst::Plugin::OpenIDConnect endpoints.
## Base URL
```
http://localhost:5000
```
Replace with your actual issuer URL.
## Authentication Methods
- **Client Authentication**: Use `client_id` and `client_secret` in request body for token endpoint
- **Bearer Token**: Use `Authorization: Bearer <access_token>` header for protected resources
## Discovery Endpoint
### GET /.well-known/openid-configuration
Returns the OpenID Provider Configuration.
**Response:**
```json
{
"issuer": "http://localhost:5000",
"authorization_endpoint": "http://localhost:5000/openidconnect/authorize",
"token_endpoint": "http://localhost:5000/openidconnect/token",
"userinfo_endpoint": "http://localhost:5000/openidconnect/userinfo",
"jwks_uri": "http://localhost:5000/openidconnect/jwks",
"scopes_supported": ["openid", "profile", "email", "phone", "address"],
"response_types_supported": ["code", "id_token token"],
"response_modes_supported": ["query", "fragment", "form_post"],
"grant_types_supported": ["authorization_code", "refresh_token", "implicit"],
"subject_types_supported": ["public", "pairwise"],
"id_token_signing_alg_values_supported": ["RS256"],
"userinfo_signing_alg_values_supported": ["RS256"],
"claims_supported": [
"sub", "name", "given_name", "family_name", "email", "email_verified",
API_REFERENCE.md view on Meta::CPAN
"exp": 1311281970,
"iat": 1311280970,
"name": "Jane Doe",
"email": "janedoe@example.com",
"email_verified": true
}
```
**Verification:**
1. Verify the signature using the public key from the JWKS endpoint
2. Verify `iss` matches expected issuer
3. Verify `aud` contains your client ID
4. Verify `exp` is in the future
5. Verify `nonce` matches the one sent in authorization request
---
## Standard Claims Reference
The UserInfo endpoint and ID token may include these standard claims:
### Profile Claims
- `sub` - Unique subject identifier
- `name` - Full name
- `given_name` - Given (first) name
- `family_name` - Family (last) name
- `middle_name` - Middle name
- `nickname` - Nickname
- `preferred_username` - Preferred username
- `profile` - Profile URL
CHANGELOG.md view on Meta::CPAN
- custom scope handler
## [0.14] - 2026-06-03 Fix JWT signature verification and CSRF behaviour
- **Fixed JWT signature verification failures with external clients** (JWT.pm):
Explicit `use_pkcs1_padding()` call added to RSA signing and verification
operations. Required for OpenSSL 3.x compatibility with Crypt::OpenSSL::RSA
>= 0.38. Fixes BadSignatureError when external JWT libraries (authlib,
cryptography) verify tokens issued by the provider.
- **Disabled unnecessary CSRF check on /token endpoint**
This caused login issues when using Catalyst::Plugin::CSRF configured with
auto_check => 1
## [0.13] - 2026-06-02 Fixed CPAN test failures
- Module::CPANfile added as required for configure phase
- Required Crypt::OpenSSL::RSA version changed to >= 0.38
## [0.10] - 2026-05-30 (Security Fixes: NEW-HIGH-1, NEW-HIGH-2, NEW-MED-1, NEW-MED-2, NEW-LOW-1)
CHANGELOG.md view on Meta::CPAN
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
CHANGELOG.md view on Meta::CPAN
- 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
CHANGELOG.md view on Meta::CPAN
- **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
CHANGELOG.md view on Meta::CPAN
- **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.
CHANGELOG.md view on Meta::CPAN
- **`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
CHANGELOG.md view on Meta::CPAN
`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
CHANGELOG.md view on Meta::CPAN
- 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
CHANGELOG.md view on Meta::CPAN
- 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
CHANGELOG.md view on Meta::CPAN
- 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
CHANGELOG.md view on Meta::CPAN
```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
DEPLOYMENT.md view on Meta::CPAN
}
__PACKAGE__->meta->make_immutable;
```
## Redis Store (FastCGI and Multi-Process Deployments)
The default in-process memory store keeps authorization codes in a Perl hash
inside each worker process. Under a **FastCGI** or any other pre-forking server
this means codes created in one worker are not visible to other workers, causing
random "invalid_grant" errors at the token endpoint.
The `Catalyst::Plugin::OpenIDConnect::Utils::Store::Redis` backend solves this
by storing codes in a shared Redis instance with automatic TTL expiry.
### Installing the Redis client
Install either `Redis::Fast` (recommended â XS-based, faster) or `Redis`:
```bash
cpanm Redis::Fast
DEPLOYMENT.md view on Meta::CPAN
log4perl.appender.FileError.layout = PatternLayout
log4perl.appender.FileError.layout.ConversionPattern = %d %p [%c] %m%n
log4perl.appender.FileError.Threshold = ERROR
</Log4perl>
```
### Key Metrics to Monitor
- Authorization request latency
- Token exchange time
- UserInfo endpoint response time
- Authorization code expiration rate
- Session creation/destruction rate
- Error rate by endpoint
- Token verification failures
- Failed authentication attempts
## Security Best Practices
### General
1. **Always use HTTPS** - All OIDC endpoints must be over HTTPS
2. **Validate redirect URIs** - Strict matching required
3. **Use POST for sensitive data** - Never pass secrets in URLs
4. **Implement rate limiting** - Prevent brute force attacks
5. **Log security events** - Track failed attempts, suspicious activity
6. **Regular key rotation** - Rotate keys annually
7. **Monitor for vulnerabilities** - Keep Perl and dependencies updated
### Key Management
1. **Rotate keys periodically** - At least annually
DEPLOYMENT.md view on Meta::CPAN
3. **CSRF protection** - Validate `state` parameter
4. **Nonce binding** - Prevent man-in-the-middle attacks
5. **Session timeout** - Clear old sessions regularly
### Client Authentication
1. **Use client secrets** - Not for public (JavaScript) clients
2. **PKCE for public clients** - RFC 7636 authorization code protection
3. **Validate redirect URIs** - Exact match required
4. **Limit client scope** - Principle of least privilege
5. **Client authentication at token endpoint** - Required
## Performance Optimization
### Caching
```perl
# Cache JWKS response (5 minutes)
sub jwks : Local {
my ($self, $c) = @_;
IMPLEMENTATION_GUIDE.md view on Meta::CPAN
- `create_authorization_code($client_id, $user, $scope, $redirect_uri, $nonce, $pkce)` - Creates short-lived auth codes; accepts an optional `$pkce` hashref with `code_challenge` and `code_challenge_method` fields
- `consume_authorization_code($code)` - Atomically fetches and deletes the code (one-step); returns the code data hashref or `undef` if not found or expired
**Features:**
- 10-minute authorization code expiration
- Atomic fetch-and-delete prevents TOCTOU races (in-memory uses `delete`; Redis uses `GETDEL`)
- PKCE `code_challenge` persisted with the code and returned by `consume_authorization_code`
#### 4. **Protocol Controller** (`Catalyst::Plugin::OpenIDConnect::Controller::Root`)
Implements the OpenID Connect protocol endpoints.
**Endpoints:**
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/.well-known/openid-configuration` | Discovery endpoint |
| GET | `/openidconnect/authorize` | Authorization endpoint |
| POST | `/openidconnect/token` | Token endpoint |
| GET | `/openidconnect/userinfo` | UserInfo endpoint |
| GET | `/openidconnect/jwks` | JSON Web Key Set |
| POST | `/openidconnect/logout` | Logout endpoint |
## OpenID Connect Flow Implementation
### Authorization Code Flow
The standard, most secure flow for web applications:
```
1. Client redirects user to /openidconnect/authorize with:
- response_type=code
IMPLEMENTATION_GUIDE.md view on Meta::CPAN
redirect_uris = http://app.example.com/callback
post_logout_redirect_uris = http://app.example.com/logged-out
response_types = code
grant_types = authorization_code refresh_token
scope = openid profile email
</my-client>
</clients>
```
**Fields:**
- `client_secret` - Shared secret for token endpoint
- `redirect_uris` - Arrayref or whitespace-separated string of URIs the client is permitted to redirect to after authorization
- `post_logout_redirect_uris` - Arrayref or whitespace-separated string of URIs the client is permitted to redirect to after logout. Required when the client uses `post_logout_redirect_uri` at the logout endpoint.
- `response_types` - Supported response types (e.g., "code")
- `grant_types` - Supported grant types (e.g., "authorization_code")
- `scope` - Default/allowed scopes
> Both `redirect_uris` and `post_logout_redirect_uris` accept the same formats:
> an arrayref in YAML/JSON/Perl-hash config, or a whitespace-separated string
> in Apache-style (`Config::General`) config. Both are matched by exact string
> comparison â prefix matching and host-only matching are not permitted.
### User Claims Mapping
IMPLEMENTATION_GUIDE.md view on Meta::CPAN
# Display login form
$c->stash->{template} = 'login.html';
}
```
The plugin will redirect to your login page like: `/login?back=/openidconnect/authorize`. After successful authentication, redirect back to the `back` URL to resume the authorization process.
## Security Considerations
### HTTPS Requirement
- In production, always use HTTPS for all OIDC endpoints
- Tokens are sensitive and must be transmitted over encrypted connections
### Key Management
- Store private keys securely (file permissions, secrets management)
- Rotate keys periodically
- Publish public keys via JWK Set endpoint
### Token Security
- ID tokens should be verified by clients using the public key
- Access tokens are bearer tokens - handle with care
- Refresh tokens should be stored securely (HTTP-only cookies)
### CSRF Protection
- Always verify the `state` parameter matches the session
- Nonce binding support (client responsibility to validate nonce matches)
IMPLEMENTATION_GUIDE.md view on Meta::CPAN
# Run the application
perl example/app.pl
# Visit http://localhost:3000
```
The example includes:
- Simple login page
- Protected resource
- Catalyst integration demo
- Fully working OIDC endpoints
## Database Integration
The current implementation uses in-memory storage. For production, extend the Store:
```perl
package MyApp::Store::OIDC;
use Moose;
extends 'Catalyst::Plugin::OpenIDConnect::Utils::Store';
IMPLEMENTATION_GUIDE.md view on Meta::CPAN
- OpenID Connect 1.0 Specification: https://openid.net/connect/
- OAuth 2.0 RFC 6749: https://tools.ietf.org/html/rfc6749
- JWT RFC 7519: https://tools.ietf.org/html/rfc7519
- JWA RFC 7518: https://tools.ietf.org/html/rfc7518
## Future Enhancements
- [ ] Implicit and Hybrid flows
- [ ] Form post response mode
- [ ] PKCE support
- [ ] Client registration endpoint
- [ ] Introspection endpoint
- [ ] Revocation endpoint
- [ ] DB-backed session store
- [ ] Multi-key support
- [ ] HS256 algorithm support
- [ ] Request object support
- [ ] Token endpoint authentication methods
- [ ] Subject type pairwise support
## License
This implementation is available under The Artistic License 2.0 (GPL Compatible). See LICENSE file for details.
## Author
Tim F. Rayner
QUICKSTART.md view on Meta::CPAN
curl http://localhost:5000/.well-known/openid-configuration
```
### Use in Your Own Client
See the example app (`example/app.pl`) for a complete implementation.
## Next Steps
1. **Read** [IMPLEMENTATION_GUIDE.md](IMPLEMENTATION_GUIDE.md) for architecture details
2. **Review** [API_REFERENCE.md](API_REFERENCE.md) for complete endpoint documentation
3. **Check** [DEPLOYMENT.md](DEPLOYMENT.md) for production setup
4. **Explore** `example/app.pl` for a working implementation
## Troubleshooting
### Keys not loading
```
Error: "Cannot read private key file"
```
- Check file path is correct
QUICKSTART.md view on Meta::CPAN
```
Error: "Unknown client"
```
- Verify client_id is in configuration
- Check spelling exactly matches
### CORS errors
```
XMLHttpRequest: No 'Access-Control-Allow-Origin' header
```
- This endpoint doesn't support CORS yet
- For now, the server must redirect rather than XHR request
## Need Help?
- Review the included documentation
- Check the example app for reference implementation
- Look at test files for usage examples
- See DEPLOYMENT.md for production setup
---
### Issuer Configuration
- `url`: The issuer URL (used as 'iss' claim in tokens)
- `private_key_file`: Path to RSA private key for signing tokens
- `public_key_file`: Path to RSA public key for verification (auto-derived from private key if not provided)
- `key_id`: Key identifier (used in JWT header)
### Client Configuration
- `client_id`: Unique client identifier
- `client_secret`: Client secret for token endpoint
- `redirect_uris`: Arrayref or whitespace-separated string of URIs the client is permitted to redirect to after authorization. At least one entry is required.
- `post_logout_redirect_uris`: Arrayref or whitespace-separated string of URIs the client is permitted to redirect to after logout. Required when the client will use `post_logout_redirect_uri` at the logout endpoint.
- `response_types`: Space-separated response types (e.g., "code" or "code id_token")
- `grant_types`: Space-separated grant types (e.g., "authorization_code refresh_token")
- `scope`: Space-separated list of scopes the client can request
### User Claims Mapping
Map from OpenID Connect claim names to user object attributes:
```
<user_claims>
```perl
my $new_tokens = $c->openidconnect->refresh_token(
client_id => 'client-id',
client_secret => 'client-secret',
refresh_token => 'refresh-token-value'
);
```
## Securing Endpoints
Use Catalyst roles and attributes to protect endpoints:
```perl
sub profile : Local : RequireUser {
my ( $self, $c ) = @_;
# User is authenticated, $c->user is available
}
```
## Advanced Topics
configure_requires 'ExtUtils::MakeMaker', '6.52';
configure_requires 'Module::CPANfile', '1.1';
requires 'Catalyst';
requires 'Catalyst::Runtime', '>= 5.90100';
requires 'Moose';
requires 'namespace::autoclean';
requires 'JSON::MaybeXS';
requires 'Crypt::OpenSSL::RSA', '>=0.38'; # for RSA signing/verification with JWT (openssl 3 support)
requires 'Crypt::PK::RSA'; # for JWK key parameter extraction in JWKS endpoint
requires 'Digest::SHA';
requires 'MIME::Base64';
requires 'DateTime';
requires 'DateTime::Format::ISO8601';
requires 'Config::General';
requires 'URI';
requires 'Try::Tiny';
requires 'Data::UUID';
requires 'Bytes::Random::Secure';
requires 'Crypt::Misc'; # for slow_eq() constant-time client secret comparison (HIGH-3)
example/root/index.html view on Meta::CPAN
<body>
<div class="container">
<h1>OpenID Connect Provider Example</h1>
<p>This is an example implementation of an OpenID Connect provider using Catalyst and the Catalyst::Plugin::OpenIDConnect.</p>
<div class="info">
<strong>About this provider:</strong><br>
- Issues OpenID Connect compliant ID tokens<br>
- Implements OAuth 2.0 authorization code flow<br>
- Provides user information via userinfo endpoint<br>
- Supports token refresh
</div>
<h2>Quick Links</h2>
<ul>
<li><a href="/.well-known/openid-configuration">.well-known/openid-configuration</a> - Discovery endpoint</li>
<li><a href="/openidconnect/jwks">/openidconnect/jwks</a> - JSON Web Key Set</li>
<li><a href="/protected">/protected</a> - Protected resource</li>
</ul>
<h2>Test OpenID Connect Flow</h2>
<p>To test the OpenID Connect flow, you can use the example client or create a test client.</p>
<div class="code">
GET /openidconnect/authorize?
response_type=code&
lib/Catalyst/Plugin/OpenIDConnect.pm view on Meta::CPAN
response_types => ['code'],
grant_types => ['authorization_code'],
scope => 'openid profile email',
},
},
},
);
=head1 CREATING THE OPENIDCONNECT CONTROLLER
To enable the OpenIDConnect endpoints, create a controller in your app that extends
the plugin's controller. Create the file C<lib/MyApp/Controller/OpenIDConnect.pm>
(where MyApp is your app's namespace) with the following content:
package MyApp::Controller::OpenIDConnect;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Plugin::OpenIDConnect::Controller::Root' }
lib/Catalyst/Plugin/OpenIDConnect.pm view on Meta::CPAN
# Load the controller before setup so Catalyst discovers it
use MyApp::Controller::OpenIDConnect;
MyApp->config(...);
MyApp->setup(...);
Setting up the controller in this way allows you to keep full control over your
routing, and avoid namespace conflicts with ACL and other route-processing plugins.
The plugin's controller will automatically mount the standard OpenID Connect
endpoints (e.g. C</authorize>, C</token>, C</userinfo>) under the C</openidconnect>
path, so you can access them at C</openidconnect/authorize>, etc.
=head1 ROUTES ADDED TO THE APPLICATION
The plugin's controller adds the following routes to the application:
GET /.well-known/openid-configuration
GET /openidconnect/authorize
POST /openidconnect/token
GET /openidconnect/userinfo
lib/Catalyst/Plugin/OpenIDConnect/Context.pm view on Meta::CPAN
=cut
sub get_discovery {
my ($self) = @_;
$self->catalyst->log->debug('Building OpenID Connect discovery document') if $self->config->{debug};
my $c = $self->catalyst;
my $issuer_url = $self->config->{issuer}{url} || $c->uri_for('/')->as_string;
# Extract scheme and authority from issuer URL to ensure endpoints match issuer scheme
my $base_url = $issuer_url;
$base_url =~ s{/$}{}; # Remove trailing slash if present
$self->catalyst->log->debug("Discovery document built for issuer: $issuer_url") if $self->config->{debug};
return {
issuer => $issuer_url,
authorization_endpoint => "$base_url/openidconnect/authorize",
token_endpoint => "$base_url/openidconnect/token",
userinfo_endpoint => "$base_url/openidconnect/userinfo",
jwks_uri => "$base_url/openidconnect/jwks",
end_session_endpoint => "$base_url/openidconnect/logout",
registration_endpoint => undef,
scopes_supported => [qw(openid profile email phone address)],
response_types_supported => [qw(code)],
response_modes_supported => [qw(query fragment form_post)],
grant_types_supported => [qw(authorization_code refresh_token)],
subject_types_supported => [qw(public pairwise)],
id_token_signing_alg_values_supported => ['RS256'],
userinfo_signing_alg_values_supported => ['RS256'],
request_parameter_supported => 1,
request_uri_parameter_supported => 1,
claims_supported => [
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
# Module-level UUID generator for refresh token JTI claims (MED-1).
my $_uuid = Data::UUID->new();
=head1 NAME
Catalyst::Plugin::OpenIDConnect::Controller::Root - OIDC Protocol Endpoints
=head1 SYNOPSIS
Handles OpenID Connect protocol endpoints:
/.well-known/openid-configuration - Discovery endpoint
/openidconnect/authorize - Authorization endpoint
/openidconnect/token - Token endpoint
/openidconnect/userinfo - UserInfo endpoint
/openidconnect/logout - Logout endpoint
/openidconnect/jwks - JWKS endpoint for key discovery
=cut
=head1 DESCRIPTION
This controller implements the core OpenID Connect protocol endpoints. To use it in your application, create a controller that extends this one:
package MyApp::Controller::Auth;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Plugin::OpenIDConnect::Controller::Root'; }
__PACKAGE__->meta->make_immutable;
=head1 METHODS
=head2 begin
Called automatically before every action in this controller. Sets HTTP
security headers that must be present on all OIDC endpoint responses.
=cut
sub begin : Private {
my ( $self, $c ) = @_;
# RFC 6749 §5.1 requires Cache-Control: no-store on token responses;
# applied globally so new endpoints can't accidentally omit it.
# Pragma: no-cache is the HTTP/1.0 equivalent.
$c->response->header( 'Cache-Control' => 'no-store' );
$c->response->header( 'Pragma' => 'no-cache' );
# Prevent MIME sniffing.
$c->response->header( 'X-Content-Type-Options' => 'nosniff' );
# Clickjacking protection on the authorize endpoint HTML page.
# Both headers are set for broadest browser compatibility (MED-6).
$c->response->header( 'X-Frame-Options' => 'DENY' );
$c->response->header( 'Content-Security-Policy' => "frame-ancestors 'none'" );
}
=head2 discovery
GET /.well-known/openid-configuration
Returns the OpenID Connect provider configuration.
=cut
sub discovery : Path('/.well-known/openid-configuration') {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('OpenID Connect discovery endpoint accessed') if $config->{debug};
$self->_json_response( $c, $c->openidconnect->get_discovery() );
}
=head2 authorize
GET /openidconnect/authorize
OpenID Connect authorization endpoint.
Query parameters:
- response_type (REQUIRED): "code"
- client_id (REQUIRED): The client ID
- redirect_uri (REQUIRED): Where to redirect after authorization
- scope (RECOMMENDED): Space-separated scopes (default: "openid")
- state (RECOMMENDED): CSRF protection state parameter
- nonce (OPTIONAL): String to bind to the ID token
=cut
sub authorize : Local {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('Authorization endpoint accessed') if $config->{debug};
my $response_type = $c->request->params->{response_type};
my $client_id = $c->request->params->{client_id};
my $redirect_uri = $c->request->params->{redirect_uri};
my $scope = $c->request->params->{scope};
my $state = $c->request->params->{state};
my $nonce = $c->request->params->{nonce};
my $code_challenge = $c->request->params->{code_challenge};
my $code_challenge_method = $c->request->params->{code_challenge_method};
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
}
# Extract user claims now, while the live user object is available.
# Storing the plain claims hashref (rather than the user object itself)
# means the store never needs to serialise application-specific objects
# such as DBIx::Class rows or LDAP entries â it always receives and
# returns plain data.
my $user_claims = $c->openidconnect->get_user_claims( $c->user );
# Create authorization code, passing any PKCE challenge so it is stored
# alongside the code and can be verified at the token endpoint.
my $pkce = $code_challenge
? { code_challenge => $code_challenge, code_challenge_method => 'S256' }
: undef;
my $code = $c->openidconnect->store->create_authorization_code(
$client_id, $user_claims, $scope, $redirect_uri, $nonce, $pkce
);
# Store authorization in session for later token request
$c->session->{oidc_code}->{$code} = {
client_id => $client_id,
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
);
$c->log->debug("Redirecting to: " . $callback_uri->as_string) if $config->{debug};
$c->response->redirect( $callback_uri->as_string );
}
=head2 token
POST /openidconnect/token
Token endpoint for exchanging authorization code for tokens.
Parameters (form-encoded):
- grant_type (REQUIRED): "authorization_code" or "refresh_token"
- code (REQUIRED for authorization_code): The authorization code
- redirect_uri (REQUIRED): Must match authorization request
- client_id (OPTIONAL): The client ID (extracted from code if not provided)
- client_secret (OPTIONAL): The client secret (required for confidential clients, optional for public clients)
Returns:
- access_token: The access token
- token_type: "Bearer"
- id_token: The ID token
- expires_in: Token expiration in seconds
- refresh_token: (optional) Refresh token
=cut
# Disable CSRF for compatibility with apps using Catalyst::Plugin::CSRF and auto_check => 1,
# which would otherwise reject POST requests to this endpoint without a valid CSRF token.
# The token endpoint is protected by client authentication and does not use cookies or sessions,
# so CSRF protection is not applicable.
sub token : Local DisableCSRF {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->response->content_type('application/json');
$c->log->debug('Token endpoint accessed') if $config->{debug};
my $grant_type = $c->request->params->{grant_type};
unless ($grant_type) {
$c->log->warn('Missing grant_type parameter');
return $self->_json_error( $c, 'invalid_request', 'grant_type is required' );
}
$c->log->debug("Token request with grant_type: $grant_type") if $config->{debug};
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
$c->log->warn("Unsupported grant_type: $grant_type");
return $self->_json_error( $c, 'unsupported_grant_type', "Unsupported grant_type: $grant_type" );
}
}
=head2 userinfo
GET /openidconnect/userinfo
Authorization: Bearer <access_token>
UserInfo endpoint returning authenticated user's claims.
=cut
sub userinfo : Local {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('UserInfo endpoint accessed') if $config->{debug};
# Get bearer token
my $auth_header = $c->request->header('Authorization') || '';
my ($token) = $auth_header =~ /^Bearer\s+(\S+)$/;
unless ($token) {
$c->log->warn('Missing or invalid Authorization header');
return $self->_json_error( $c, 'invalid_token', 'Missing or invalid Authorization header' );
}
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
}
$c->log->debug('UserInfo response prepared') if $config->{debug};
$self->_json_response( $c, \%claims );
}
=head2 logout
POST /openidconnect/logout
Logout endpoint to invalidate tokens and clear sessions.
Implements OpenID Connect RP-Initiated Logout 1.0.
Parameters:
- id_token_hint (REQUIRED when post_logout_redirect_uri is supplied): A
previously issued ID Token identifying the client requesting logout.
The token's signature is verified to confirm it was issued by this server.
Expiry is intentionally not checked; hint tokens are often expired.
- post_logout_redirect_uri (OPTIONAL): URL to redirect to after logout.
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
- state (OPTIONAL): Opaque value returned verbatim in the redirect query
string (only when post_logout_redirect_uri is also provided).
=cut
sub logout : Local {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('Logout endpoint accessed') if $config->{debug};
my $redirect_uri = $c->request->params->{post_logout_redirect_uri};
my $id_token_hint = $c->request->params->{id_token_hint};
my $state = $c->request->params->{state};
# Decode the hint early â before the session is destroyed â so that we
# have the subject identifier available for refresh token revocation (MED-1).
my $hint_claims;
if ($id_token_hint) {
$hint_claims = $c->openidconnect->jwt->decode_id_token_hint($id_token_hint);
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
sub _normalize_uri_list {
my ($field) = @_;
return () unless defined $field;
return ref $field eq 'ARRAY' ? @$field : split /\s+/, $field;
}
=head2 jwks
GET /openidconnect/jwks
JSON Web Key Set endpoint for key discovery.
Returns the public key(s) for verifying signatures.
=cut
sub jwks : Local {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('JWKS endpoint accessed') if $config->{debug};
# Get JWT handler and public key
my $jwt = $c->openidconnect->jwt;
my $public_key = $jwt->public_key;
$c->log->debug('Extracting public key parameters for JWKS') if $config->{debug};
# Convert OpenSSL public key to Crypt::PK::RSA for easier parameter extraction
my $public_key_pem = $public_key->get_public_key_string();
my $pk = Crypt::PK::RSA->new(\$public_key_pem);
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
# Use client_id from authorization code if not provided in request (public client flow)
$client_id ||= $code_data->{client_id};
# Enforce that the client presenting the token request is the same client
# the authorization code was issued to (RFC 6749 §4.1.3, NEW-HIGH-2).
# Without this check a confidential client that obtains another client's
# code could redeem it by authenticating with its own valid secret.
if ( $client_id ne $code_data->{client_id} ) {
$c->log->warn(
"client_id mismatch at token endpoint: "
. "request=$client_id stored=$code_data->{client_id}"
);
return $self->_json_error( $c, 'invalid_grant',
'client_id does not match the authorization code' );
}
# Verify redirect URI matches
unless ( $code_data->{redirect_uri} eq $redirect_uri ) {
$c->log->error("Redirect URI mismatch for code: $code (expected: " . $code_data->{redirect_uri} . ", got: $redirect_uri)");
return $self->_json_error( $c, 'invalid_grant', 'Redirect URI mismatch' );
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
aud => $client_id,
scp => $code_data->{scope},
typ => 'at+JWT', # RFC 9068 â distinguishes access tokens from ID/refresh tokens (NEW-MED-1)
exp => $now + 3600,
);
my $access_token = $c->openidconnect->jwt->create_access_token(%access_token_payload);
$c->log->debug('Access token created') if $config->{debug};
# Issue a refresh token with a unique JTI and register the JTI in the
# store so the token endpoint can enforce single-use semantics (MED-1).
my $rt_jti = $_uuid->create_str();
my $rt_ttl = 30 * 24 * 3600; # 30 days
my %refresh_token_payload = (
sub => $user_claims->{sub},
aud => $client_id,
jti => $rt_jti,
exp => $now + $rt_ttl,
);
my $refresh_token = $c->openidconnect->jwt->create_refresh_token(%refresh_token_payload);
lib/Catalyst/Plugin/OpenIDConnect/Role/Store.pm view on Meta::CPAN
C<$user_data> must be a plain (unblessed) hashref of the user's OIDC claims,
as returned by C<get_user_claims()>. Callers are responsible for extracting
claims from the live user object before calling this method; doing so here
(rather than in the store) ensures that any application-specific user object
(DBIx::Class row, LDAP entry, etc.) is resolved while the Catalyst context
is still available, and that the store only ever handles plain serialisable data.
C<$pkce> is an optional hashref with keys C<code_challenge> and
C<code_challenge_method>. When supplied the values are stored alongside the
code so that the token endpoint can verify the RFC 7636 PKCE proof. Omit or
pass C<undef> for flows that do not use PKCE.
Returns the authorization code string.
=cut
requires 'create_authorization_code';
=head2 get_authorization_code($code)
lib/Catalyst/Plugin/OpenIDConnect/Role/Store.pm view on Meta::CPAN
Returns a hashref containing at minimum: C<client_id>, C<user>, C<scope>,
C<redirect_uri>, C<nonce>, C<created_at>, C<expires_at>.
=cut
requires 'get_authorization_code';
=head2 consume_authorization_code($code)
Atomically removes an authorization code and returns its data. This is the
primary method the token endpoint must use to redeem a code; it enforces
single-use semantics without a TOCTOU race condition.
Returns the code data hashref (same structure as C<get_authorization_code>)
on success, or C<undef> if the code does not exist, has already been consumed,
or has expired.
B<Important:> Callers must not call C<get_authorization_code> followed by
C<consume_authorization_code>. Use C<consume_authorization_code> alone.
=cut
requires 'consume_authorization_code';
=head2 store_refresh_token($jti, $sub, $client_id, $ttl)
Stores a refresh token identifier (JTI) with associated metadata and a TTL
(in seconds). Called at token-issuance time so that the token endpoint can
later verify the token has not been used or revoked.
=cut
requires 'store_refresh_token';
=head2 consume_refresh_token($jti)
Atomically checks that the JTI exists in the store and removes it. Returns a
hashref containing at minimum C<sub> and C<client_id> on success, or C<undef>
lib/Catalyst/Plugin/OpenIDConnect/Utils/Store.pm view on Meta::CPAN
return;
}
$self->logger->debug("Authorization code consumed: $code") if $self->logger;
return $code_data;
}
=head2 store_refresh_token($jti, $sub, $client_id, $ttl)
Stores a refresh token JTI with the associated subject, client, and a TTL in
seconds. Called at token-issuance time so that the token endpoint can later
enforce single-use semantics via L</consume_refresh_token>.
=cut
sub store_refresh_token {
my ( $self, $jti, $sub, $client_id, $ttl ) = @_;
$self->_refresh_tokens->{$jti} = {
sub => $sub,
client_id => $client_id,
exp => time() + $ttl,
t/03_plugin.t view on Meta::CPAN
my $default_claims = $context_default->get_user_claims($default_user);
ok($default_claims, 'get_user_claims() with default config');
is($default_claims->{sub}, '789', 'default sub claim');
is($default_claims->{name}, 'Bob Smith', 'default name claim');
is($default_claims->{email}, 'bob@example.com', 'default email claim');
# Test get_discovery() method
my $discovery = $context->get_discovery();
ok($discovery, 'get_discovery() returns structure');
is($discovery->{issuer}, 'http://localhost:5000', 'issuer in discovery');
like($discovery->{authorization_endpoint}, qr/authorize/, 'authorization_endpoint');
like($discovery->{token_endpoint}, qr/token/, 'token_endpoint');
like($discovery->{userinfo_endpoint}, qr/userinfo/, 'userinfo_endpoint');
like($discovery->{jwks_uri}, qr/jwks/, 'jwks_uri');
# Check scopes
my @scopes = @{ $discovery->{scopes_supported} };
ok(grep { $_ eq 'openid' } @scopes, 'openid scope supported');
ok(grep { $_ eq 'profile' } @scopes, 'profile scope supported');
ok(grep { $_ eq 'email' } @scopes, 'email scope supported');
# Check algorithms
is_deeply(
t/04_store_redis.t view on Meta::CPAN
my $data = $store->get_authorization_code($code);
ok( $data, 'get_authorization_code returns data' );
is( $data->{client_id}, 'test-client', 'client_id matches' );
is( $data->{scope}, 'openid profile email', 'scope matches' );
is( $data->{redirect_uri}, 'http://localhost:3000/callback', 'redirect_uri matches' );
is( $data->{nonce}, 'nonce-abc', 'nonce matches' );
ok( $data->{created_at}, 'created_at is set' );
ok( $data->{expires_at}, 'expires_at is set' );
# The user claims must survive the JSON round-trip intact so that the token
# endpoint can use them directly to build the ID/access tokens.
ok( ref($data->{user}) eq 'HASH',
'user claims are a plain hashref after Redis round-trip' );
is( $data->{user}{sub}, 'user-123', 'user.sub preserved through Redis' );
is( $data->{user}{name}, 'Test User', 'user.name preserved through Redis' );
is( $data->{user}{email}, 't@example.com', 'user.email preserved through Redis' );
is( $store->get_authorization_code('nonexistent'), undef,
'get_authorization_code returns undef for unknown code' );
# ---------------------------------------------------------------------------
t/07_security_headers.t view on Meta::CPAN
'begin sets Cache-Control: no-store' );
# HTTP/1.0 compatibility header required alongside Cache-Control
is( $headers->{'pragma'}, 'no-cache',
'begin sets Pragma: no-cache' );
# Prevent MIME-type sniffing (MED-6)
is( $headers->{'x-content-type-options'}, 'nosniff',
'begin sets X-Content-Type-Options: nosniff' );
# Clickjacking protection on the authorize endpoint (MED-6)
is( $headers->{'x-frame-options'}, 'DENY',
'begin sets X-Frame-Options: DENY' );
# Modern CSP-based clickjacking protection (complements X-Frame-Options)
is( $headers->{'content-security-policy'}, "frame-ancestors 'none'",
"begin sets Content-Security-Policy: frame-ancestors 'none'" );
# Confirm all five expected headers are present (no extras silently swallowing them)
is( scalar( grep { defined $headers->{$_} }
qw( cache-control pragma x-content-type-options