Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117758277
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
43 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
rK kolab
Attached
Detach File
Event Timeline