Catalyst-Plugin-OpenIDConnect
view release on metacpan or search on metacpan
IMPLEMENTATION_GUIDE.md view on Meta::CPAN
â âââ State Store (Utils/Store.pm)
â âââ Controllers
â âââ Root.pm (Protocol Endpoints)
```
### Core Components
#### 1. **Main Plugin Module** (`Catalyst::Plugin::OpenIDConnect`)
A Moose role that integrates OIDC into Catalyst applications.
**Key Methods:**
- `openidconnect()` - Returns OIDC context for use in controllers
- `_oidc_build_jwt_handler()` - Configures JWT signing/verification
- `setup_component()` - Initializes the plugin
- `finalize_setup()` - Finalizes setup after bootstrapping
**Features:**
- Automatic key loading from configuration
- Public key derivation from private key
- Configurable per-Catalyst-app
- Non-intrusive Moose role architecture
#### 2. **JWT Utility Module** (`Catalyst::Plugin::OpenIDConnect::Utils::JWT`)
Handles JSON Web Token (JWT) operations using RS256 (RSA SHA-256).
**Key Methods:**
- `sign_token(%payload)` - Signs a JWT with the private key
- `verify_token($token)` - Verifies and decodes a JWT
- `create_id_token(%claims)` - Creates an ID token
- `create_access_token(%claims)` - Creates an access token
- `create_refresh_token(%claims)` - Creates a refresh token
**Features:**
- RS256 algorithm (RSA + SHA256)
- RFC 4648 URL-safe Base64 encoding
- Automatic expiration validation
- Standard claims management (iss, aud, exp, iat)
- Signature verification
#### 3. **State Store Module** (`Catalyst::Plugin::OpenIDConnect::Utils::Store`)
In-memory storage for authorization codes, sessions, and tokens.
**Current Implementation:**
- In-memory Perl hashes
- Can be extended for database backends
**Key Methods:**
- `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
- client_id
- redirect_uri
- scope
- state (CSRF protection)
- nonce (optional, binds to session)
- code_challenge (required for public clients; recommended for all â see PKCE below)
- code_challenge_method=S256 (required when code_challenge is supplied)
2. User authenticates (handled by application)
3. Server issues authorization code via redirect
4. Client exchanges code for tokens at /openidconnect/token with:
- grant_type=authorization_code
- code
- redirect_uri
- client_id
- client_secret (omit for public clients)
- code_verifier (required when code_challenge was sent in step 1)
5. Server verifies and issues:
- id_token (JWT with user claims)
- access_token (JWT for API access)
- refresh_token (JWT for token refresh)
- expires_in
```
### Token Types
#### ID Token
- Contains user identity claims (name, email, etc.)
- Signed JWT (RS256)
- Expires in 1 hour
- Includes nonce (if provided) for CSRF protection
#### Access Token
- Authorization token for API access
- Signed JWT
- Expires in 1 hour
- Contains scope information
#### Refresh Token
- Long-lived token for refreshing access tokens
- Signed JWT
- Expires in 30 days
- Not accessible to browser (HTTP-only cookies in production)
## Configuration
### Issuer Configuration
```perl
<Plugin::OpenIDConnect>
<issuer>
url = http://localhost:5000
private_key_file = /path/to/private.pem
public_key_file = /path/to/public.pem
key_id = my-key-123
</issuer>
```
**Fields:**
- `url` - The issuer identifier (in iss claim)
- `private_key_file` - Path to RSA private key (PEM format)
- `public_key_file` - Path to RSA public key (optional, derived from private)
- `key_id` - Key identifier for JWK Set
### Client Configuration
```perl
<clients>
<my-client>
client_secret = secret123
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
Map user object attributes to OpenID Connect claims:
```perl
<user_claims>
sub = id
name = full_name
email = email_address
picture = avatar_url
email_verified = is_email_verified
</user_claims>
```
The format is: `<oidc_claim> = <user_attribute_path>`
Nested attributes use dot notation: `claims = user.profile.claims`
## Standard OpenID Connect Claims
The plugin supports the following standard claims:
**Profile Claims:**
- `sub` - Subject (unique user 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
- `picture` - Picture/avatar URL
- `website` - Website URL
- `gender` - Gender
- `birthdate` - Birth date (YYYY-MM-DD)
- `zoneinfo` - Timezone (IANA tz identifier)
- `locale` - Locale/language
- `updated_at` - Profile update time (Unix timestamp)
**Email Claims:**
- `email` - Email address
- `email_verified` - Whether email is verified (boolean)
**Phone Claims:**
- `phone_number` - Phone number (E.164 format)
- `phone_number_verified` - Whether phone is verified (boolean)
**Address Claims:**
- `address` - Physical address (JSON object with formatted, street_address, etc.)
## Extension Points
IMPLEMENTATION_GUIDE.md view on Meta::CPAN
From any controller:
```perl
my $oidc = $c->openidconnect;
# Get client config
my $client = $oidc->get_client('client-id');
# Get user claims
my $claims = $oidc->get_user_claims($user);
# Access JWT handler
my $token = $oidc->jwt->sign_token(sub => 'user-123');
# Access store
my $code = $oidc->store->create_authorization_code(...);
```
### Implementing the Login Action
When the OpenID Connect plugin redirects an unauthenticated user to your login page, it includes a `back` parameter specifying where to return after successful authentication. Your login action **must support the `back` parameter** to resume the auth...
```perl
sub login : Local {
my ( $self, $c ) = @_;
if ( $c->request->method eq 'POST' ) {
my $username = $c->request->params->{username};
my $password = $c->request->params->{password};
# Validate credentials against your user store
if ( validate_user($username, $password) ) {
my $user = get_user($username);
# Store user in session
$c->session->{user} = $user;
$c->session->{user_id} = $user->id;
# IMPORTANT: Redirect to the 'back' parameter if provided
# This resumes the authorization flow after authentication.
# Validate it to prevent open redirect (only allow relative paths).
my $back = $c->request->params->{back} || '/';
$back = '/' unless $back =~ m{^/[^/]};
return $c->response->redirect( $c->uri_for($back) );
}
$c->stash->{error} = 'Invalid credentials';
}
# 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)
### Code Security
- Authorization codes are one-time use only
- Codes expire after 10 minutes
- Code exchange requires client authentication
- Atomic fetch-and-delete in the store layer prevents TOCTOU race conditions
### PKCE (Proof Key for Code Exchange â RFC 7636)
- Public clients (those without a registered `client_secret`) **must** send `code_challenge` and `code_challenge_method=S256` in the authorization request
- Confidential clients are also strongly encouraged to use PKCE
- Only the `S256` method is supported; `plain` is rejected per OAuth 2.1 / security BCP
- The verifier must be 43â128 characters using only unreserved URI characters (`A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`)
- The server verifies `BASE64URL(SHA256(ASCII(code_verifier))) == code_challenge` with a constant-time comparison before issuing tokens
## Testing
Run the included tests:
```bash
# JWT functionality
prove -l t/01_jwt.t
# Store functionality
prove -l t/02_store.t
# All tests
prove -l t/
```
## Example Application
Start the example app:
```bash
# Generate RSA keys
bash example/generate_keys.sh
# 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';
sub create_authorization_code {
my ($self, $client_id, $user, $scope, $redirect_uri, $nonce) = @_;
# Store in database instead of memory
my $code = $self->_generate_code();
$c->model('DB::AuthCode')->create({
code => $code,
client_id => $client_id,
user_id => $user->id,
# ...
});
return $code;
}
```
## Troubleshooting
### Keys not loading
- Verify file paths in configuration
- Check file permissions (should be readable)
- Ensure PEM format (-----BEGIN RSA PRIVATE KEY-----)
### Token verification fails
- Check that public/private keys are paired
- Verify issuer URL matches configuration
- Check token expiration
### Code not found
- Authorization codes are one-time use
- Codes expire after 10 minutes
- Verify code value is correct (typos)
### CSRF errors
- Always include `state` parameter
- Verify state value matches session
- Check redirect_uri matches registered URI
## References
- 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
( run in 1.621 second using v1.01-cache-2.11-cpan-9581c071862 )