Catalyst-Plugin-OpenIDConnect

 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

---

README.md  view on Meta::CPAN

### 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>

README.md  view on Meta::CPAN

```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

cpanfile  view on Meta::CPAN

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



( run in 0.771 second using v1.01-cache-2.11-cpan-9581c071862 )