NAME
Plack::Middleware::OIDC - OpenID Connect integration for PSGI/Plack applications
SYNOPSIS
use Plack::Builder;
my $oidc_config = {
default_provider => 'my_provider',
authentication_error_path => '/error',
provider => {
my_provider => {
id => 'my-app-id',
secret => 'xxxxxxxxx',
well_known_url => 'https://yourprovider.com/.well-known/openid-configuration',
signin_redirect_path => '/oidc/login/callback',
logout_redirect_path => '/oidc/logout/callback',
scope => 'openid profile roles email',
},
},
};
builder {
enable 'Session';
enable 'OIDC', config => $oidc_config;
$app;
};
# in the wrapped application :
sub {
my $env = shift;
if (my $identity = $env->{oidc}->get_valid_identity()) {
# authenticated : $identity->subject, $identity->claims, ...
}
else {
# throws an internal exception which Plack::Middleware::OIDC
# catches and turns into a real 302 response
$env->{oidc}->redirect_to_authorize();
}
};
DESCRIPTION
This middleware makes it easy to integrate the OpenID Connect protocol into a PSGI/Plack application. It enables applications to act as an OpenID Connect Relying Party (RP) for user authentication and as an OAuth 2.0 Resource Server by validating Access Tokens issued by an external Authorization Server or OpenID Provider. It does not implement an OpenID Provider.
It is built on the OIDC::Client distribution and is the PSGI/Plack counterpart of Mojolicious::Plugin::OIDC, Catalyst::Plugin::OIDC and Dancer2::Plugin::OIDC. It is meant for applications that do not use one of these frameworks (hand-written PSGI applications, ...). If you use one of the three frameworks above, prefer its dedicated plugin, which is better integrated.
This documentation focuses on what is specific to Plack. The methods available on the objects exposed to your application are documented in OIDC::Client::Plugin, and the configuration entries in OIDC::Client::Config.
HOW IT WORKS
Configuration
Plack has no built-in configuration mechanism, so the configuration is not looked up in a file: the hashref (same structure as for the other plugins, see OIDC::Client::Config) is passed explicitly to enable, under the config key. How you load it (Config::Any, YAML, JSON, environment variables, a plain hash in app.psgi...) is up to you.
Routing
At startup, for each configured provider, the middleware reads the signin_redirect_path and logout_redirect_path configuration entries and handles the matching requests itself, without passing them on to your application. There is nothing to declare in your application for these two callback routes.
These paths are compared with PATH_INFO, so they are relative to the point where the application is mounted (for example under Plack::App::URLMap). The redirect_uri sent to the provider is built from the base URL of the request, which includes SCRIPT_NAME, so both stay consistent.
Two providers cannot use the same callback path: this is detected at startup and raises an exception.
Access to the OIDC::Client::Plugin instances
For every other request, the middleware creates one OIDC::Client::Plugin instance per configured provider and exposes them to the downstream application through $env :
$env->{oidc}-
Instance for the default provider (see "Default provider").
$env->{"oidc.$provider_name"}-
Instance dedicated to the provider named
$provider_name. When$provider_nameis the default provider, it is the very same object as$env->{oidc}.
This is the equivalent of the $c->oidc and $c->oidc('my_provider') helpers of the other plugins, and the key was deliberately named oidc to stay aligned with them. The instances are created on every request (no I/O is involved, they only wrap the request, the session and the already built OIDC::Client objects).
Default provider
If only one provider is configured, it is the default one. Otherwise, the default_provider configuration entry is used. If several providers are configured and default_provider is not set, $env->{oidc} is not populated and only the $env->{"oidc.$provider_name"} keys are available.
Redirects from application code
Methods like redirect_to_authorize() and redirect_to_logout() of OIDC::Client::Plugin are meant to redirect the browser immediately. A PSGI application has no mutable response object that application code could alter at any point, so these methods throw an internal Plack::Middleware::OIDC::Redirect exception instead. The middleware catches it around the call to your application and turns it into a real 302 response. From your application's point of view, calling one of these methods therefore ends the request with a redirect, as with the other plugins.
This means these methods must not be called inside an eval or try block that would swallow the exception without rethrowing it. See also "LIMITATIONS".
If your application is built on a framework that catches the exceptions thrown by your code, the redirection only reaches this middleware if the framework rethrows the exceptions implementing an as_psgi() method (a convention also used by Plack::Middleware::HTTPExceptions). The exception implements it : see "as_psgi()" in Plack::Middleware::OIDC::Redirect.
Session and storage modes
By default (store_mode set to session, see OIDC::Client::Config), the tokens, the identity and the transient data of the authorization flow (state, nonce, PKCE code verifier) are stored in $env->{'psgix.session'}. A session middleware such as Plack::Middleware::Session must therefore be enabled before Plack::Middleware::OIDC :
builder {
enable 'Session';
enable 'OIDC', config => $oidc_config;
$app;
};
If no session is found, an exception is raised.
With the stash storage mode, nothing is kept between requests: the stash is a hashref scoped to the current request ($env->{'oidc.stash'}). This is the mode to use for a pure Resource Server (see "Protecting a web service"), which does not need any session middleware.
Behind a reverse proxy
The URLs given to the provider (redirect_uri, post logout redirect) are built from the request itself. If your application runs behind a reverse proxy which terminates TLS, enable Plack::Middleware::ReverseProxy (or an equivalent) before this middleware so that the scheme and the host are the public ones.
Authentication errors
If an OIDC::Client::Error exception occurs while handling the login callback (invalid state, error returned by the provider, token verification failure...):
if the
authentication_error_pathconfiguration entry is set, the browser is redirected to this path and the error message is stored in the session, so that the error page can display it (see "Displaying an authentication error") ;otherwise, the exception is rethrown.
The message behaves like a framework's flash : it is readable in $env->{'psgix.session'}{oidc_flash}{error_message} during the request that follows the redirect, and only during that request. The middleware removes it automatically, there is nothing to clean up. This requires a session, so the message is not available with the stash storage mode alone.
CONFIGURATION
config
Hashref with the same structure as the configuration of the other framework plugins. See OIDC::Client::Config for the complete reference.
log
Optional logger object, which must implement the debug, info, error and warning (or warn) methods. Defaults to an instance of Plack::Middleware::OIDC::Logger, which writes to $env->{'psgix.logger'} when available and to STDERR otherwise.
SAMPLES
Protecting routes
A small inline middleware, placed after OIDC, can require an authenticated user for the whole application except some public paths :
builder {
enable 'Session';
enable 'OIDC', config => $oidc_config;
enable sub {
my $next = shift;
return sub {
my $env = shift;
return $next->($env) if $env->{PATH_INFO} =~ m{^/(?:public|assets)/};
$env->{oidc}->get_valid_identity()
or $env->{oidc}->redirect_to_authorize();
return $next->($env);
};
};
$app;
};
Once the user is authenticated and sent back to the originally requested URL, the identity is available in any layer of the application :
my $identity = $env->{oidc}->get_valid_identity();
my $login = $identity->subject;
my $email = $identity->claims->{email};
Several providers
my $oidc_config = {
default_provider => 'my_provider',
provider => {
my_provider => {
...
signin_redirect_path => '/oidc/login/callback',
logout_redirect_path => '/oidc/logout/callback',
},
my_other_provider => {
...
signin_redirect_path => '/oidc/other/login/callback',
logout_redirect_path => '/oidc/other/logout/callback',
},
},
};
# default provider
$env->{oidc}->get_valid_identity();
# explicit provider
$env->{'oidc.my_other_provider'}->get_valid_identity();
Each provider needs its own callback paths.
Logging out
if ($env->{PATH_INFO} eq '/logout') {
$env->{oidc}->redirect_to_logout(target_url => 'https://myapp.example.com/');
}
The browser is sent to the provider, then back to the logout_redirect_path route handled by the middleware, which removes the data stored for the user and redirects to target_url (the base URL of the application by default).
Protecting a web service
For an application acting as an OAuth 2.0 Resource Server, use the stash storage mode, so that no session is required, and verify the access token received in the Authorization header :
my $oidc_config = {
provider => {
my_provider => {
id => 'my-api-id',
secret => 'xxxxxxxxx',
well_known_url => 'https://yourprovider.com/.well-known/openid-configuration',
store_mode => 'stash',
token_validation_method => 'jwt',
},
},
};
builder {
enable 'OIDC', config => $oidc_config;
sub {
my $env = shift;
my $access_token = eval { $env->{oidc}->verify_token() };
if ($@) {
return [401, ['Content-Type' => 'application/json',
'WWW-Authenticate' => 'Bearer'],
['{"error":"invalid_token"}']];
}
my @scopes = @{ $access_token->scopes };
...
};
};
When the token_validation_method is jwt, the provider's signing keys are fetched when the application starts.
Calling another web service
To call another application with the security context of the current request, the access token is automatically added to every request made with the web client returned by build_api_useragent() :
my $ua = $env->{oidc}->build_api_useragent();
my $res = $ua->get('https://other-app.example.com/api/things')->result;
See "build_api_useragent" in OIDC::Client::Plugin for the optional audience alias.
Displaying an authentication error
When authentication_error_path is configured, the error page can read the message stored by the middleware. It is only available during the request that follows the redirect :
if ($env->{PATH_INFO} eq '/error') {
my $message = $env->{'psgix.session'}{oidc_flash}{error_message}
|| 'Unknown error';
...
}
DIFFERENCES WITH THE OTHER FRAMEWORK PLUGINS
The configuration is passed to
enableinstead of being read from the framework's configuration.The
$c->oidchelper becomes$env->{oidc}and$c->oidc($provider)becomes$env->{"oidc.$provider"}.The callback routes are handled directly by the middleware, there is no route to declare.
Redirections requested by the application are implemented with an exception (see "Redirects from application code").
There is no
flashhelper : the authentication error message is read directly from the session, under theoidc_flashkey (see "Authentication errors").
LIMITATIONS
The redirect exception is only caught while the application runs synchronously. Calling
redirect_to_authorize()orredirect_to_logout()from within the callback of a delayed or streaming PSGI response is not supported.Only the
signin_redirect_pathandlogout_redirect_pathentries are taken into account for the callback routes, like in the other plugins. Thesignin_redirect_uriandpost_logout_redirect_urientries are not supported.A session middleware must be enabled before this middleware when the
sessionstorage mode is used. The exception is raised on every request going through the middleware, not only on the OIDC routes.The authentication error message is available during the first request going through the middleware after the redirect, whichever it is (for example a request for a favicon or a parallel request made by the browser would use it up).
METHODS
prepare_app()
Called once when the middleware is built. Depending on the configuration, creates and keeps in memory one OIDC::Client (stateless object) per configured provider, and builds the internal table used to intercept the callback routes.
call( $env )
Called for every request. If the request path matches a configured callback route, handles it directly. Otherwise, injects the OIDC::Client::Plugin instances into $env and delegates to the downstream application, converting any Plack::Middleware::OIDC::Redirect exception raised by the application into a real 302 response.
SECURITY RECOMMENDATION
It is highly recommended to configure the framework to store session data, including sensitive tokens such as access and refresh tokens, on the backend rather than in client-side cookies. Although cookies can be signed and encrypted, storing tokens in the client exposes them to potential security threats.
AUTHOR
Sébastien Mourlhou
COPYRIGHT AND LICENSE
Copyright (C) Sébastien Mourlhou
This program is free software, you can redistribute it and/or modify it under the terms of the Artistic License version 2.0.