Catalyst-Plugin-OpenIDConnect
view release on metacpan or search on metacpan
DEPLOYMENT.md view on Meta::CPAN
## Database Integration
### PostgreSQL Example
```perl
package MyApp::Model::OIDC;
use Moose;
extends 'Catalyst::Plugin::OpenIDConnect::Utils::Store';
has dbic => (
is => 'ro',
isa => 'Catalyst::Model::DBIC::Schema',
);
sub create_authorization_code {
my ($self, $client_id, $user, $scope, $redirect_uri, $nonce) = @_;
my $code = $self->_generate_secure_code();
$self->dbic->resultset('AuthCode')->create({
code => $code,
client_id => $client_id,
user_id => $user->id,
scope => $scope,
redirect_uri => $redirect_uri,
nonce => $nonce,
created_at => DateTime->now,
expires_at => DateTime->now->add(minutes => 10),
});
return $code;
}
sub get_authorization_code {
my ($self, $code) = @_;
my $code_row = $self->dbic->resultset('AuthCode')->find({ code => $code });
return unless $code_row;
# Check expiration
return if DateTime->now > $code_row->expires_at;
return {
client_id => $code_row->client_id,
user => $code_row->user,
scope => $code_row->scope,
redirect_uri => $code_row->redirect_uri,
nonce => $code_row->nonce,
};
}
__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
# or
cpanm Redis
```
The store will use whichever is installed, preferring `Redis::Fast`.
### Configuring the Redis store
Add `store_class` and `store_args` to your `Plugin::OpenIDConnect` config block.
**`catalyst.conf` (Apache-style):**
```
<Plugin::OpenIDConnect>
store_class = Catalyst::Plugin::OpenIDConnect::Utils::Store::Redis
<store_args>
server = 127.0.0.1:6379
prefix = myapp:oidc:code:
code_ttl = 600
# password = <redis-auth-password> # omit if no AUTH required
</store_args>
<issuer>
url = https://auth.example.com
private_key_file = /secure/path/private.pem
public_key_file = /secure/path/public.pem
key_id = prod-key-2024-01
</issuer>
...
</Plugin::OpenIDConnect>
```
**Perl hash config (e.g. `MyApp.pm`):**
```perl
__PACKAGE__->config(
'Plugin::OpenIDConnect' => {
store_class => 'Catalyst::Plugin::OpenIDConnect::Utils::Store::Redis',
store_args => {
server => $ENV{REDIS_URL} // '127.0.0.1:6379',
prefix => 'myapp:oidc:code:',
code_ttl => 600,
# password => $ENV{REDIS_PASSWORD},
},
issuer => { ... },
...
},
);
```
DEPLOYMENT.md view on Meta::CPAN
via your secrets manager. Reference it from your app config:
```perl
store_args => {
server => $ENV{REDIS_URL} // '127.0.0.1:6379',
password => $ENV{REDIS_PASSWORD} // undef,
prefix => 'myapp:oidc:code:',
},
```
## Monitoring
### Health Check Endpoint
```perl
sub health : Local : ActionClass('RenderView') {
my ($self, $c) = @_;
# Check database connectivity
try {
$c->model('DB')->schema->dbh->ping or die 'DB not responding';
}
catch {
$c->response->status(503);
$c->stash->{json} = { status => 'error', message => $_ };
return;
};
$c->response->status(200);
$c->stash->{json} = {
status => 'healthy',
timestamp => scalar gmtime(),
version => $VERSION,
};
}
```
### Logging Configuration
```perl
<Log4perl>
log4perl.rootLogger = DEBUG, FileApp, FileError
log4perl.appender.FileApp = Log::Log4perl::Appender::File
log4perl.appender.FileApp.filename = /var/log/oidc/app.log
log4perl.appender.FileApp.layout = PatternLayout
log4perl.appender.FileApp.layout.ConversionPattern = %d %p [%c] %m%n
log4perl.appender.FileError = Log::Log4perl::Appender::File
log4perl.appender.FileError.filename = /var/log/oidc/error.log
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
2. **Keep private keys secure** - Restrict file permissions (600)
3. **Don't hardcode secrets** - Use environment variables or secure vaults
4. **Use key IDs** - Allow key rotation without breaking clients
5. **Publish public keys via JWKS** - Don't require manual download
### Session Management
1. **Use secure cookies** - Set `Secure`, `HttpOnly`, `SameSite` flags
2. **Short-lived sessions** - Default to 30 minutes
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) = @_;
$c->response->header('Cache-Control' => 'public, max-age=300');
# ... return JWKS
}
# Cache discovery (1 hour)
sub discovery : Path('/.well-known/openid-configuration') {
my ($self, $c) = @_;
$c->response->header('Cache-Control' => 'public, max-age=3600');
# ... return discovery
}
```
### Database Indexes
```sql
CREATE INDEX idx_auth_code_code ON auth_codes(code);
CREATE INDEX idx_auth_code_expires ON auth_codes(expires_at);
CREATE INDEX idx_session_user ON sessions(user_id);
CREATE INDEX idx_session_created ON sessions(created_at);
```
### Connection Pooling
```perl
<Model::DB>
<connect_info>
<0>
dbi:Pg:dbname=oidc;host=localhost
</0>
<1>
postgres
</1>
<2>
password
</2>
<3>
{
AutoCommit = 1
RaiseError = 1
PrintError = 0
pg_enable_utf8 = 1
}
</3>
</connect_info>
<storage>
<0>pg
<1></1>
<2>
{
( run in 0.848 second using v1.01-cache-2.11-cpan-9581c071862 )