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_name is 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_path configuration 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 enable instead of being read from the framework's configuration.

  • The $c->oidc helper 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 flash helper : the authentication error message is read directly from the session, under the oidc_flash key (see "Authentication errors").

LIMITATIONS

  • The redirect exception is only caught while the application runs synchronously. Calling redirect_to_authorize() or redirect_to_logout() from within the callback of a delayed or streaming PSGI response is not supported.

  • Only the signin_redirect_path and logout_redirect_path entries are taken into account for the callback routes, like in the other plugins. The signin_redirect_uri and post_logout_redirect_uri entries are not supported.

  • A session middleware must be enabled before this middleware when the session storage 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.

SEE ALSO