Catalyst::Plugin::OpenIDConnect

A Catalyst plugin implementing the OpenID Connect specification. This plugin provides OAuth 2.0 authentication and authorization capabilities with full OIDC compliance.

Note that this is not an OIDC client plugin. If that is what you seek, please take a look at the Catalyst::Plugin::OIDC module maintained elsewhere.

Full disclosure: this plugin has been written with the aid of Claude Haiku 4.5. A human has been included in the loop throughout, closely monitoring the agent outputs, but this is an early release and mistakes may have crept through. Please create an issue on Github if you find any errors. Thank you!

Features

Installation

Install via cpanm:

cpanm Catalyst::Plugin::OpenIDConnect

Or add to your cpanfile:

requires 'Catalyst::Plugin::OpenIDConnect';

Quick Start

1. Add plugin to your Catalyst app

package MyApp;
use Catalyst qw/
    -Debug
    OpenIDConnect
    Session
    Session::Store::File
    Session::State::Cookie
/;

2. Create the OpenIDConnect controller

The plugin requires you to create a controller that extends the plugin's controller. Create lib/MyApp/Controller/OpenIDConnect.pm:

package MyApp::Controller::OpenIDConnect;

use Moose;
use namespace::autoclean;

BEGIN { extends 'Catalyst::Plugin::OpenIDConnect::Controller::Root' }

__PACKAGE__->meta->make_immutable;

1;

Then load it in your main app module before setup:

package MyApp;
use Catalyst qw/
    -Debug
    OpenIDConnect
    Session
    Session::Store::File
    Session::State::Cookie
/;

# Load the controller before setup
use MyApp::Controller::OpenIDConnect;

3. Configure in your catalyst.conf

<Plugin::OpenIDConnect>
    <issuer>
        url = http://localhost:5000
        private_key_file = /path/to/private_key.pem
        public_key_file = /path/to/public_key.pem
        key_id = my-key-123
    </issuer>
    
    <clients>
        <MyClient>
            client_id = my-client-id
            client_secret = my-client-secret
            redirect_uris = http://localhost:3000/callback
            post_logout_redirect_uris = http://localhost:3000/logged-out
            response_types = code
            grant_types = authorization_code refresh_token
            scope = openid profile email
        </MyClient>
    </clients>
    
    <user_claims>
        sub = user.id
        username = user.username
        name = user.name
        email = user.email
        picture = user.avatar_url
    </user_claims>
</Plugin::OpenIDConnect>

4. Implement a login action

Your app must have a login action that supports the back parameter. When a user is not authenticated, the plugin redirects to your login page with a back parameter indicating where to return:

package MyApp::Controller::Auth;
use Moose;
use namespace::autoclean;

BEGIN { extends 'Catalyst::Controller'; }

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
        if ( validate_credentials($username, $password) ) {
            my $user = get_user($username);
            $c->session->{user} = $user;

            # IMPORTANT: Redirect to 'back' parameter to resume OIDC flow
            my $back = $c->request->params->{back} || '/';
            return $c->response->redirect($back);
        }

        $c->stash->{error} = 'Invalid credentials';
    }

    $c->stash->{template} = 'login.html';
}

1;

5. Use in your controllers

package MyApp::Controller::Protected;
use Moose;
use namespace::autoclean;

BEGIN { extends 'Catalyst::Controller'; }

sub profile : Local {
    my ( $self, $c ) = @_;
    
    # Check if user is authenticated via OIDC
    unless ( $c->user ) {
        $c->response->redirect( $c->uri_for('/openidconnect/authorize') );
        return;
    }
    
    $c->stash->{user} = $c->user;
}

1;

API Endpoints

Authorization Endpoint

GET /openidconnect/authorize

Parameters:

Token Endpoint

POST /openidconnect/token
Content-Type: application/x-www-form-urlencoded

Parameters:

UserInfo Endpoint

GET /openidconnect/userinfo
Authorization: Bearer <access_token>

Returns:

{
  "sub": "user-id",
  "name": "User Name",
  "email": "user@example.com",
  "picture": "https://example.com/avatar.jpg"
}

Discovery Endpoint

GET /.well-known/openid-configuration

Returns the OpenID Connect provider configuration in JSON format.

Configuration Reference

Issuer Configuration

Client Configuration

User Claims Mapping

Map from OpenID Connect claim names to user object attributes:

<user_claims>
    sub = user.id
    name = user.display_name
    email = user.email_address
    email_verified = user.email_confirmed
    phone_number = user.phone
</user_claims>

Standard Claims

Supported OpenID Connect standard claims:

Token Refresh

To refresh an access token:

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:

sub profile : Local : RequireUser {
    my ( $self, $c ) = @_;
    # User is authenticated, $c->user is available
}

Advanced Topics

Custom Scope Handling

Implement a custom scope handler:

$c->openidconnect->scope_handler(sub {
    my ($c, $scope_string) = @_;
    # Custom scope validation/processing
});

Custom Claims Provider

Provide custom user claims:

$c->openidconnect->claims_provider(sub {
    my ($c, $user) = @_;
    return {
        sub => $user->id,
        name => $user->full_name,
        custom_claim => $user->some_attribute,
    };
});

Testing

Run tests with:

prove -l t/

License

This module is available under The Artistic License 2.0 (GPL Compatible). See LICENSE file for details.

Author

Tim F. Rayner