Merge branch 'fix-session-refresh-2713' into 'v2.0'

Use OIDC Refresh tokens for session refresh

See merge request lemonldap-ng/lemonldap-ng!268
This commit is contained in:
Maxime Besson 2022-06-27 09:40:19 +00:00
commit 1322d78065
10 changed files with 280 additions and 19 deletions

View File

@ -148,15 +148,17 @@ Key Description
OpenID Connect
--------------
============================ ===============================================
============================ ======================================================================
Key Description
============================ ===============================================
============================ ======================================================================
\_oidc_id_token ID Token
\_oidc_OP Configuration key of OP used for authentication
\_oidc_access_token OAuth2 Access Token used to get UserInfo data
\_oidc_access_token_eol Timestamp after which the Access Token should no longer be valid
\_oidc_refresh_token OAuth2 Refresh Token. This should never be transmitted to applications
\_oidc_consent_scope\_\ *rp* Scope for which consent was given for RP *rp*
\_oidc_consent_time\_\ *rp* Time when consent was given for RP *rp*
============================ ===============================================
============================ ======================================================================
Other
-----

View File

@ -114,7 +114,7 @@ categories =
saml: ['_idp', '_idpConfKey', '_samlToken', '_lassoSessionDump', '_lassoIdentityDump']
groups: ['groups', 'hGroups']
ldap: ['dn']
OpenIDConnect: ['_oidc_id_token', '_oidc_OP', '_oidc_access_token']
OpenIDConnect: ['_oidc_id_token', '_oidc_OP', '_oidc_access_token', '_oidc_refresh_token', '_oidc_access_token_eol']
sfaTitle: ['_2fDevices']
oidcConsents: ['_oidcConsents']

View File

@ -126,7 +126,7 @@
saml: ['_idp', '_idpConfKey', '_samlToken', '_lassoSessionDump', '_lassoIdentityDump'],
groups: ['groups', 'hGroups'],
ldap: ['dn'],
OpenIDConnect: ['_oidc_id_token', '_oidc_OP', '_oidc_access_token'],
OpenIDConnect: ['_oidc_id_token', '_oidc_OP', '_oidc_access_token', '_oidc_refresh_token', '_oidc_access_token_eol'],
sfaTitle: ['_2fDevices'],
oidcConsents: ['_oidcConsents']
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,7 @@ package Lemonldap::NG::Portal::Auth::OpenIDConnect;
use strict;
use Mouse;
use MIME::Base64 qw/encode_base64 decode_base64/;
use Scalar::Util qw/looks_like_number/;
use Lemonldap::NG::Common::JWT qw(getJWTPayload);
use Lemonldap::NG::Portal::Main::Constants qw(
PE_OK
@ -159,11 +160,19 @@ sub extractFormInfo {
$self->logger->debug("Token response is valid");
}
my $access_token = $token_response->{access_token};
my $id_token = $token_response->{id_token};
my $access_token = $token_response->{access_token};
my $expires_in = $token_response->{expires_in};
my $id_token = $token_response->{id_token};
my $refresh_token = $token_response->{refresh_token};
undef $expires_in unless looks_like_number($expires_in);
$self->logger->debug("Access token: $access_token");
$self->logger->debug(
"Access token expires in: " . ( $expires_in || "<unknown>" ) );
$self->logger->debug("ID token: $id_token");
$self->logger->debug(
"Refresh token: " . ( $refresh_token || "<none>" ) );
# Verify JWT signature
if ( $self->conf->{oidcOPMetaDataOptions}->{$op}
@ -219,8 +228,15 @@ sub extractFormInfo {
my $user_id = $id_token_payload_hash->{sub};
# Remember tokens
$req->data->{access_token} = $access_token;
$req->data->{id_token} = $id_token;
$req->data->{access_token} = $access_token;
$req->data->{refresh_token} = $refresh_token if $refresh_token;
$req->data->{id_token} = $id_token;
# If access token TTL is given save expiration date
# (with security margin)
if ($expires_in) {
$req->data->{access_token_eol} = time + ( $expires_in * 0.9 );
}
$self->logger->debug( "Found user_id: " . $user_id );
$req->user($user_id);
@ -303,6 +319,16 @@ sub setAuthSessionInfo {
$req->{sessionInfo}->{_oidc_access_token} =
$req->data->{access_token};
if ( $req->data->{refresh_token} ) {
$req->{sessionInfo}->{_oidc_refresh_token} =
$req->data->{refresh_token};
}
if ( $req->data->{access_token_eol} ) {
$req->{sessionInfo}->{_oidc_access_token_eol} =
$req->data->{access_token_eol};
}
# Keep ID Token in session
my $store_IDToken = $self->conf->{oidcOPMetaDataOptions}->{$op}
->{oidcOPMetaDataOptionsStoreIDToken};

View File

@ -18,6 +18,7 @@ use Lemonldap::NG::Common::JWT
qw(getAccessTokenSessionId getJWTPayload getJWTHeader getJWTSignature getJWTSignedData);
use MIME::Base64
qw/encode_base64 decode_base64 encode_base64url decode_base64url/;
use Scalar::Util qw/looks_like_number/;
use Mouse;
use Lemonldap::NG::Portal::Main::Constants qw(PE_OK PE_REDIRECT);
@ -703,6 +704,134 @@ sub checkIDTokenValidity {
return 1;
}
# Returns the current OP and a valid Access token
sub getUserInfoParams {
my ( $self, $req ) = @_;
my $op = $req->data->{_oidcOPCurrent};
if ($op) {
# We are in the middle of an auth process,
# access token has just been fetched already
my $access_token = $req->data->{access_token};
return ( $op, $access_token );
}
else {
# Get OP and access token from existing session (refresh)
return $self->getUserInfoParamsFromSession($req);
}
}
sub getUserInfoParamsFromSession {
my ( $self, $req ) = @_;
my $op = $req->userData->{_oidc_OP};
# Save current OP, we will need it for setSessionInfo & friends
$req->data->{_oidcOPCurrent} = $op;
if ($op) {
my $access_token = $req->userData->{_oidc_access_token};
my $access_token_eol = $req->userData->{_oidc_access_token_eol};
if ($access_token_eol) {
return $self->refreshAccessTokenIfExpired( $req, $op );
}
else {
# We don't know the TTL for this access token,
# so we can only hope that it works
return ( $op, $access_token );
}
}
else {
$self->logger->warn("No OP found in session");
return ( $op, undef );
}
}
sub refreshAccessTokenIfExpired {
my ( $self, $req, $op, $session ) = @_;
# Handle unauthenticated OIDC calls
my $data = $session ? $session->data : $req->userData;
my $access_token = $data->{_oidc_access_token};
my $access_token_eol = $data->{_oidc_access_token_eol};
if ( time < $access_token_eol ) {
# Access Token is still valid, return it
return ( $op, $access_token );
}
else {
# Refresh Access Token
return ( $op, $self->refreshAccessToken( $req, $op, $session ) );
}
}
sub refreshAccessToken {
my ( $self, $req, $op, $session ) = @_;
# Handle unauthenticated OIDC calls
my $data = $session ? $session->data : $req->userData;
my $session_id = $session ? $session->id : $req->id;
my $refresh_token = $data->{_oidc_refresh_token};
if ($refresh_token) {
my $content =
$self->getAccessTokenFromTokenEndpoint( $req, $op, 'refresh_token',
{ refresh_token => $refresh_token } );
if ($content) {
my $token_response = $self->decodeTokenResponse($content);
if ($token_response) {
my $access_token = $token_response->{access_token};
my $expires_in = $token_response->{expires_in};
my $refresh_token = $token_response->{refresh_token};
undef $expires_in unless looks_like_number($expires_in);
$self->logger->debug("Access token: $access_token");
$self->logger->debug( "Access token expires in: "
. ( $expires_in || "<unknown>" ) );
$self->logger->debug(
"Refresh token: " . ( $refresh_token || "<none>" ) );
my $updateSession;
# Remember tokens
$updateSession->{_oidc_access_token} = $access_token;
$updateSession->{_oidc_refresh_token} = $refresh_token
if $refresh_token;
# If access token TTL is given save expiration date
# (with security margin)
if ($expires_in) {
$updateSession->{_oidc_access_token_eol} =
time + ( $expires_in * 0.9 );
}
$self->p->updateSession( $req, $updateSession, $session_id );
return ($access_token);
}
else {
$self->logger->warn("Could not decode Token Response for $op");
return undef;
}
}
else {
$self->logger->warn("Could not fetch new Access Token for $op");
return undef;
}
}
else {
$self->logger->warn("No Refresh Token was found for $op");
return undef;
}
}
# Get UserInfo response
# return String UserInfo response decoded content
sub getUserInfo {

View File

@ -203,8 +203,30 @@ sub refresh {
$req->user( $data{_user} || $data{ $self->conf->{whatToTrace} } );
$req->id( $data{_session_id} );
foreach ( keys %data ) {
delete $data{$_}
unless ( /^_/ or /^(?:startTime|authenticationLevel)$/ );
# Variables that start with _ are kept accross refresh
if (/^_/) {
# But not OIDC tokens, which can be refreshed
if (
/^(_oidc_access_token|_oidc_refresh_token|_oidc_access_token_eol)$/
)
{
delete $data{$_};
}
}
# Other variables should be refreshed
else {
# But not these two
if (/^(?:startTime|authenticationLevel)$/) {
next;
}
else {
delete $data{$_};
}
}
}
$data{_updateTime} = strftime( "%Y%m%d%H%M%S", localtime() );
$self->logger->debug(

View File

@ -27,7 +27,8 @@ sub init {
sub getUser {
my ( $self, $req ) = @_;
my $op = $req->data->{_oidcOPCurrent};
my ( $op, $access_token ) = $self->getUserInfoParams($req);
# This is likely to happen when running getUser without extractFormInfo
# see #1980
@ -36,7 +37,10 @@ sub getUser {
return PE_ERROR;
}
my $access_token = $req->data->{access_token};
unless ($access_token) {
$self->logger->warn("Could not get Access Token for User Info request");
return PE_ERROR;
}
my $userinfo_content = $self->getUserInfo( $op, $access_token );

View File

@ -200,9 +200,13 @@ count(2);
switch ('rp');
ok( $res = $rp->_get("/sessions/global/$spId"), 'Get UTF-8' );
$res = expectJSON($res);
ok( $res->{cn} eq 'Frédéric Accents', 'UTF-8 values' )
or explain( $res, 'cn => Frédéric Accents' );
count(2);
my $access_token_eol = $res->{_oidc_access_token_eol};
my $access_token_old = $res->{_oidc_access_token};
ok( $access_token_eol, 'OIDC EOL time is stored' );
ok( $access_token_old, 'Obtained refresh token' );
is( $res->{cn}, 'Frédéric Accents', 'UTF-8 values' );
is( $res->{mail}, 'fa@badwolf.org', 'Correct email' );
count(5);
is( $res->{userinfo_hook}, "op/french", "oidcGotUserInfo called" );
is( $res->{id_token_hook}, "op/french", "oidcGotIDToken called" );
@ -212,6 +216,77 @@ my $id_token_decoded = id_token_payload( $res->{_oidc_id_token} );
is( $id_token_decoded->{acr}, 'customacr-1', "Correct custom ACR" );
count(1);
# Update session at OP
$Lemonldap::NG::Portal::UserDB::Demo::demoAccounts{french} = {
uid => 'french',
cn => 'Frédéric Accents',
mail => 'fa2@badwolf.org',
guy => '',
type => '',
};
switch ('op');
ok( $op->_get( '/refresh', cookie => "lemonldap=$idpId" ) );
count(1);
switch ('rp');
# Test session refresh (before access token refresh)
ok(
$res = $rp->_get(
'/refresh',
cookie => "lemonldap=$spId",
accept => 'text/html'
),
'Query RP for refresh'
);
count(1);
ok( $res = $rp->_get("/sessions/global/$spId"), 'Get session after refresh' );
count(1);
$res = expectJSON($res);
my $access_token_new = $res->{_oidc_access_token};
my $access_token_new_eol = $res->{_oidc_access_token_eol};
is( $access_token_new_eol, $access_token_eol,
"Access token EOL has not changed" );
is( $access_token_new, $access_token_old, "Access token has not changed" );
is( $res->{mail}, 'fa2@badwolf.org', 'Updated RP session' );
count(3);
# Update session at OP
$Lemonldap::NG::Portal::UserDB::Demo::demoAccounts{french} = {
uid => 'french',
cn => 'Frédéric Accents',
mail => 'fa3@badwolf.org',
guy => '',
type => '',
};
switch ('op');
ok( $op->_get( '/refresh', cookie => "lemonldap=$idpId" ) );
count(1);
switch ('rp');
# Test session refresh (with access token refresh)
Time::Fake->offset("+2h");
ok(
$res = $rp->_get(
'/refresh',
cookie => "lemonldap=$spId",
accept => 'text/html'
),
'Query RP for refresh'
);
count(1);
ok( $res = $rp->_get("/sessions/global/$spId"), 'Get session after refresh' );
count(1);
$res = expectJSON($res);
$access_token_new = $res->{_oidc_access_token};
$access_token_new_eol = $res->{_oidc_access_token_eol};
isnt( $access_token_new_eol, $access_token_eol,
"Access token EOL has changed" );
isnt( $access_token_new, $access_token_old, "Access token has changed" );
is( $res->{mail}, 'fa3@badwolf.org', 'Updated RP session' );
count(3);
# Logout initiated by RP
ok(
$res = $rp->_get(
@ -346,6 +421,7 @@ sub op {
userDB => 'Same',
issuerDBOpenIDConnectActivation => "1",
restSessionServer => 1,
restExportSecretKeys => 1,
oidcRPMetaDataExportedVars => {
rp => {
email => "mail",
@ -364,6 +440,7 @@ sub op {
oidcRPMetaDataOptionsIDTokenSignAlg => "HS512",
oidcRPMetaDataOptionsBypassConsent => 0,
oidcRPMetaDataOptionsClientSecret => "rpsecret",
oidcRPMetaDataOptionsRefreshToken => 1,
oidcRPMetaDataOptionsUserIDAttr => "",
oidcRPMetaDataOptionsAccessTokenExpiration => 3600,
oidcRPMetaDataOptionsPostLogoutRedirectUris =>
@ -398,6 +475,7 @@ sub rp {
authentication => 'OpenIDConnect',
userDB => 'Same',
restSessionServer => 1,
restExportSecretKeys => 1,
oidcOPMetaDataExportedVars => {
op => {
cn => "name",
@ -411,7 +489,7 @@ sub rp {
oidcOPMetaDataOptionsCheckJWTSignature => 1,
oidcOPMetaDataOptionsJWKSTimeout => 0,
oidcOPMetaDataOptionsClientSecret => "rpsecret",
oidcOPMetaDataOptionsScope => "openid profile",
oidcOPMetaDataOptionsScope => "openid profile email",
oidcOPMetaDataOptionsStoreIDToken => 0,
oidcOPMetaDataOptionsMaxAge => 30,
oidcOPMetaDataOptionsDisplay => "",