diff --git a/config.demo/src/database/seeds/PassportSeeder.php b/config.demo/src/database/seeds/PassportSeeder.php --- a/config.demo/src/database/seeds/PassportSeeder.php +++ b/config.demo/src/database/seeds/PassportSeeder.php @@ -31,6 +31,21 @@ $client->id = \config('auth.proxy.client_id'); $client->save(); + // Create a client for Webmail SSO + $client = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => 'Webmail SSO client', + 'secret' => \config('auth.sso.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain') . '/roundcubemail/index.php/login/oauth', + 'personal_access_client' => 0, + 'password_client' => 0, + 'revoked' => false, + 'allowed_scopes' => ['email', 'auth.token'], + ]); + $client->id = \config('auth.sso.client_id'); + $client->save(); + // Create a client for synapse oauth $client = Passport::client()->forceFill([ 'user_id' => null, diff --git a/src/app/Auth/IdentityEntity.php b/src/app/Auth/IdentityEntity.php --- a/src/app/Auth/IdentityEntity.php +++ b/src/app/Auth/IdentityEntity.php @@ -35,8 +35,16 @@ // TODO: Other claims // TODO: Should we use this in AuthController::oauthUserInfo() for some de-duplicaton? - return [ + $claims = [ 'email' => $this->user->email, ]; + + // Short living password for IMAP/SMTP + // We use same TTL as for the OAuth tokens, so clients can get a new password on token refresh + // TODO: We should create the password only when the access token scope requests it + $ttl = config('auth.token_expiry_minutes') * 60; + $claims['auth.token'] = Utils::tokenCreate((string) $this->user->id, $ttl); + + return $claims; } } diff --git a/src/app/Auth/Utils.php b/src/app/Auth/Utils.php --- a/src/app/Auth/Utils.php +++ b/src/app/Auth/Utils.php @@ -10,10 +10,11 @@ * Create a simple authentication token * * @param string $userid User identifier + * @param int $ttl Token's time to live (in seconds) * * @return string|null Encrypted token, Null on failure */ - public static function tokenCreate($userid): ?string + public static function tokenCreate($userid, $ttl = 10): ?string { // Note: Laravel's Crypt::encryptString() creates output that is too long // We need output string to be max. 127 characters. For that reason @@ -23,7 +24,7 @@ $key = config('app.key'); $iv = random_bytes(openssl_cipher_iv_length($cipher)); - $data = $userid . '!' . now()->addSeconds(10)->format('YmdHis'); + $data = $userid . '!' . now()->addSeconds($ttl)->format('YmdHis'); $value = openssl_encrypt($data, $cipher, $key, 0, $iv, $tag); diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -111,7 +111,10 @@ } try { - // league/oauth2-server/src/Grant/ code expects GET parameters, but we're using POST here + // OpenID handler reads parameters from the request query string (GET) + $request->query->replace($request->input()); + + // OAuth2 server's code also expects GET parameters, but we're using POST here $psrRequest = $psrRequest->withQueryParams($request->input()); $authRequest = $server->validateAuthorizationRequest($psrRequest); diff --git a/src/config/auth.php b/src/config/auth.php --- a/src/config/auth.php +++ b/src/config/auth.php @@ -140,6 +140,11 @@ 'client_secret' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET'), ], + 'sso' => [ + 'client_id' => env('PASSPORT_WEBMAIL_SSO_CLIENT_ID'), + 'client_secret' => env('PASSPORT_WEBMAIL_SSO_CLIENT_SECRET'), + ], + 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; diff --git a/src/config/openid.php b/src/config/openid.php --- a/src/config/openid.php +++ b/src/config/openid.php @@ -14,6 +14,7 @@ // 'phone' => 'Information about your phone numbers', // 'address' => 'Information about your address', // 'login' => 'See your login information', + 'auth.token' => 'Kolab authentication token', ], ], @@ -30,6 +31,9 @@ // 'company_phone', // 'company_email', // ], + 'auth.token' => [ + 'auth.token', + ] ], /** diff --git a/src/resources/js/utils.js b/src/resources/js/utils.js --- a/src/resources/js/utils.js +++ b/src/resources/js/utils.js @@ -52,7 +52,7 @@ return result } -const loader = '
Loading
' +const loader = '
Loading
' let isLoading = 0 @@ -63,7 +63,7 @@ * - DOMElement or jQuery collection or selector string: for element-level loader inside * - array: for element-level loader inside the element specified in the first array element * - undefined, null or true: for page-level loader - * @param object $style Additional element style + * @param object $style Additional element style (and loader text) */ const startLoading = (element, style = null) => { let small = false @@ -101,6 +101,8 @@ loaderElement.addClass('small') } + loaderElement.find('.text').text(style && style.text ? style.text : '') + $(element).append(loaderElement) return loaderElement diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -21,7 +21,7 @@ 'error.password' => "Invalid password", 'error.invalidrequest' => "Invalid authorization request.", 'error.geolocation' => "Country code mismatch", - 'error.nofound' => "User not found", + 'error.notfound' => "User not found", 'error.2fa' => "Second factor failure", 'error.2fa-generic' => "Second factor failure", diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -346,6 +346,7 @@ 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", + 'redirecting' => "Redirecting...", 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -73,6 +73,7 @@ display: flex; align-items: center; justify-content: center; + flex-direction: column; z-index: 8; .spinner-border { @@ -82,6 +83,13 @@ color: #b2aa99; } + .text { + width: 100%; + text-align: center; + color: #b2aa99; + margin-top: 1em; + } + &.small .spinner-border { width: 25px; height: 25px; diff --git a/src/resources/vue/Authorize.vue b/src/resources/vue/Authorize.vue --- a/src/resources/vue/Authorize.vue +++ b/src/resources/vue/Authorize.vue @@ -1,5 +1,5 @@ @@ -20,8 +20,10 @@ axios.post('/api/oauth/approve', post, { loading: true }) .then(response => { + // Display loading widget, redirecting may take a while + this.$root.startLoading(['#auth-container', { small: false, text: this.$t('msg.redirecting') }]) // Follow the redirect to the external page - window.location.href = response.data.redirectUrl; + window.location.href = response.data.redirectUrl }) .catch(this.$root.errorHandler) } diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -471,15 +471,15 @@ public function testOIDCAuthorizationCodeFlow(): void { $user = $this->getTestUser('john@kolab.org'); - $client = \App\Auth\PassportClient::find(\config('auth.synapse.client_id')); + $client = \App\Auth\PassportClient::find(\config('auth.sso.client_id')); // Note: Invalid input cases were tested above, we omit them here - // This is essentially the same as for OAuth2, but with extended scope + // This is essentially the same as for OAuth2, but with extended scopes $post = [ 'client_id' => $client->id, 'response_type' => 'code', - 'scope' => 'openid email', + 'scope' => 'openid email auth.token', 'state' => 'state', 'nonce' => 'nonce', ]; @@ -522,8 +522,10 @@ $this->assertSame('JWT', $token['typ']); $this->assertSame('RS256', $token['alg']); + $this->assertSame('nonce', $token['nonce']); $this->assertSame(url('/'), $token['iss']); $this->assertSame($user->email, $token['email']); + $this->assertSame((string) $user->id, \App\Auth\Utils::tokenValidate($token['auth.token'])); // TODO: Validate JWT token properly diff --git a/src/tests/Feature/Controller/WellKnownTest.php b/src/tests/Feature/Controller/WellKnownTest.php --- a/src/tests/Feature/Controller/WellKnownTest.php +++ b/src/tests/Feature/Controller/WellKnownTest.php @@ -11,7 +11,7 @@ */ public function testOpenidConfiguration(): void { - $href = \App\Utils::serviceUrl("/"); + $href = \App\Utils::serviceUrl('/'); $response = $this->get('.well-known/openid-configuration'); $response->assertStatus(200)