diff --git a/TODO-2.0.md b/TODO-2.0.md index e109a8ff1..e545a34c0 100644 --- a/TODO-2.0.md +++ b/TODO-2.0.md @@ -1,4 +1,4 @@ -* Minimal authn level system +* Minimal authn level system (choice only) * (2ndF/OTP mail) * Combination/Choice for password (using session data) * Combination: reinitialize password without being authentified diff --git a/lemonldap-ng-portal/t/32-Auth-and-issuer-OIDC-authorization_code-with-authchoice.t b/lemonldap-ng-portal/t/32-Auth-and-issuer-OIDC-authorization_code-with-authchoice.t new file mode 100644 index 000000000..87790738a --- /dev/null +++ b/lemonldap-ng-portal/t/32-Auth-and-issuer-OIDC-authorization_code-with-authchoice.t @@ -0,0 +1,406 @@ +use Test::More; +use strict; +use IO::String; +use MIME::Base64; + +BEGIN { + require 't/test-lib.pm'; +} + +my $debug = 'error'; +my $mainTests = 18; +my ( $op, $rp, $res ); +my %handlerOR = ( op => [], rp => [] ); + +SKIP: { + eval { require DBI; require DBD::SQLite; }; + if ($@) { + skip 'DBD::SQLite not found', $mainTests; + } + my $dbh = DBI->connect("dbi:SQLite:dbname=t/userdb.db"); + $dbh->do('CREATE TABLE users (user text,password text,name text)'); + $dbh->do("INSERT INTO users VALUES ('dwho','dwho','Doctor who')"); + + # Initialization + ok( $op = op(), 'OP portal' ); + + ok( + $res = $op->_get('/oauth2/jwks'), + 'Get JWKS, endpoint /oauth2/jwks' + ); + expectOK($res); + my $jwks = $res->[2]->[0]; + + ok( + $res = $op->_get('/.well-known/openid-configuration'), + 'Get metadata, endpoint /.well-known/openid-configuration' + ); + expectOK($res); + my $metadata = $res->[2]->[0]; + + switch ('rp'); + ok( $rp = rp( $jwks, $metadata ), 'RP portal' ); + + # Query RP for auth + ok( $res = $rp->_get( '/', accept => 'text/html' ), 'Unauth SP request' ); + my ( $url, $query ) = + expectRedirection( $res, + qr#http://auth.op.com(/oauth2/authorize)\?(.*)$# ); + + # Push request to OP + switch ('op'); + ok( $res = $op->_get( $url, query => $query, accept => 'text/html' ), + "Push request to OP, endpoint $url" ); + my ( $host, $tmp ); + ( $host, $tmp, $query ) = expectForm($res); + + # Try to authenticate to OP + $query =~ s/user=&?//; + $query = "user=dwho&password=dwho&$query"; + ok( + $res = $op->_post( + $url, + IO::String->new($query), + accept => 'text/html', + length => length($query), + ), + "Post authentication, endpoint $url" + ); + my $idpId = expectCookie($res); + ( $host, $tmp, $query ) = expectForm( $res, '#', undef, 'confirm' ); + + ok( + $res = $op->_post( + $url, + IO::String->new($query), + accept => 'text/html', + cookie => "lemonldap=$idpId", + length => length($query), + ), + "Post confirmation, endpoint $url" + ); + + ($query) = expectRedirection( $res, qr#^http://auth.rp.com/?\?(.*)$# ); + + # Push OP response to RP + switch ('rp'); + + ok( $res = $rp->_get( '/', query => $query, accept => 'text/html' ), + 'Call openidconnectcallback on RP' ); + my $spId = expectCookie($res); + + switch ('op'); + ok( + $res = $op->_get( '/oauth2/checksession.html', accept => 'text.html' ), + 'Check session, endpoint /oauth2/checksession.html' + ); + expectOK($res); + ok( getHeader( $res, 'Content-Security-Policy' ) !~ /frame-ancestors/, + ' Frame can be embedded' ) + or explain( $res->[1], + 'Content-Security-Policy does not contain a frame-ancestors' ); + + # Logout initiated by RP + ok( + $res = $rp->_get( + '/', + query => 'logout', + cookie => "lemonldap=$spId", + accept => 'text/html' + ), + 'Query RP for logout' + ); + ( $url, $query ) = expectRedirection( $res, + qr#http://auth.op.com(/oauth2/logout)\?(post_logout_redirect_uri=.+)$# + ); + + # Push logout to OP + switch ('op'); + + ok( + $res = $op->_get( + $url, + query => $query, + cookie => "lemonldap=$idpId", + accept => 'text/html' + ), + "Push logout request to OP, endpoint $url" + ); + + ( $host, $tmp, $query ) = expectForm( $res, '#', undef, 'confirm' ); + + ok( + $res = $op->_post( + $url, IO::String->new($query), + length => length($query), + cookie => "lemonldap=$idpId", + accept => 'text/html', + ), + "Confirm logout, endpoint $url" + ); + + ( $url, $query ) = expectRedirection( $res, qr#.# ); + + # Test logout endpoint without session + ok( + $res = $op->_get( + '/oauth2/logout', + accept => 'text/html', + query => 'post_logout_redirect_uri=http://auth.rp.com/?logout=1' + ), + 'logout endpoint with redirect, endpoint /oauth2/logout' + ); + expectRedirection( $res, 'http://auth.rp.com/?logout=1' ); + + ok( $res = $op->_get('/oauth2/logout'), + 'logout endpoint, endpoint /oauth2/logout' ); + expectReject($res); + + # Test if logout is done + ok( + $res = $op->_get( + '/', cookie => "lemonldap=$idpId", + ), + 'Test if user is reject on IdP' + ); + expectReject($res); + + switch ('rp'); + ok( + $res = $rp->_get( + '/', + accept => 'text/html', + cookie => + "lemonldapidp=http://auth.idp.com/saml/metadata; lemonldap=$spId" + ), + 'Test if user is reject on SP' + ); + expectRedirection( $res, qr#^http://auth.op.com/oauth2/authorize# ); + + clean_sessions(); +} + +#print STDERR Dumper($res); + +count($mainTests); +eval { unlink 't/userdb.db' }; +done_testing( count() ); + +no warnings 'redefine'; + +sub LWP::UserAgent::request { + my ( $self, $req ) = @_; + ok( $req->uri =~ m#http://auth.((?:o|r)p).com(.*)#, ' REST request' ); + my $host = $1; + my $url = $2; + my ( $res, $client ); + count(1); + if ( $host eq 'op' ) { + pass(" Request from RP to OP, endpoint $url"); + $client = $op; + } + elsif ( $host eq 'rp' ) { + pass(' Request from OP to RP'); + $client = $rp; + } + else { + fail(' Aborting REST request (external)'); + return HTTP::Response->new(500); + } + if ( $req->method =~ /^post$/i ) { + my $s = $req->content; + ok( + $res = $client->_post( + $url, IO::String->new($s), + length => length($s), + type => $req->header('Content-Type'), + ), + ' Execute request' + ); + } + else { + ok( + $res = $client->_get( + $url, + custom => { + HTTP_AUTHORIZATION => $req->header('Authorization'), + } + ), + ' Execute request' + ); + } + ok( $res->[0] == 200, ' Response is 200' ); + ok( getHeader( $res, 'Content-Type' ) =~ m#^application/json#, + ' Content is JSON' ) + or explain( $res->[1], 'Content-Type => application/json' ); + my $httpResp = HTTP::Response->new( $res->[0], 'OK' ); + + while ( my $name = shift @{ $res->[1] } ) { + $httpResp->header( $name, shift( @{ $res->[1] } ) ); + } + + #print STDERR Dumper($res->[2]); + $httpResp->content( join( '', @{ $res->[2] } ) ); + count(4); + return $httpResp; +} + +sub switch { + my $type = shift; + pass( '==> Switching to ' . uc($type) . ' <==' ); + count(1); + @Lemonldap::NG::Handler::Main::_onReload = @{ + $handlerOR{$type}; + }; +} + +sub op { + return LLNG::Manager::Test->new( + { + ini => { + logLevel => $debug, + domain => 'idp.com', + portal => 'http://auth.op.com/', + authentication => 'Choice', + userDB => 'Same', + authChoiceParam => 'test', + authChoiceModules => { + demo => 'Demo;Demo;Demo', + sql => 'DBI;DBI;DBI', + }, + dbiAuthChain => 'dbi:SQLite:dbname=t/userdb.db', + dbiAuthUser => '', + dbiAuthPassword => '', + dbiAuthTable => 'users', + dbiAuthLoginCol => 'user', + dbiAuthPasswordCol => 'password', + dbiAuthPasswordHash => '', + issuerDBOpenIDConnectActivation => "1", + oidcRPMetaDataExportedVars => { + rp => { + email => "mail", + family_name => "cn", + name => "cn" + } + }, + oidcServiceMetaDataIssuer => "http://auth.op.com/", + oidcServiceMetaDataAuthorizeURI => "authorize", + oidcServiceMetaDataCheckSessionURI => "checksession.html", + oidcServiceMetaDataJWKSURI => "jwks", + oidcServiceMetaDataEndSessionURI => "logout", + oidcServiceMetaDataRegistrationURI => "register", + oidcServiceMetaDataTokenURI => "token", + oidcServiceMetaDataUserInfoURI => "userinfo", + oidcServiceAllowHybridFlow => 1, + oidcServiceAllowImplicitFlow => 1, + oidcServiceAllowDynamicRegistration => 1, + oidcServiceAllowAuthorizationCodeFlow => 1, + oidcRPMetaDataOptions => { + rp => { + oidcRPMetaDataOptionsDisplayName => "RP", + oidcRPMetaDataOptionsIDTokenExpiration => 3600, + oidcRPMetaDataOptionsClientID => "rpid", + oidcRPMetaDataOptionsIDTokenSignAlg => "HS512", + oidcRPMetaDataOptionsBypassConsent => 0, + oidcRPMetaDataOptionsClientSecret => "rpsecret", + oidcRPMetaDataOptionsUserIDAttr => "", + oidcRPMetaDataOptionsAccessTokenExpiration => 3600, + oidcRPMetaDataOptionsPostLogoutRedirectUris => + "http://auth.rp.com/?logout=1" + } + }, + oidcOPMetaDataOptions => {}, + oidcOPMetaDataJSON => {}, + oidcOPMetaDataJWKS => {}, + oidcStorageOptions => {}, + oidcServiceMetaDataAuthnContext => { + 'loa-4' => 4, + 'loa-1' => 1, + 'loa-5' => 5, + 'loa-2' => 2, + 'loa-3' => 3 + }, + oidcServicePrivateKeySig => "-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAs2jsmIoFuWzMkilJaA8//5/T30cnuzX9GImXUrFR2k9EKTMt +GMHCdKlWOl3BV+BTAU9TLz7Jzd/iJ5GJ6B8TrH1PHFmHpy8/qE/S5OhinIpIi7eb +ABqnoVcwDdCa8ugzq8k8SWxhRNXfVIlwz4NH1caJ8lmiERFj7IvNKqEhzAk0pyDr +8hubveTC39xREujKlsqutpPAFPJ3f2ybVsdykX5rx0h5SslG3jVWYhZ/SOb2aIzO +r0RMjhQmsYRwbpt3anjlBZ98aOzg7GAkbO8093X5VVk9vaPRg0zxJQ0Do0YLyzkR +isSAIFb0tdKuDnjRGK6y/N2j6At2HjkxntbtGQIDAQABAoIBADYq6LxJd977LWy3 +0HT9nboFPIf+SM2qSEc/S5Po+6ipJBA4ZlZCMf7dHa6znet1TDpqA9iQ4YcqIHMH +6xZNQ7hhgSAzG9TrXBHqP+djDlrrGWotvjuy0IfS9ixFnnLWjrtAH9afRWLuG+a/ +NHNC1M6DiiTE0TzL/lpt/zzut3CNmWzH+t19X6UsxUg95AzooEeewEYkv25eumWD +mfQZfCtSlIw1sp/QwxeJa/6LJw7KcPZ1wXUm1BN0b9eiKt9Cmni1MS7elgpZlgGt +xtfGTZtNLQ7bgDiM8MHzUfPBhbceNSIx2BeCuOCs/7eaqgpyYHBbAbuBQex2H61l +Lcc3Tz0CgYEA4Kx/avpCPxnvsJ+nHVQm5d/WERuDxk4vH1DNuCYBvXTdVCGADf6a +F5No1JcTH3nPTyPWazOyGdT9LcsEJicLyD8vCM6hBFstG4XjqcAuqG/9DRsElpHQ +yi1zc5DNP7Vxmiz9wII0Mjy0abYKtxnXh9YK4a9g6wrcTpvShhIcIb8CgYEAzGzG +lorVCfX9jXULIznnR/uuP5aSnTEsn0xJeqTlbW0RFWLdj8aIL1peirh1X89HroB9 +GeTNqEJXD+3CVL2cx+BRggMDUmEz4hR59meZCDGUyT5fex4LIsceb/ESUl2jo6Sw +HXwWbN67rQ55N4oiOcOppsGxzOHkl5HdExKidycCgYEAr5Qev2tz+fw65LzfzHvH +Kj4S/KuT/5V6He731cFd+sEpdmX3vPgLVAFPG1Q1DZQT/rTzDDQKK0XX1cGiLG63 +NnaqOye/jbfzOF8Z277kt51NFMDYhRLPKDD82IOA4xjY/rPKWndmcxwdob8yAIWh +efY76sMz6ntCT+xWSZA9i+ECgYBWMZM2TIlxLsBfEbfFfZewOUWKWEGvd9l5vV/K +D5cRIYivfMUw5yPq2267jPUolayCvniBH4E7beVpuPVUZ7KgcEvNxtlytbt7muil +5Z6X3tf+VodJ0Swe2NhTmNEB26uwxzLe68BE3VFCsbSYn2y48HAq+MawPZr18bHG +ZfgMxwKBgHHRg6HYqF5Pegzk1746uH2G+OoCovk5ylGGYzcH2ghWTK4agCHfBcDt +EYqYAev/l82wi+OZ5O8U+qjFUpT1CVeUJdDs0o5u19v0UJjunU1cwh9jsxBZAWLy +PAGd6SWf4S3uQCTw6dLeMna25YIlPh5qPA6I/pAahe8e3nSu2ckl +-----END RSA PRIVATE KEY----- +", + oidcServicePublicKeySig => "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs2jsmIoFuWzMkilJaA8/ +/5/T30cnuzX9GImXUrFR2k9EKTMtGMHCdKlWOl3BV+BTAU9TLz7Jzd/iJ5GJ6B8T +rH1PHFmHpy8/qE/S5OhinIpIi7ebABqnoVcwDdCa8ugzq8k8SWxhRNXfVIlwz4NH +1caJ8lmiERFj7IvNKqEhzAk0pyDr8hubveTC39xREujKlsqutpPAFPJ3f2ybVsdy +kX5rx0h5SslG3jVWYhZ/SOb2aIzOr0RMjhQmsYRwbpt3anjlBZ98aOzg7GAkbO80 +93X5VVk9vaPRg0zxJQ0Do0YLyzkRisSAIFb0tdKuDnjRGK6y/N2j6At2Hjkxntbt +GQIDAQAB +-----END PUBLIC KEY----- +", + } + } + ); +} + +sub rp { + my ( $jwks, $metadata ) = @_; + return LLNG::Manager::Test->new( + { + ini => { + logLevel => $debug, + domain => 'rp.com', + portal => 'http://auth.rp.com/', + authentication => 'OpenIDConnect', + userDB => 'Same', + restSessionServer => 1, + oidcOPMetaDataExportedVars => { + op => { + cn => "name", + uid => "sub", + sn => "family_name", + mail => "email" + } + }, + oidcOPMetaDataOptions => { + op => { + oidcOPMetaDataOptionsJWKSTimeout => 0, + oidcOPMetaDataOptionsClientSecret => "rpsecret", + oidcOPMetaDataOptionsScope => "openid profile", + oidcOPMetaDataOptionsStoreIDToken => 0, + oidcOPMetaDataOptionsDisplay => "", + oidcOPMetaDataOptionsClientID => "rpid", + oidcOPMetaDataOptionsConfigurationURI => + "https://auth.op.com/.well-known/openid-configuration" + } + }, + oidcOPMetaDataJWKS => { + op => $jwks, + }, + oidcOPMetaDataJSON => { + op => $metadata, + } + } + } + ); +}