Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
43 KB
Referenced Files
None
Subscribers
None
diff --git a/src/app/Auth/OAuth.php b/src/app/Auth/OAuth.php
index 171618ed..a863f550 100644
--- a/src/app/Auth/OAuth.php
+++ b/src/app/Auth/OAuth.php
@@ -1,211 +1,216 @@
<?php
namespace App\Auth;
use App\Http\Controllers\Controller;
use App\Support\Facades\Roundcube;
use App\User;
use App\Utils;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Exception\OAuthServerException;
use Nyholm\Psr7\Response as Psr7Response;
use Psr\Http\Message\ServerRequestInterface;
class OAuth
{
/**
* Approval request for the oauth authorization endpoint
*
* The implementation is based on Laravel\Passport\Http\Controllers\AuthorizationController
*
* @param User $user Authenticating user
* @param ServerRequestInterface $psrRequest PSR request
* @param Request $request The API request
* @param AuthorizationServer $server Authorization server
* @param bool $use_cache Cache the approval state
*
* @return JsonResponse
*/
public static function approve(
User $user,
ServerRequestInterface $psrRequest,
Request $request,
AuthorizationServer $server,
bool $use_cache = true
) {
$clientId = $request->input('client_id');
try {
if ($request->response_type != 'code') {
throw new \Exception('Invalid response_type');
}
$cacheKey = "oauth-seen-{$user->id}-{$clientId}";
// 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);
// Check if the client was approved before (in last x days)
if ($clientId && $use_cache && $request->ifSeen) {
$client = PassportClient::find($clientId);
- if ($client && !Cache::has($cacheKey)) {
- throw new \Exception('Not seen yet');
+ if ($client) {
+ // System clients are trusted, don't need approval
+ if (!$client->user_id) {
+ $use_cache = false;
+ } elseif (!Cache::has($cacheKey)) {
+ throw new \Exception('Not seen yet');
+ }
}
}
// TODO I'm not sure if we should still execute this to deny the request
$authRequest->setUser(new \Laravel\Passport\Bridge\User($user->getAuthIdentifier()));
$authRequest->setAuthorizationApproved(true);
// This will generate a 302 redirect to the redirect_uri with the generated authorization code
$response = $server->completeAuthorizationRequest($authRequest, new Psr7Response());
// Remember the approval for x days.
// In this time we'll not show the UI form and we'll redirect automatically
// TODO: If we wanted to give users ability to remove this "approved" state for a client,
// we would have to store these records in SQL table. It would become handy especially
// if we give users possibility to register external OAuth apps.
if ($use_cache) {
Cache::put($cacheKey, 1, now()->addDays(14));
}
} catch (OAuthServerException $e) {
// Note: We don't want 401 or 400 codes here, use 422 which is used in our API
$code = $e->getHttpStatusCode();
$response = $e->getPayload();
$response['redirectUrl'] = !empty($client) ? $client->redirect : $request->input('redirect_uri');
return Controller::errorResponse($code < 500 ? 422 : 500, $e->getMessage(), $response);
} catch (\Exception $e) {
if (!empty($client)) {
$scopes = preg_split('/\s+/', (string) $request->input('scope'));
$claims = [];
foreach (array_intersect($scopes, $client->allowed_scopes) as $claim) {
$claims[$claim] = Controller::trans("auth.claim.{$claim}");
}
return response()->json([
'status' => 'prompt',
'client' => [
'name' => $client->name,
'url' => $client->redirect,
'claims' => $claims,
],
]);
}
$response = [
'error' => $e->getMessage() == 'Invalid response_type' ? 'unsupported_response_type' : 'server_error',
'redirectUrl' => $request->input('redirect_uri'),
];
return Controller::errorResponse(422, Controller::trans('auth.error.invalidrequest'), $response);
}
return response()->json([
'status' => 'success',
// Client (e.g. webmail) location to redirect to
'redirectUrl' => $response->getHeader('Location')[0],
]);
}
/**
* Get the authenticated User information (using access token claims)
*
* @param User $user User
*/
public static function userInfo(User $user): array
{
$response = [
// Per OIDC spec. 'sub' must be always returned
'sub' => $user->id,
];
if ($user->tokenCan('email')) {
$response['email'] = $user->email;
$response['email_verified'] = $user->isActive();
// At least synapse depends on a "settings" structure being available
$response['settings'] = ['name' => $user->name()];
}
// TODO: Other claims (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)
// address: address
// phone: phone_number and phone_number_verified
// profile: name, family_name, given_name, middle_name, nickname, preferred_username,
// profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at
return $response;
}
/**
* Webmail Login-As session initialization (via SSO)
*
* @param User $user The user to log in as
* @param ServerRequestInterface $psrRequest PSR request
* @param Request $request The API request
* @param AuthorizationServer $server Authorization server
*
* @return JsonResponse
*/
public static function loginAs(User $user, ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server)
{
// Use OAuth client for Webmail
$client = PassportClient::where('name', 'Webmail SSO client')->whereNull('user_id')->first();
if (!$client) {
return Controller::errorResponse(404);
}
// Abuse the self::oauthApprove() handler to init the OAuth session (code)
$request->merge([
'client_id' => $client->id,
'redirect_uri' => $client->redirect,
'scope' => 'email openid auth.token',
'state' => Utils::uuidStr(),
'nonce' => Utils::uuidStr(),
'response_type' => 'code',
'ifSeen' => false,
]);
$response = self::approve($user, $psrRequest, $request, $server, false);
// Check status, on error remove the redirect url
if ($response->status() != 200) {
return Controller::errorResponse($response->status(), $response->getData()->error);
}
$url = $response->getData()->redirectUrl;
// Store state+nonce in Roundcube database (for the kolab plugin)
// for request origin validation and token validation there
// Get the code from the URL
parse_str(parse_url($url, \PHP_URL_QUERY), $query);
Roundcube::cacheSet(
'helpdesk.' . md5($query['code']),
[
'state' => $request->state,
'nonce' => $request->nonce,
],
30 // TTL
);
// Tell the kolab plugin that the request origin is helpdesk mode, it will read
// the cache entry and make sure the token is accepted by Roundcube OAuth code.
$response->setData([
'redirectUrl' => $url . '&helpdesk=1',
'status' => 'success',
]);
return $response;
}
}
diff --git a/src/resources/vue/Authorize.vue b/src/resources/vue/Authorize.vue
index 58793264..cfea75cf 100644
--- a/src/resources/vue/Authorize.vue
+++ b/src/resources/vue/Authorize.vue
@@ -1,114 +1,115 @@
<template>
<div class="container d-flex flex-column align-items-center justify-content-center" id="auth-container">
<div v-if="client" id="auth-form" class="card col-sm-8 col-lg-6">
<div class="card-body p-4 text-center">
<h1 class="card-title mb-3">{{ $t('auth.authorize-title', client) }}</h1>
<div class="card-text m-2 mb-0">
<h6 id="auth-email" class="text-secondary mb-4">
<svg-icon icon="user" class="me-2"></svg-icon>{{ client.email }}
</h6>
<p id="auth-header">
{{ $t('auth.authorize-header', client) }}
</p>
<p>
<ul id="auth-claims" class="list-group text-start">
<li class="list-group-item" v-for="(item, idx) in client.claims" :key="idx">{{ item }}</li>
</ul>
</p>
<small id="auth-footer" class="text-secondary">
{{ $t('auth.authorize-footer', client) }}
</small>
<p class="mt-4">
<btn class="btn-success" icon="check" @click="allow">{{ $t('auth.allow') }}</btn>
<btn class="btn-danger ms-5" icon="xmark" @click="deny">{{ $t('auth.deny') }}</btn>
</p>
</div>
</div>
</div>
</div>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faXmark').definition,
)
export default {
data() {
return {
client: null,
}
},
created() {
this.submit(true)
},
methods: {
allow() {
this.submit()
},
deny() {
if (this.client.url) {
this.redirect(this.client.url, { error: 'access_denied', state: this.$route.query.state })
}
},
redirect(url, params) {
// Merge additional parameters with the URL (that can already contain a search query)
if (params) {
url = URL.parse(url)
const search = new URLSearchParams(url.searchParams)
for (const [k, v] of Object.entries(params)) {
search.set(k, v)
}
url.search = search
}
// 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 = url
},
submit(ifSeen = false) {
let props = ['client_id', 'redirect_uri', 'state', 'nonce', 'scope', 'response_type', 'response_mode']
let post = this.$root.pick(this.$route.query, props)
let redirect = null
post.ifSeen = ifSeen
axios.post('/api/oauth/approve', post, { loading: true })
.then(response => {
if (response.data.status == 'prompt') {
// Display the form with Allow/Deny buttons
this.client = response.data.client
this.client.email = this.$root.authInfo.email
} else {
// Redirect to the external page
redirect = response.data
}
})
.catch(error => {
- if (!(redirect = error.response.data)) {
+ redirect = error.response.data
+ if (!redirect || !redirect.redirectUrl) {
this.$root.errorHandler(error)
}
})
.finally(() => {
if (redirect && redirect.redirectUrl) {
let params = this.$root.pick(redirect, ['error', 'error_description'])
params.state = this.$route.query.state
try {
params.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (e) {}
this.redirect(redirect.redirectUrl, params)
}
})
}
}
}
</script>
diff --git a/src/tests/Browser/AuthorizeTest.php b/src/tests/Browser/AuthorizeTest.php
index 85d34e64..6287dd10 100644
--- a/src/tests/Browser/AuthorizeTest.php
+++ b/src/tests/Browser/AuthorizeTest.php
@@ -1,104 +1,105 @@
<?php
namespace Tests\Browser;
use App\Auth\PassportClient;
use App\Utils;
use Illuminate\Support\Facades\Cache;
use Tests\Browser;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
class AuthorizeTest extends TestCaseDusk
{
private $client;
protected function setUp(): void
{
parent::setUp();
// Create a client for tests
+ $user = $this->getTestUser('john@kolab.org');
$this->client = PassportClient::firstOrCreate(
- ['id' => 'test'],
+ ['id' => 'test' . base64_encode(random_bytes(4))],
[
- 'user_id' => null,
+ 'user_id' => $user->id,
'name' => 'Test',
'secret' => '123',
'provider' => 'users',
'redirect' => Utils::serviceUrl('support'),
'personal_access_client' => 0,
'password_client' => 0,
'revoked' => false,
'allowed_scopes' => ['email', 'auth.token'],
]
);
}
protected function tearDown(): void
{
$this->client->delete();
parent::tearDown();
}
/**
* Test /oauth/authorize page
*/
public function testAuthorize(): void
{
$user = $this->getTestUser('john@kolab.org');
$url = '/oauth/authorize?' . http_build_query([
'client_id' => $this->client->id,
'response_type' => 'code',
'scope' => 'email auth.token',
'state' => 'state',
'redirect_uri' => $this->client->redirect,
]);
Cache::forget("oauth-seen-{$user->id}-{$this->client->id}");
$this->browse(function (Browser $browser) use ($url, $user) {
// Visit the page and expect logon form, then log in
$browser->visit($url)
->on(new Home())
->submitLogon($user->email, 'simple123');
// Expect the claims form
$browser->waitFor('#auth-form')
->assertSeeIn('#auth-form h1', "Test is asking for permission")
->assertSeeIn('#auth-email', $user->email)
->assertVisible('#auth-header')
->assertElementsCount('#auth-claims li', 2)
->assertSeeIn('#auth-claims li:nth-child(1)', "See your email address")
->assertSeeIn('#auth-claims li:nth-child(2)', "Have read and write access to")
->assertSeeIn('#auth-footer', $this->client->redirect)
->assertSeeIn('#auth-form button.btn-success', 'Allow access')
->assertSeeIn('#auth-form button.btn-danger', 'No, thanks');
// Click the "No, thanks" button
$browser->click('#auth-form button.btn-danger')
->waitForLocation('/support')
->assertScript("location.search.match(/^\\?error=access_denied&state=state/) !== null");
// Visit the page again and click the "Allow access" button
$browser->visit($url)
->waitFor('#auth-form button.btn-success')
->click('#auth-form button.btn-success')
->waitForLocation('/support')
->assertScript("location.search.match(/^\\?code=[a-f0-9]+&state=state/) !== null")
->pause(1000); // let the Support page refresh the session tokens before we proceed
// Visit the page and expect an immediate redirect
$browser->visit($url)
->waitForLocation('/support')
->assertScript("location.search.match(/^\\?code=[a-f0-9]+&state=state/) !== null")
->pause(1000); // let the Support page refresh the session token before we proceed
// Error handling (invalid response_type)
$browser->visit(str_replace('response_type=code', 'response_type=invalid', $url))
->waitForLocation('/support')
->assertScript("location.search.match(/^\\?error=unsupported_response_type&state=state/) !== null");
});
}
}
diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php
index 9bcdab40..63bd5566 100644
--- a/src/tests/Feature/Controller/AuthTest.php
+++ b/src/tests/Feature/Controller/AuthTest.php
@@ -1,670 +1,704 @@
<?php
namespace Tests\Feature\Controller;
use App\Auth\PassportClient;
use App\Domain;
use App\IP4Net;
use App\User;
use App\Utils;
use Tests\TestCase;
class AuthTest extends TestCase
{
private $expectedExpiry;
+ private $client;
/**
* Reset all authentication guards to clear any cache users
*/
protected function resetAuth()
{
$this->app['auth']->forgetGuards();
}
protected function setUp(): void
{
parent::setUp();
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestDomain('userscontroller.com');
$this->expectedExpiry = \config('auth.token_expiry_minutes') * 60;
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
$user = $this->getTestUser('john@kolab.org');
$user->setSettings([
'limit_geo' => null,
'password_expired' => null,
]);
}
protected function tearDown(): void
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestDomain('userscontroller.com');
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
$user = $this->getTestUser('john@kolab.org');
$user->setSettings([
'limit_geo' => null,
'password_expired' => null,
]);
+ if ($this->client) {
+ $this->client->delete();
+ }
+
parent::tearDown();
}
/**
* Test fetching current user info (/api/auth/info)
*/
public function testInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com', ['status' => User::STATUS_NEW]);
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$response = $this->get("api/auth/info");
$response->assertStatus(401);
$response = $this->actingAs($user)->get("api/auth/info");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($user->id, $json['id']);
$this->assertSame($user->email, $json['email']);
$this->assertSame(User::STATUS_NEW, $json['status']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(!isset($json['access_token']));
// Note: Details of the content are tested in testUserResponse()
}
/**
* Test fetching current user location (/api/auth/location)
*/
public function testLocation(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
// Authentication required
$response = $this->get("api/auth/location");
$response->assertStatus(401);
$headers = ['X-Client-IP' => '128.0.0.2'];
$response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('128.0.0.2', $json['ipAddress']);
$this->assertSame('', $json['countryCode']);
IP4Net::create([
'net_number' => '128.0.0.0',
'net_broadcast' => '128.255.255.255',
'net_mask' => 8,
'country' => 'US',
'rir_name' => 'test',
'serial' => 1,
]);
$response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('128.0.0.2', $json['ipAddress']);
$this->assertSame('US', $json['countryCode']);
}
/**
* Test /api/auth/login
*/
public function testLogin(): string
{
$user = $this->getTestUser('john@kolab.org');
// Request with no data
$response = $this->post("api/auth/login", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
// Request with invalid password
$post = ['email' => 'john@kolab.org', 'password' => 'wrong'];
$response = $this->post("api/auth/login", $post);
$response->assertStatus(401);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('Invalid username or password.', $json['message']);
// Valid user+password
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertTrue(!empty($json['access_token']));
$this->assertEqualsWithDelta($this->expectedExpiry, $json['expires_in'], 5);
$this->assertSame('bearer', $json['token_type']);
$this->assertSame($user->id, $json['id']);
$this->assertSame($user->email, $json['user']['email']);
$this->assertTrue(is_array($json['user']['statusInfo']));
$this->assertTrue(is_array($json['user']['settings']));
// Valid long password (255 chars)
$password = str_repeat('123abc789E', 25) . '12345';
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com', ['password' => $password]);
$post = ['email' => $user->email, 'password' => $password];
$response = $this->post("api/auth/login", $post);
$response->assertStatus(200);
// Valid user+password (upper-case)
$post = ['email' => 'John@Kolab.org', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertTrue(!empty($json['access_token']));
$this->assertEqualsWithDelta($this->expectedExpiry, $json['expires_in'], 5);
$this->assertSame('bearer', $json['token_type']);
// No user info in the response
$post['mode'] = 'fast';
$response = $this->post("api/auth/login", $post);
$json = $response->json();
$this->assertTrue(!empty($json['id']));
$this->assertTrue(!empty($json['access_token']));
$this->assertTrue(empty($json['user']));
// TODO: We have browser tests for 2FA but we should probably also test it here
return $json['access_token'];
}
/**
* Test service account login attempt
*/
public function testLoginServiceAccount(): void
{
$user = $this->getTestUser('cyrus-admin');
$user->role = User::ROLE_SERVICE;
$user->password = 'simple123';
$user->save();
// Request with service account
$post = ['email' => 'cyrus-admin', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$response->assertStatus(401);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('Invalid username or password.', $json['message']);
}
/**
* Test /api/auth/login with geo-lockin
*/
public function testLoginGeoLock(): void
{
$user = $this->getTestUser('john@kolab.org');
$user->setSetting('limit_geo', json_encode(['US']));
$headers['X-Client-IP'] = '128.0.0.2';
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$response = $this->withHeaders($headers)->post("api/auth/login", $post);
$response->assertStatus(401);
$json = $response->json();
$this->assertSame("Invalid username or password.", $json['message']);
$this->assertSame('error', $json['status']);
IP4Net::create([
'net_number' => '128.0.0.0',
'net_broadcast' => '128.255.255.255',
'net_mask' => 8,
'country' => 'US',
'rir_name' => 'test',
'serial' => 1,
]);
$response = $this->withHeaders($headers)->post("api/auth/login", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(!empty($json['access_token']));
$this->assertSame($user->id, $json['id']);
}
/**
* Test /api/auth/logout
*
* @depends testLogin
*/
public function testLogout($token): void
{
// Request with no token, testing that it requires auth
$response = $this->post("api/auth/logout");
$response->assertStatus(401);
// Test the same using JSON mode
$response = $this->json('POST', "api/auth/logout", []);
$response->assertStatus(401);
// Request with invalid token
$response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout");
$response->assertStatus(401);
// Request with valid token
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('Successfully logged out.', $json['message']);
$this->resetAuth();
// Check if it really destroyed the token?
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info");
$response->assertStatus(401);
}
/**
* Test /api/auth/refresh
*/
public function testRefresh(): void
{
// Test refresh token requirement
$response = $this->post("api/auth/refresh", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame(['refresh_token' => ['The refresh token field is required.']], $json['errors']);
// Login the user to get a valid token
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$response->assertStatus(200);
$json = $response->json();
$token = $json['access_token'];
$user = $this->getTestUser('john@kolab.org');
// Request with a valid token (include user info in the response)
$post = ['refresh_token' => $json['refresh_token'], 'info' => 1];
$response = $this->post("api/auth/refresh", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($user->email, $json['user']['email']);
$this->assertTrue(is_array($json['user']['statusInfo']));
$this->assertTrue(is_array($json['user']['settings']));
$this->assertTrue($json['access_token'] != $token);
$this->assertEqualsWithDelta($this->expectedExpiry, $json['expires_in'], 5);
$this->assertSame('bearer', $json['token_type']);
$new_token = $json['access_token'];
$new_refresh_token = $json['refresh_token'];
// The old token should not work anymore
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info");
$response->assertStatus(401);
// Check if the new token is working
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info");
$response->assertStatus(200);
}
/**
* Test OAuth2 Authorization Code Flow
*/
public function testOAuthAuthorizationCodeFlow(): void
{
$user = $this->getTestUser('john@kolab.org');
// Request unauthenticated, testing that it requires auth
$response = $this->post("api/oauth/approve");
$response->assertStatus(401);
// Request authenticated, invalid POST data
$post = ['response_type' => 'unknown'];
$response = $this->actingAs($user)->post("api/oauth/approve", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('unsupported_response_type', $json['error']);
$this->assertSame('Invalid authorization request.', $json['message']);
// Request authenticated, invalid POST data
$post = [
'client_id' => 'unknown',
'response_type' => 'code',
'scope' => 'email', // space-separated
'state' => 'state', // optional
'nonce' => 'nonce', // optional
];
$response = $this->actingAs($user)->post("api/oauth/approve", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('invalid_client', $json['error']);
$this->assertSame('Client authentication failed', $json['message']);
$client = PassportClient::find(\config('auth.synapse.client_id'));
$post['client_id'] = $client->id;
// Request authenticated, invalid scope
$post['scope'] = 'unknown';
$response = $this->actingAs($user)->post("api/oauth/approve", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('invalid_scope', $json['error']);
$this->assertSame('The requested scope is invalid, unknown, or malformed', $json['message']);
// Request authenticated, valid POST data
$post['scope'] = 'email';
$response = $this->actingAs($user)->post("api/oauth/approve", $post);
$response->assertStatus(200);
$json = $response->json();
$url = $json['redirectUrl'];
parse_str(parse_url($url, \PHP_URL_QUERY), $params);
$this->assertTrue(str_starts_with($url, $client->redirect . '?'));
$this->assertCount(2, $params);
$this->assertSame('state', $params['state']);
$this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']);
$this->assertSame('success', $json['status']);
// Note: We do not validate the code trusting Passport to do the right thing. Should we not?
// Token endpoint tests
// Valid authorization code, but invalid secret
$post = [
'grant_type' => 'authorization_code',
'client_id' => $client->id,
'client_secret' => 'invalid',
// 'redirect_uri' => '',
'code' => $params['code'],
];
// Note: This is a 'web' route, not 'api'
$this->resetAuth(); // reset guards
$response = $this->post("/oauth/token", $post);
$response->assertStatus(401);
$json = $response->json();
$this->assertSame('invalid_client', $json['error']);
$this->assertTrue(!empty($json['error_description']));
// Valid authorization code
$post['client_secret'] = \config('auth.synapse.client_secret');
$response = $this->post("/oauth/token", $post);
$response->assertStatus(200);
$params = $response->json();
$this->assertSame('Bearer', $params['token_type']);
$this->assertTrue(!empty($params['access_token']));
$this->assertTrue(!empty($params['refresh_token']));
$this->assertTrue(!empty($params['expires_in']));
$this->assertTrue(empty($params['id_token']));
// Invalid authorization code
// Note: The code is being revoked on use, so we expect it does not work anymore
$response = $this->post("/oauth/token", $post);
$response->assertStatus(400);
$json = $response->json();
$this->assertSame('invalid_request', $json['error']);
$this->assertTrue(!empty($json['error_description']));
// Token refresh
unset($post['code']);
$post['grant_type'] = 'refresh_token';
$post['refresh_token'] = $params['refresh_token'];
$response = $this->post("/oauth/token", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('Bearer', $json['token_type']);
$this->assertTrue(!empty($json['access_token']));
$this->assertTrue(!empty($json['refresh_token']));
$this->assertTrue(!empty($json['expires_in']));
$this->assertTrue(empty($json['id_token']));
$this->assertNotSame($json['access_token'], $params['access_token']);
$this->assertNotSame($json['refresh_token'], $params['refresh_token']);
$token = $json['access_token'];
// Validate the access token works on /oauth/userinfo endpoint
$this->resetAuth(); // reset guards
$headers = ['Authorization' => 'Bearer ' . $token];
$response = $this->withHeaders($headers)->get("/oauth/userinfo");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($user->id, $json['sub']);
$this->assertSame($user->email, $json['email']);
// Validate that the access token does not give access to API other than /oauth/userinfo
$this->resetAuth(); // reset guards
$response = $this->withHeaders($headers)->get("/api/auth/location");
$response->assertStatus(403);
}
/**
* Test Oauth approve end-point in ifSeen mode
*/
public function testOAuthApprovePrompt(): void
{
// HTTP_HOST is not set in tests for some reason, but it's required down the line
$host = parse_url(Utils::serviceUrl('/'), \PHP_URL_HOST);
$_SERVER['HTTP_HOST'] = $host;
+ // Test trusted client
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$client = PassportClient::find(\config('auth.sso.client_id'));
$post = [
'client_id' => $client->id,
'response_type' => 'code',
'scope' => 'openid email auth.token',
'state' => 'state',
'nonce' => 'nonce',
'ifSeen' => '1',
];
$response = $this->actingAs($user)->post("api/oauth/approve", $post);
$response->assertStatus(200);
$json = $response->json();
+ $this->assertSame('success', $json['status']);
+ $this->assertTrue(!empty($json['redirectUrl']));
+
+ // Test non-trusted client
+ $this->client = PassportClient::firstOrCreate(
+ ['id' => 'test' . base64_encode(random_bytes(4))],
+ [
+ 'user_id' => $user->id,
+ 'name' => 'Test',
+ 'secret' => '123',
+ 'provider' => 'users',
+ 'redirect' => Utils::serviceUrl('support'),
+ 'personal_access_client' => 0,
+ 'password_client' => 0,
+ 'revoked' => false,
+ 'allowed_scopes' => ['email', 'auth.token', 'openid'],
+ ]
+ );
+
+ $post['client_id'] = $this->client->id;
+ $post['scope'] = 'openid email auth.token';
+
+ $response = $this->actingAs($user)->post("api/oauth/approve", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
$claims = [
+ 'openid' => 'See your email/id via a standard authorization token (OIDC)',
'email' => 'See your email address',
'auth.token' => 'Have read and write access to all your data',
];
$this->assertSame('prompt', $json['status']);
- $this->assertSame($client->name, $json['client']['name']);
- $this->assertSame($client->redirect, $json['client']['url']);
+ $this->assertSame($this->client->name, $json['client']['name']);
+ $this->assertSame($this->client->redirect, $json['client']['url']);
$this->assertSame($claims, $json['client']['claims']);
// Approve the request
$post['ifSeen'] = 0;
$response = $this->actingAs($user)->post("api/oauth/approve", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertTrue(!empty($json['redirectUrl']));
// Second request with ifSeen=1 should succeed with the code
$post['ifSeen'] = 1;
$response = $this->actingAs($user)->post("api/oauth/approve", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertTrue(!empty($json['redirectUrl']));
}
/**
* Test OpenID-Connect Authorization Code Flow
*/
public function testOIDCAuthorizationCodeFlow(): void
{
// HTTP_HOST is not set in tests for some reason, but it's required down the line
$host = parse_url(Utils::serviceUrl('/'), \PHP_URL_HOST);
$_SERVER['HTTP_HOST'] = $host;
$user = $this->getTestUser('john@kolab.org');
$client = 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 scopes
$post = [
'client_id' => $client->id,
'response_type' => 'code',
'scope' => 'openid email auth.token',
'state' => 'state',
'nonce' => 'nonce',
];
$response = $this->actingAs($user)->post("api/oauth/approve", $post);
$response->assertStatus(200);
$json = $response->json();
$url = $json['redirectUrl'];
parse_str(parse_url($url, \PHP_URL_QUERY), $params);
$this->assertTrue(str_starts_with($url, $client->redirect . '?'));
$this->assertCount(2, $params);
$this->assertSame('state', $params['state']);
$this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']);
$this->assertSame('success', $json['status']);
// Token endpoint tests
$post = [
'grant_type' => 'authorization_code',
'client_id' => $client->id,
'client_secret' => \config('auth.sso.client_secret'),
'code' => $params['code'],
];
$this->resetAuth(); // reset guards state
$response = $this->post("/oauth/token", $post);
$response->assertStatus(200);
$params = $response->json();
$this->assertSame('Bearer', $params['token_type']);
$this->assertTrue(!empty($params['access_token']));
$this->assertTrue(!empty($params['refresh_token']));
$this->assertTrue(!empty($params['id_token']));
$this->assertTrue(!empty($params['expires_in']));
$token = $this->parseIdToken($params['id_token']);
$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
// Token refresh
unset($post['code']);
$post['grant_type'] = 'refresh_token';
$post['refresh_token'] = $params['refresh_token'];
$response = $this->post("/oauth/token", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('Bearer', $json['token_type']);
$this->assertTrue(!empty($json['access_token']));
$this->assertTrue(!empty($json['refresh_token']));
$this->assertTrue(!empty($json['id_token']));
$this->assertTrue(!empty($json['expires_in']));
// Validate the access token works on /oauth/userinfo endpoint
$this->resetAuth(); // reset guards state
$headers = ['Authorization' => 'Bearer ' . $json['access_token']];
$response = $this->withHeaders($headers)->get("/oauth/userinfo");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($user->id, $json['sub']);
$this->assertSame($user->email, $json['email']);
// Validate that the access token does not give access to API other than /oauth/userinfo
$this->resetAuth(); // reset guards state
$response = $this->withHeaders($headers)->get("/api/auth/location");
$response->assertStatus(403);
}
/**
* Test to make sure Passport routes are disabled
*/
public function testPassportDisabledRoutes(): void
{
$this->post("/oauth/authorize", [])->assertStatus(405);
$this->post("/oauth/token/refresh", [])->assertStatus(405);
}
/**
* Parse JWT token into an array
*/
private function parseIdToken($token): array
{
[$headb64, $bodyb64, $cryptob64] = explode('.', $token);
$header = json_decode(base64_decode(strtr($headb64, '-_', '+/'), true), true);
$body = json_decode(base64_decode(strtr($bodyb64, '-_', '+/'), true), true);
return array_merge($header, $body);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Apr 4, 9:41 AM (3 w, 5 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18823511
Default Alt Text
(43 KB)

Event Timeline