## @file # Common OpenID Connect functions ## @class # Common OpenID Connect functions package Lemonldap::NG::Portal::_OpenIDConnect; use strict; use JSON; use MIME::Base64 qw/encode_base64url encode_base64 decode_base64url decode_base64/; use URI::Escape; use Digest::SHA qw/hmac_sha256_base64 hmac_sha384_base64 hmac_sha512_base64 sha256 sha256_base64 sha384_base64 sha512_base64/; use Crypt::OpenSSL::RSA; use Crypt::OpenSSL::Bignum; use utf8; use base qw(Lemonldap::NG::Portal::_Browser); our $VERSION = '1.9.0'; our $oidcCache; BEGIN { eval { require threads::shared; threads::shared::share($oidcCache); }; } ## @method boolean loadOPs(boolean no_cache) # Load OpenID Connect Providers and JWKS data # @param no_cache Disable cache use # @return boolean result sub loadOPs { my ( $self, $no_cache ) = splice @_; # Check cache unless ($no_cache) { if ( $oidcCache->{_oidcOPList} ) { $self->lmLog( "Load OPs from cache", 'debug' ); $self->{_oidcOPList} = $oidcCache->{_oidcOPList}; return 1; } } # Check presence of at least one identity provider in configuration unless ( $self->{oidcOPMetaDataJSON} and keys %{ $self->{oidcOPMetaDataJSON} } ) { $self->lmLog( "No OpenID Connect Provider found in configuration", 'warn' ); } # Extract JSON data $self->{_oidcOPList} = {}; foreach ( keys %{ $self->{oidcOPMetaDataJSON} } ) { $self->{_oidcOPList}->{$_}->{conf} = $self->decodeJSON( $self->{oidcOPMetaDataJSON}->{$_}->{oidcOPMetaDataJSON} ); $self->{_oidcOPList}->{$_}->{jwks} = $self->decodeJSON( $self->{oidcOPMetaDataJWKS}->{$_}->{oidcOPMetaDataJWKS} ); } $oidcCache->{_oidcOPList} = $self->{_oidcOPList}; return 1; } ## @method boolean loadRPs(boolean no_cache) # Load OpenID Connect Relying Parties # @param no_cache Disable cache use # @return boolean result sub loadRPs { my ( $self, $no_cache ) = splice @_; # Check cache unless ($no_cache) { if ( $oidcCache->{_oidcRPList} ) { $self->lmLog( "Load RPs from cache", 'debug' ); $self->{_oidcRPList} = $oidcCache->{_oidcRPList}; return 1; } } # Check presence of at least one relying party in configuration unless ( $self->{oidcRPMetaDataOptions} and keys %{ $self->{oidcRPMetaDataOptions} } ) { $self->lmLog( "No OpenID Connect Relying Party found in configuration", 'warn' ); } $self->{_oidcRPList} = $self->{oidcRPMetaDataOptions}; $oidcCache->{_oidcRPList} = $self->{_oidcRPList}; return 1; } ## @method boolean refreshJWKSdata(boolean no_cache) # Refresh JWKS data if needed # @param no_cache Disable cache update # @return boolean result sub refreshJWKSdata { my ( $self, $no_cache ) = splice @_; unless ( $self->{oidcOPMetaDataJSON} and keys %{ $self->{oidcOPMetaDataJSON} } ) { $self->lmLog( "No OpenID Provider configured, JWKS data will not be refreshed", 'debug' ); return 1; } foreach ( keys %{ $self->{oidcOPMetaDataJSON} } ) { # Refresh JWKS data if # 1/ oidcOPMetaDataOptionsJWKSTimeout > 0 # 2/ jwks_uri defined in metadata my $jwksTimeout = $self->{oidcOPMetaDataOptions}->{$_} ->{oidcOPMetaDataOptionsJWKSTimeout}; my $jwksUri = $self->{_oidcOPList}->{$_}->{conf}->{jwks_uri}; unless ($jwksTimeout) { $self->lmLog( "No JWKS refresh timeout defined for $_, skipping...", 'debug' ); next; } unless ($jwksUri) { $self->lmLog( "No JWKS URI defined for $_, skipping...", 'debug' ); next; } if ( $self->{_oidcOPList}->{$_}->{jwks}->{time} + $jwksTimeout > time ) { $self->lmLog( "JWKS data still valid for $_, skipping...", 'debug' ); next; } $self->lmLog( "Refresh JWKS data for $_ from $jwksUri", 'debug' ); my $response = $self->ua->get($jwksUri); if ( $response->is_error ) { $self->lmLog( "Unable to get JWKS data for $_ from $jwksUri: " . $response->message, "warn" ); $self->lmLog( $response->content, 'debug' ); next; } my $content = $self->decodeJSON( $response->decoded_content ); $self->{_oidcOPList}->{$_}->{jwks} = $content; $self->{_oidcOPList}->{$_}->{jwks}->{time} = time; $oidcCache->{_oidcOPList}->{$_}->{jwks} = $content unless $no_cache; $oidcCache->{_oidcOPList}->{$_}->{jwks}->{time} = time unless $no_cache; } return 1; } ## @method String getRP(String client_id) # Get Relying Party corresponding to a Client ID # @param client_id Client ID # @return String result sub getRP { my ( $self, $client_id ) = splice @_; my $rp; foreach ( keys %{ $self->{_oidcRPList} } ) { if ( $client_id eq $self->{_oidcRPList}->{$_}->{oidcRPMetaDataOptionsClientID} ) { $rp = $_; last; } } return $rp; } ## @method String getCallbackUri() # Compute callback URI # @return String Callback URI sub getCallbackUri { my $self = shift; my $callback_get_param = $self->{oidcRPCallbackGetParam}; my $callback_uri = $self->{portal}; $callback_uri .= ( $self->{portal} =~ /\?/ ) ? '&' . $callback_get_param . '=1' : '?' . $callback_get_param . '=1'; # Use authChoiceParam in redirect URL if ( $self->param( $self->{authChoiceParam} ) ) { $callback_uri .= '&' . $self->{authChoiceParam} . '=' . uri_escape( $self->param( $self->{authChoiceParam} ) ); } $self->lmLog( "OpenIDConnect Callback URI: $callback_uri", 'debug' ); return $callback_uri; } ## @method String buildAuthorizationCodeAuthnRequest(String op, String state) # Build Authentication Request URI for Authorization Code Flow # @param op OpenIP Provider configuration key # @param state State # return String Authentication Request URI sub buildAuthorizationCodeAuthnRequest { my ( $self, $op, $state ) = splice @_; my $authorize_uri = $self->{_oidcOPList}->{$op}->{conf}->{authorization_endpoint}; my $client_id = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsClientID}; my $scope = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsScope}; my $use_nonce = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsUseNonce}; my $response_type = "code"; my $redirect_uri = $self->getCallbackUri; my $display = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsDisplay}; my $prompt = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsPrompt}; my $max_age = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsMaxAge}; my $ui_locales = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsUiLocales}; my $acr_values = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsAcrValues}; my $nonce; if ($use_nonce) { my $nonceSession = $self->getOpenIDConnectSession(); $nonceSession->update( { '_utime' => time } ); $nonce = $nonceSession->id; } $client_id = uri_escape($client_id); $scope = uri_escape($scope); $response_type = uri_escape($response_type); $redirect_uri = uri_escape($redirect_uri); $state = uri_escape($state) if defined $state; $nonce = uri_escape($nonce) if defined $nonce; $display = uri_escape($display) if defined $display; $prompt = uri_escape($prompt) if defined $prompt; $max_age = uri_escape($max_age) if defined $max_age; $ui_locales = uri_escape($ui_locales) if defined $ui_locales; $acr_values = uri_escape($acr_values) if defined $acr_values; my $authn_uri = $authorize_uri; $authn_uri .= ( $authorize_uri =~ /\?/ ? '&' : '?' ); $authn_uri .= "response_type=$response_type"; $authn_uri .= "&client_id=$client_id"; $authn_uri .= "&scope=$scope"; $authn_uri .= "&redirect_uri=$redirect_uri"; $authn_uri .= "&state=$state" if defined $state; $authn_uri .= "&nonce=$nonce" if defined $nonce; $authn_uri .= "&display=$display" if defined $display; $authn_uri .= "&prompt=$prompt" if defined $prompt; $authn_uri .= "&max_age=$max_age" if $max_age; $authn_uri .= "&ui_locales=$ui_locales" if defined $ui_locales; $authn_uri .= "&acr_values=$acr_values" if defined $acr_values; $self->lmLog( "OpenIDConnect Authorization Code Flow Authn Request: $authn_uri", 'debug' ); return $authn_uri; } ## @method String buildAuthorizationCodeAuthnResponse(String redirect_uri, String code, String state, String session_state) # Build Authentication Response URI for Authorization Code Flow # @param redirect_uri Redirect URI # @param code Code # @param state State # @param session_state Session state # return String Authentication Response URI sub buildAuthorizationCodeAuthnResponse { my ( $self, $redirect_uri, $code, $state, $session_state ) = splice @_; my $response_url = $redirect_uri; $response_url .= ( $redirect_uri =~ /\?/ ? '&' : '?' ); $response_url .= "code=" . uri_escape($code); if ($state) { $response_url .= "&state=" . uri_escape($state); } if ($session_state) { $response_url .= "&session_state=" . uri_escape($session_state); } return $response_url; } ## @method String buildImplicitAuthnResponse(String redirect_uri, String access_token, String id_token, String expires_in, String state, String session_state) # Build Authentication Response URI for Implicit Flow # @param redirect_uri Redirect URI # @param access_token Access token # @param id_token ID token # @param expires_in Expiration of access token # @param state State # @param session_state Session state # return String Authentication Response URI sub buildImplicitAuthnResponse { my ( $self, $redirect_uri, $access_token, $id_token, $expires_in, $state, $session_state ) = splice @_; my $response_url = $redirect_uri; $response_url .= "#id_token=" . uri_escape($id_token); if ($access_token) { $response_url .= "&access_token=" . uri_escape($access_token); $response_url .= "&token_type=bearer"; } if ($expires_in) { $response_url .= "&expires_in=" . uri_escape($expires_in); } if ($state) { $response_url .= "&state=" . uri_escape($state); } if ($session_state) { $response_url .= "&session_state=" . uri_escape($session_state); } return $response_url; } ## @method String buildHybridAuthnResponse(String redirect_uri, String code, String access_token, String id_token, String expires_in, String state, String session_state) # Build Authentication Response URI for Hybrid Flow # @param redirect_uri Redirect URI # @param code Code # @param access_token Access token # @param id_token ID token # @param expires_in Expiration of access token # @param state State # @param session_state Session state # return String Authentication Response URI sub buildHybridAuthnResponse { my ( $self, $redirect_uri, $code, $access_token, $id_token, $expires_in, $state, $session_state ) = splice @_; my $response_url = $redirect_uri; $response_url .= "#code=" . uri_escape($code); if ($access_token) { $response_url .= "&access_token=" . uri_escape($access_token); $response_url .= "&token_type=bearer"; } if ($id_token) { $response_url .= "&id_token=" . uri_escape($id_token); } if ($expires_in) { $response_url .= "&expires_in=" . uri_escape($expires_in); } if ($state) { $response_url .= "&state=" . uri_escape($state); } if ($session_state) { $response_url .= "&session_state=" . uri_escape($session_state); } return $response_url; } ## @method String getAuthorizationCodeAccessToken(String op, String code, String auth_method) # Get Token response with autorization code # @param op OpenIP Provider configuration key # @param code Code # @param auth_method Authentication Method # return String Token response decoded content sub getAuthorizationCodeAccessToken { my ( $self, $op, $code, $auth_method ) = splice @_; my $client_id = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsClientID}; my $client_secret = $self->{oidcOPMetaDataOptions}->{$op} ->{oidcOPMetaDataOptionsClientSecret}; my $redirect_uri = $self->getCallbackUri; my $access_token_uri = $self->{_oidcOPList}->{$op}->{conf}->{token_endpoint}; my $grant_type = "authorization_code"; $self->lmLog( "Using auth method $auth_method to token endpoint $access_token_uri", 'debug' ); my $response; my %form; if ( $auth_method eq "client_secret_basic" ) { $form{"code"} = $code; $form{"redirect_uri"} = $redirect_uri; $form{"grant_type"} = $grant_type; $response = $self->ua->post( $access_token_uri, \%form, "Authorization" => "Basic " . encode_base64("$client_id:$client_secret"), "Content-Type" => 'application/x-www-form-urlencoded', ); } if ( $auth_method eq "client_secret_post" ) { $form{"code"} = $code; $form{"client_id"} = $client_id; $form{"client_secret"} = $client_secret; $form{"redirect_uri"} = $redirect_uri; $form{"grant_type"} = $grant_type; $response = $self->ua->post( $access_token_uri, \%form, "Content-Type" => 'application/x-www-form-urlencoded' ); } if ( $response->is_error ) { $self->lmLog( "Bad authorization response: " . $response->message, "error" ); $self->lmLog( $response->content, 'debug' ); return 0; } return $response->decoded_content; } ## @method boolean checkTokenResponseValidity(HashRef json) # Check validity of Token Response # @param json JSON HashRef # return boolean 1 if the response is valid, 0 else sub checkTokenResponseValidity { my ( $self, $json ) = splice @_; # token_type MUST be Bearer unless ( $json->{token_type} eq "Bearer" ) { $self->lmLog( "Token type is " . $json->{token_type} . " but must be Bearer", 'error' ); return 0; } # id_token MUST be present unless ( $json->{id_token} ) { $self->lmLog( "No id_token", 'error' ); return 0; } return 1; } ## @method boolean checkIDTokenValidity(String op, HashRef id_token) # Check validity of ID Token # @param op OpenIP Provider configuration key # @param id_token ID Token payload as HashRef # return boolean 1 if the token is valid, 0 else sub checkIDTokenValidity { my ( $self, $op, $id_token ) = splice @_; my $client_id = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsClientID}; my $acr_values = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsAcrValues}; my $max_age = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsMaxAge}; my $id_token_max_age = $self->{oidcOPMetaDataOptions}->{$op} ->{oidcOPMetaDataOptionsIDTokenMaxAge}; my $use_nonce = $self->{oidcOPMetaDataOptions}->{$op}->{oidcOPMetaDataOptionsUseNonce}; # Check issuer unless ( $id_token->{iss} eq $self->{_oidcOPList}->{$op}->{conf}->{issuer} ) { $self->lmLog( "Issuer mismatch", 'error' ); return 0; } # Check audience if ( ref $id_token->{aud} ) { my @audience = @{ $id_token->{aud} }; unless ( grep $_ eq $client_id, @audience ) { $self->lmLog( "Client ID not found in audience array", 'error' ); return 0; } if ( $#audience > 1 ) { unless ( $id_token->{azp} eq $client_id ) { $self->lmLog( "More than one audience, and azp not equal to client ID", 'error' ); return 0; } } } else { unless ( $id_token->{aud} eq $client_id ) { $self->lmLog( "Audience mismatch", 'error' ); return 0; } } # Check time unless ( time < $id_token->{exp} ) { $self->lmLog( "ID token expired", 'error' ); return 0; } # Check iat my $iat = $id_token->{iat}; if ($id_token_max_age) { unless ( $iat + $id_token_max_age > time ) { $self->lmLog( "ID token too old (Max age: $id_token_max_age)", 'error' ); return 0; } } # Check nonce if ($use_nonce) { my $nonce = $id_token->{nonce}; unless ($nonce) { $self->lmLog( "Nonce was not returned by OP $op", 'error' ); return 0; } else { # Get nonce session my $nonceSession = $self->getOpenIDConnectSession($nonce); unless ($nonceSession) { $self->lmLog( "Nonce $nonce verification failed", 'error' ); return 0; } else { $nonceSession->remove; $self->lmLog( "Nonce $nonce deleted", 'debug' ); } } } # Check acr my $acr = $id_token->{acr}; if ( defined $acr_values ) { unless ($acr) { $self->lmLog( "ACR was not returned by OP $op", 'error' ); return 0; } unless ( $acr_values =~ /\b$acr\b/i ) { $self->lmLog( "ACR $acr not listed in request ACR values ($acr_values)", 'error' ); return 0; } } # Check auth_time my $auth_time = $id_token->{auth_time}; if ($max_age) { unless ($auth_time) { $self->lmLog( "Auth time was not returned by OP $op", 'error' ); return 0; } if ( $auth_time + $max_age > time ) { $self->lmLog( "Authentication time ($auth_time) is too old (Max age: $max_age)", 'error' ); return 0; } } return 1; } ## @method String getUserInfo(String op, String access_token) # Get UserInfo response # @param op OpenIP Provider configuration key # @param access_token Access Token # return String UserInfo response decoded content sub getUserInfo { my ( $self, $op, $access_token ) = splice @_; my $userinfo_uri = $self->{_oidcOPList}->{$op}->{conf}->{userinfo_endpoint}; unless ($userinfo_uri) { $self->lmLog( "UserInfo URI not found in $op configuration", 'error' ); return 0; } $self->lmLog( "Request User Info on $userinfo_uri with access token $access_token", 'debug' ); my $response = $self->ua->get( $userinfo_uri, "Authorization" => "Bearer $access_token" ); if ( $response->is_error ) { $self->lmLog( "Bad userinfo response: " . $response->message, "error" ); $self->lmLog( $response->content, 'debug' ); return 0; } my $content_type = $response->header('Content-Type'); if ( $content_type =~ /json/ ) { return $response->decoded_content; } elsif ( $content_type =~ /jwt/ ) { my $jwt = $response->decoded_content; return unless $self->verifyJWTSignature( $op, $jwt ); my $jwt_parts = $self->extractJWT($jwt); return $jwt_parts->[1]; } } ## @method HashRef decodeJSON(String json) # Convert JSON to HashRef # @param json JSON raw content # @return HashRef JSON decoded content sub decodeJSON { my ( $self, $json ) = splice @_; my $json_hash; eval { $json_hash = decode_json $json; }; if ($@) { $json_hash->{error} = "parse_error"; } return $json_hash; } ## @method hashref getOpenIDConnectSession(string id) # Try to recover the OpenID Connect session corresponding to id and return session # If id is set to undef, return a new session # @param id session reference # @return Lemonldap::NG::Common::Session object sub getOpenIDConnectSession { my ( $self, $id ) = splice @_; my $oidcSession = Lemonldap::NG::Common::Session->new( { storageModule => $self->{oidcStorage}, storageModuleOptions => $self->{oidcStorageOptions}, cacheModule => $self->{localSessionStorage}, cacheModuleOptions => $self->{localSessionStorageOptions}, id => $id, kind => "OpenIDConnect", } ); if ( $oidcSession->error ) { if ($id) { $self->_sub( 'userInfo', "OpenIDConnect session $id isn't yet available" ); } else { $self->lmLog( "Unable to create new OpenIDConnect session", 'error' ); $self->lmLog( $oidcSession->error, 'error' ); } return undef; } return $oidcSession; } ## @method string storeState(array data) # Store information in state database and return # corresponding session_id # @param data Array of information to store # @return State Session ID sub storeState { my ( $self, @data ) = splice @_; # check if there are data to store my $infos; foreach (@data) { $infos->{$_} = $self->{$_} if $self->{$_}; } return unless ($infos); # Create state session my $stateSession = $self->getOpenIDConnectSession(); return unless $stateSession; # Session type $infos->{_type} = "state"; # Set _utime for session autoremove # Use default session timeout and relayState session timeout to compute it my $time = time(); my $timeout = $self->{timeout}; my $stateTimeout = $self->{oidcRPStateTimeout} || $timeout; $infos->{_utime} = $time + ( $stateTimeout - $timeout ); # Store infos in state session $stateSession->update($infos); # Return session ID return $stateSession->id; } ## @method boolean extractState(string state) # Extract state information into $self # @param state state value # @return result sub extractState { my ( $self, $state ) = splice @_; return 0 unless $state; # Open state session my $stateSession = $self->getOpenIDConnectSession($state); return 0 unless $stateSession; # Push values in $self foreach ( keys %{ $stateSession->data } ) { next if $_ =~ /(type|_session_id|_utime)/; $self->{$_} = $stateSession->data->{$_}; } # Delete state session if ( $stateSession->remove ) { $self->lmLog( "State $state was deleted", 'debug' ); } else { $self->lmLog( "Unable to delete state $state", 'error' ); $self->lmLog( $stateSession->error, 'error' ); } return 1; } ## @method arrayref extractJWT(String jwt) # Extract parts of a JWT # @param jwt JWT raw value # @return arrayref JWT parts sub extractJWT { my ( $self, $jwt ) = splice @_; my @jwt_parts = split( /\./, $jwt ); return \@jwt_parts; } ## @method boolean verifyJWTSignature(String jwt, String op, String rp) # Check signature of a JWT # @param jwt JWT raw value # @param op OpenIP Provider configuration key # @param rp OpenIP Relying Party configuration key # @return boolean 1 if signature is verified, 0 else sub verifyJWTSignature { my ( $self, $jwt, $op, $rp ) = splice @_; $self->lmLog( "Verification of JWT signature: $jwt", 'debug' ); # Extract JWT parts my $jwt_parts = $self->extractJWT($jwt); # Read header my $jwt_header_part = $jwt_parts->[0]; my $jwt_header_hash = $self->decodeJSON( decode_base64url($jwt_header_part) ); # Get signature algorithm my $alg = $jwt_header_hash->{alg}; $self->lmLog( "JWT signature algorithm: $alg", 'debug' ); if ( $alg eq "none" ) { # If none alg, signature should be empty if ( $jwt_parts->[2] ) { $self->lmLog( "Signature " . $jwt_parts->[2] . " is present but algorithm is 'none'", 'debug' ); return 0; } return 1; } if ( $alg eq "HS256" or $alg eq "HS384" or $alg eq "HS512" ) { # Check signature with client secret my $client_secret; $client_secret = $self->{oidcOPMetaDataOptions}->{$op} ->{oidcOPMetaDataOptionsClientSecret} if $op; $client_secret = $self->{oidcRPMetaDataOptions}->{$rp} ->{oidcRPMetaDataOptionsClientSecret} if $rp; my $digest; if ( $alg eq "HS256" ) { $digest = hmac_sha256_base64( $jwt_parts->[0] . "." . $jwt_parts->[1], $client_secret ); } if ( $alg eq "HS384" ) { $digest = hmac_sha384_base64( $jwt_parts->[0] . "." . $jwt_parts->[1], $client_secret ); } if ( $alg eq "HS512" ) { $digest = hmac_sha512_base64( $jwt_parts->[0] . "." . $jwt_parts->[1], $client_secret ); } # Convert + and / to get Base64 URL valid (RFC 4648) $digest =~ s/\+/-/g; $digest =~ s/\//_/g; unless ( $digest eq $jwt_parts->[2] ) { $self->lmLog( "Digest $digest not equal to signature " . $jwt_parts->[2], 'debug' ); return 0; } return 1; } if ( $alg eq "RS256" or $alg eq "RS384" or $alg eq "RS512" ) { if ($rp) { $self->lmLog( "Algorithm $alg not supported", 'debug' ); return 0; } # The public key is needed unless ( $self->{_oidcOPList}->{$op}->{jwks} ) { $self->lmLog( "Cannot verify $alg signature: no JWKS data found", 'error' ); return 0; } my $keys = $self->{_oidcOPList}->{$op}->{jwks}->{keys}; my $key_hash; # Find Key ID associated with signature my $kid = $jwt_header_hash->{kid}; if ($kid) { $self->lmLog( "Search key with id $kid", 'debug' ); foreach (@$keys) { if ( $_->{kid} eq $kid ) { $key_hash = $_; last; } } } else { $key_hash = shift @$keys; } unless ($key_hash) { $self->lmLog( "No key found in JWKS data", 'error' ); return 0; } $self->lmLog( "Found public key parameter n: " . $key_hash->{n}, 'debug' ); $self->lmLog( "Found public key parameter e: " . $key_hash->{e}, 'debug' ); # Create public key my $n = Crypt::OpenSSL::Bignum->new_from_bin( decode_base64url( $key_hash->{n} ) ); my $e = Crypt::OpenSSL::Bignum->new_from_bin( decode_base64url( $key_hash->{e} ) ); my $public_key = Crypt::OpenSSL::RSA->new_key_from_parameters( $n, $e ); if ( $alg eq "RS256" ) { $public_key->use_sha256_hash; } if ( $alg eq "RS384" ) { $public_key->use_sha384_hash; } if ( $alg eq "RS512" ) { $public_key->use_sha512_hash; } return $public_key->verify( $jwt_parts->[0] . "." . $jwt_parts->[1], decode_base64url( $jwt_parts->[2] ) ); } # Other algorithms not managed $self->lmLog( "Algorithm $alg not known", 'debug' ); return 0; } ## @method boolean verifyHash(String value, String hash, String id_token) # Check value hash # @param value Value # @param hash Hash # @param id_token ID Token # @return boolean 1 if hash is verified, 0 else sub verifyHash { my ( $self, $value, $hash, $id_token ) = splice @_; $self->lmLog( "Verification of value $value with hash $hash", 'debug' ); # Extract ID token parts my $jwt_parts = $self->extractJWT($id_token); # Read header my $jwt_header_part = $jwt_parts->[0]; my $jwt_header_hash = $self->decodeJSON( decode_base64url($jwt_header_part) ); # Get signature algorithm my $alg = $jwt_header_hash->{alg}; $self->lmLog( "ID Token signature algorithm: $alg", 'debug' ); if ( $alg eq "none" ) { # Not supported $self->lmLog( "Cannot check hash without signature algorithm", 'debug' ); return 0; } if ( $alg =~ /(?:\w{2})(\d{3})/ ) { # Hash Level my $hash_level = $1; $self->lmLog( "Use SHA $hash_level to check hash", 'debug' ); my $cHash = $self->createHash( $value, $hash_level ); # Compare values unless ( $cHash eq $hash ) { $self->lmLog( "Hash $hash not equal to hash $cHash", 'debug' ); return 0; } return 1; } # Other algorithms not managed $self->lmLog( "Algorithm $alg not known", 'debug' ); return 0; } ## @method String createHash(String value, String hash_level) # Create Hash # @param value Value to hash # @param hash_level SHA Hash level # @return String hash sub createHash { my ( $self, $value, $hash_level ) = splice @_; $self->lmLog( "Use SHA $hash_level to hash $value", 'debug' ); my $hash; if ( $hash_level eq "256" ) { $hash = sha256($value); } if ( $hash_level eq "384" ) { $hash = sha384($value); } if ( $hash_level eq "512" ) { $hash = sha512($value); } $hash = substr( $hash, 0, length($hash) / 2 ); $hash = encode_base64url( $hash, "" ); return $hash; } ## @method void returnRedirectError(String redirect_url, String error, String error_description, String error_uri, String state, Boolean fragment) # Create error redirection # @param redirect_url Redirection URL # @param error Error code # @param error_description Human-readable ASCII encoded text description of the error # @param error_uri URI of a web page that includes additional information about the error # @param state OAuth 2.0 state value # @param fragment Set to true to return fragment component # @return void sub returnRedirectError { my ( $self, $redirect_url, $error, $error_description, $error_uri, $state, $fragment ) = splice @_; my $urldc = $redirect_url; if ($fragment) { $urldc .= "#"; } else { $urldc .= ( $redirect_url =~ /\?/ ? '&' : '?' ); } $urldc .= "error=" . uri_escape($error); $urldc .= "&error_description=" . uri_escape($error_description) if defined $error_description; $urldc .= "&error_uri=" . uri_escape($error_uri) if defined $error_uri; $urldc .= "&state=" . uri_escape($state) if defined $state; $self->lmLog( "Redirect user to $urldc", 'debug' ); $self->{'urldc'} = $urldc; $self->_sub('autoRedirect'); $self->quit; } ## @method void returnJSONError(String error); # Print JSON error # @param error Error message # @return void sub returnJSONError { my ( $self, $error ) = splice @_; my $content = { "error" => "$error" }; # We use to_json because values are already UTF-8 encoded my $json = to_json( $content, { pretty => 1 } ); # TODO Send 400 return code # CGI always add HTML code to non 200 return code, which is not compatible with JSON response print $self->header('application/json'); print $json; } ## @method void returnJSON(String content); # Print JSON content # @param content Message # @return void sub returnJSON { my ( $self, $content ) = splice @_; # We use to_json because values are already UTF-8 encoded my $json = to_json( $content, { pretty => 1 } ); $self->lmLog( "Return JSON: $json", 'debug' ); print $self->header('application/json'); print $json; } ## @method void returnBearerError(String error_code, String error_message); # Return Bearer error # @param error_code Error code # @param error_message Error message # @return void sub returnBearerError { my ( $self, $error_code, $error_message ) = splice @_; my $content = "error=$error_code,error_description=$error_message"; # TODO Send 400/401 return code # CGI always add HTML code to non 200 return code, which is not compatible with JSON response print $self->header( -www_authenticate => $content ); } ## @method array getEndPointAuthenticationCredentials() # Get Client ID and Client Secret # @return array (client_id, client_secret) sub getEndPointAuthenticationCredentials { my $self = shift; my ( $client_id, $client_secret ); my $authorization = $ENV{HTTP_AUTHORIZATION}; if ( $authorization =~ /^Basic (\w+)/i ) { $self->lmLog( "Method client_secret_basic used", 'debug' ); ( $client_id, $client_secret ) = split( /:/, decode_base64($1) ); } elsif ( $self->param('client_id') && $self->param('client_secret') ) { $self->lmLog( "Method client_secret_post used", 'debug' ); $client_id = $self->param('client_id'); $client_secret = $self->param('client_secret'); } return ( $client_id, $client_secret ); } ## @method String getEndPointAccessToken() # Get Access Token # @return access_token sub getEndPointAccessToken { my $self = shift; my $access_token; my $authorization = $ENV{HTTP_AUTHORIZATION}; if ( $authorization =~ /^Bearer (\w+)/i ) { $self->lmLog( "Bearer access token", 'debug' ); $access_token = $1; } return $access_token; } ## @method arrayref getAttributesListFromClaim(String rp, String claim) # Return list of attributes authorized for a claim # @param rp RP name # @param claim Claim # @return arrayref attributes list sub getAttributesListFromClaim { my ( $self, $rp, $claim ) = splice @_; my $attributes = {}; # OpenID Connect standard claims $attributes->{profile} = [ qw/name family_name given_name middle_name nickname preferred_username profile picture website gender birthdate zoneinfo locale updated_at/ ]; $attributes->{email} = [qw/email email_verified/]; $attributes->{address} = [qw/formatted street_address locality region postal_code/]; $attributes->{phone} = [qw/phone_number phone_number_verified/]; # Additional claims my $extraClaims = $self->{oidcRPMetaDataOptions}->{$rp}->{oidcRPMetaDataOptionsExtraClaims}; if ($extraClaims) { foreach my $claim ( keys %$extraClaims ) { $self->lmLog( "Using extra claim $claim", 'debug' ); my @extraAttributes = split( /\s/, $extraClaims->{$claim} ); $attributes->{$claim} = \@extraAttributes; } } return $attributes->{$claim}; } ## @method hashref buildUserInfoResponse(String scope, String rp, String user_session_id) # Return Hash of UserInfo data # @param scope OIDC scope # @param rp Internal Relying Party identifier # @param user_session_id User session identifier # @return hashref UserInfo data sub buildUserInfoResponse { my ( $self, $scope, $rp, $user_session_id ) = splice @_; my $userinfo_response = {}; # Get user identifier my $apacheSession = $self->getApacheSession( $user_session_id, 1 ); unless ($apacheSession) { $self->lmLog( "Unable to find user session", "error" ); $self->returnJSONError("invalid_request"); $self->quit; } my $user_id_attribute = $self->{oidcRPMetaDataOptions}->{$rp}->{oidcRPMetaDataOptionsUserIDAttr} || $self->{whatToTrace}; my $user_id = $apacheSession->data->{$user_id_attribute}; $self->lmLog( "Found corresponding user: $user_id", 'debug' ); $userinfo_response->{sub} = $user_id; # Parse scope and return allowed attributes foreach my $claim ( split( /\s/, $scope ) ) { next if ( $claim eq "openid" ); $self->lmLog( "Get attributes linked to claim $claim", 'debug' ); my $list = $self->getAttributesListFromClaim( $rp, $claim ); next unless $list; foreach my $attribute (@$list) { my $session_key = $self->{oidcRPMetaDataExportedVars}->{$rp}->{$attribute}; if ($session_key) { my $session_value = $apacheSession->data->{$session_key}; utf8::decode($session_value); # Address is a JSON object if ( $claim eq "address" ) { $userinfo_response->{address}->{$attribute} = $session_value; } else { $userinfo_response->{$attribute} = $session_value; } } } } return $userinfo_response; } ## @method String createJWT(hashref payload, String alg, String rp) # Return JWT # @param payload JWT content # @param alg Signature algorithm # @param rp Internal Relying Party identifier # @return String jwt JWT sub createJWT { my ( $self, $payload, $alg, $rp ) = splice @_; # Payload encoding my $jwt_payload = encode_base64( encode_json($payload), "" ); # JWT header my $jwt_header_hash = { typ => "JWT", alg => $alg }; $jwt_header_hash->{kid} = $self->{oidcServiceKeyIdSig} if $self->{oidcServiceKeyIdSig}; my $jwt_header = encode_base64( encode_json($jwt_header_hash), "" ); if ( $alg eq "none" ) { return $jwt_header . "." . $jwt_payload; } if ( $alg eq "HS256" or $alg eq "HS384" or $alg eq "HS512" ) { # Sign with client secret my $client_secret = $self->{oidcRPMetaDataOptions}->{$rp} ->{oidcRPMetaDataOptionsClientSecret}; my $digest; if ( $alg eq "HS256" ) { $digest = hmac_sha256_base64( $jwt_header . "." . $jwt_payload, $client_secret ); } if ( $alg eq "HS384" ) { $digest = hmac_sha384_base64( $jwt_header . "." . $jwt_payload, $client_secret ); } if ( $alg eq "HS512" ) { $digest = hmac_sha512_base64( $jwt_header . "." . $jwt_payload, $client_secret ); } # Convert + and / to get Base64 URL valid (RFC 4648) $digest =~ s/\+/-/g; $digest =~ s/\//_/g; return $jwt_header . "." . $jwt_payload . "." . $digest; } if ( $alg eq "RS256" or $alg eq "RS384" or $alg eq "RS512" ) { # Get signing private key my $priv_key = $self->{oidcServicePrivateKeySig}; my $rsa_priv = Crypt::OpenSSL::RSA->new_private_key($priv_key); if ( $alg eq "RS256" ) { $rsa_priv->use_sha256_hash; } if ( $alg eq "RS384" ) { $rsa_priv->use_sha384_hash; } if ( $alg eq "RS512" ) { $rsa_priv->use_sha512_hash; } my $digest = encode_base64url( $rsa_priv->sign( $jwt_header . "." . $jwt_payload ) ); return $jwt_header . "." . $jwt_payload . "." . $digest; } $self->lmLog( "Algorithm $alg not supported to sign JWT", 'debug' ); return; } ## @method String createIDToken(hashref payload, String rp) # Return ID Token # @param payload ID Token content # @param rp Internal Relying Party identifier # @return String id_token ID Token as JWT sub createIDToken { my ( $self, $payload, $rp ) = splice @_; # Get signature algorithm my $alg = $self->{oidcRPMetaDataOptions}->{$rp} ->{oidcRPMetaDataOptionsIDTokenSignAlg}; $self->lmLog( "ID Token signature algorithm: $alg", 'debug' ); return $self->createJWT( $payload, $alg, $rp ); } ## @method String getFlowType(String response_type) # Return flow type # @param response_type Response type # @return String flow sub getFlowType { my ( $self, $response_type ) = splice @_; my $response_types = { "code" => "authorizationcode", "id_token" => "implicit", "id_token token" => "implicit", "code id_token" => "hybrid", "code token" => "hybrid", "code id_token token" => "hybrid", }; return $response_types->{$response_type}; } ## @method String getIDTokenSub(String id_token) # Return sub field of an ID Token # @param id_token ID Token # @return String sub sub getIDTokenSub { my ( $self, $id_token ) = splice @_; my $payload = $self->getJWTJSONData($id_token); return $payload->{sub}; } ## @method HashRef getJWTJSONData(String jwt) # Return payload of a JWT as Hash ref # @param jwt JWT # @return HashRef payload sub getJWTJSONData { my ( $self, $jwt ) = splice @_; my $jwt_parts = $self->extractJWT($jwt); return decode_json( decode_base64url( $jwt_parts->[1] ) ); } ## @method HashRef key2jwks(String key) # Return JWKS representation of a key # @param key Raw key # @return HashRef JWKS key sub key2jwks { my ( $self, $key ) = splice @_; my $hash = {}; my $rsa_pub = Crypt::OpenSSL::RSA->new_public_key($key); my @params = $rsa_pub->get_key_parameters(); $hash->{n} = encode_base64( $params[0]->to_bin(), "" ); $hash->{e} = encode_base64( $params[1]->to_bin(), "" ); return $hash; } ## @method String buildLogoutRequest(String redirect_uri, String id_token_hint, String post_logout_redirect_uri, String state) # Build Logout Request URI # @param redirect_uri Redirect URI # @param id_token_hint ID Token # @param post_logout_redirect_uri Callback URI # @param state State # return String Logout URI sub buildLogoutRequest { my ( $self, $redirect_uri, $id_token_hint, $post_logout_redirect_uri, $state ) = splice @_; my $response_url = $redirect_uri; if ($id_token_hint) { $response_url .= ( $response_url =~ /\?/ ? '&' : '?' ); $response_url .= "id_token_hint=" . uri_escape($id_token_hint); } if ($post_logout_redirect_uri) { $response_url .= ( $response_url =~ /\?/ ? '&' : '?' ); $response_url .= "post_logout_redirect_uri=" . uri_escape($post_logout_redirect_uri); } if ($state) { $response_url .= ( $response_url =~ /\?/ ? '&' : '?' ); $response_url .= "state=" . uri_escape($state); } return $response_url; } ## @method String buildLogoutResponse(String redirect_uri, String state) # Build Logout Response URI # @param redirect_uri Redirect URI # @param state State # return String Logout URI sub buildLogoutResponse { my ( $self, $redirect_uri, $state ) = splice @_; my $response_url = $redirect_uri; if ($state) { $response_url .= ( $redirect_uri =~ /\?/ ? '&' : '?' ); $response_url .= "state=" . uri_escape($state); } return $response_url; } ## @method String createSessionState(String session_id, String client_id) # Create session_state parameter # @param session_id Session ID # @param client_id CLient ID # return String Session state sub createSessionState { my ( $self, $session_id, $client_id ) = splice @_; my $salt = encode_base64url( $self->{cipher}->encrypt($client_id) ); my $data = $client_id . " " . $session_id . " " . $salt; my $hash = sha256_base64($data); while ( length($hash) % 4 ) { $hash .= '='; } my $session_state = $hash . "." . $salt; return $session_state; } ## @method String getRequestJWT(String request_uri) # Get request JWT from request uri # @param request_uri request uri # return String request JWT sub getRequestJWT { my ( $self, $request_uri ) = splice @_; my $response = $self->ua->get($request_uri); if ( $response->is_error ) { $self->lmLog( "Unable to get request JWT on $request_uri", 'error' ); return; } return $response->decoded_content; } ## @method String getSessionManagementOPIFrameJS # Create JS code needed on OP side to manage session # return String JS code sub getSessionManagementOPIFrameJS { my ($self) = splice @_; my $js; $js .= "window.addEventListener(\"message\", receiveMessage, false);\n"; $js .= "function receiveMessage(e){ \n"; $js .= "var message = e.data; \n"; $js .= "client_id = decodeURIComponent(message.split(' ')[0]);\n"; $js .= "session_state = decodeURIComponent(message.split(' ')[1]);\n"; $js .= "var salt = decodeURIComponent(session_state.split('.')[1]);\n"; $js .= 'var opbs = document.cookie.replace(/(?:(?:^|.*;\s*)' . $self->{cookieName} . '\s*\=\s*([^;]*).*$)|^.*$/, "$1");' . "\n"; $js .= "var hash = CryptoJS.SHA256(client_id + ' ' + opbs + ' ' + salt);\n"; $js .= "var ss = hash.toString(CryptoJS.enc.Base64) + '.' + salt;\n"; $js .= "if (session_state == ss) {\n"; $js .= "stat = 'unchanged';\n"; $js .= "} else {\n"; $js .= "stat = 'changed';\n"; $js .= "}\n"; $js .= "e.source.postMessage(stat,e.origin);\n"; $js .= "};\n"; return $js; } 1; __END__ =head1 NAME =encoding utf8 Lemonldap::NG::Portal::_OpenIDConnect - Common OpenIDConnect functions =head1 SYNOPSIS use Lemonldap::NG::Portal::_OpenIDConnect; =head1 DESCRIPTION This module contains common methods for OpenIDConnect authentication and user information loading =head1 METHODS =head2 loadOPs Load OpenID Connect Providers and JWKS data =head2 loadRPs Load OpenID Connect Relying Parties =head2 refreshJWKSdata Refresh JWKS data if needed =head2 getRP Get Relying Party corresponding to a Client ID =head2 getCallbackUri Compute callback URI =head2 buildAuthorizationCodeAuthnRequest Build Authentication Request URI for Authorization Code Flow =head2 buildAuthorizationCodeAuthnResponse Build Authentication Response URI for Authorization Code Flow =head2 buildImplicitAuthnResponse Build Authentication Response URI for Implicit Flow =head2 buildHybridAuthnResponse Build Authentication Response URI for Hybrid Flow =head2 getAuthorizationCodeAccessToken Get Token response with autorization code =head2 checkTokenResponseValidity Check validity of Token Response =head2 getUserInfo Get UserInfo response =head2 decodeJSON Convert JSON to HashRef =head2 getOpenIDConnectSession Try to recover the OpenID Connect session corresponding to id and return session =head2 storeState Store information in state database and return =head2 extractState Extract state information into $self =head2 extractJWT Extract parts of a JWT =head2 verifyJWTSignature Check signature of a JWT =head2 verifyHash Check value hash =head2 createHash Create Hash =head2 returnRedirectError Create error redirection =head2 returnJSONError Print JSON error =head2 returnJSON Print JSON content =head2 returnBearerError Return Bearer error =head2 getEndPointAuthenticationCredentials Get Client ID and Client Secret =head2 getEndPointAccessToken Get Access Token =head2 getAttributesListFromClaim Return list of attributes authorized for a claim =head2 buildUserInfoResponse Return Hash of UserInfo data =head2 createJWT Return JWT =head2 createIDToken Return ID Token =head2 getFlowType Return flow type =head2 getIDTokenSub Return sub field of an ID Token =head2 getJWTJSONData Return payload of a JWT as Hash ref =head2 key2jwks Return JWKS representation of a key =head2 buildLogoutRequest Build Logout Request URI =head2 buildLogoutResponse Build Logout Response URI =head1 SEE ALSO L, L =head1 AUTHOR =over =item Clement Oudot, Eclem.oudot@gmail.comE =back =head1 BUG REPORT Use OW2 system to report bug or ask for features: L =head1 DOWNLOAD Lemonldap::NG is available at L =head1 COPYRIGHT AND LICENSE =over =item Copyright (C) 2014 by Clement Oudot, Eclem.oudot@gmail.comE =back This library is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see L. =cut