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:
commit
1322d78065
|
@ -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
|
||||
-----
|
||||
|
|
|
@ -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']
|
||||
|
||||
|
|
|
@ -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
|
@ -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};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 );
|
||||
|
||||
|
|
|
@ -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 => "",
|
||||
|
|
Loading…
Reference in New Issue