Page MenuHomePhorge

D4866.id13933.diff
No OneTemporary

D4866.id13933.diff

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
@@ -41,7 +41,7 @@
'personal_access_client' => 0,
'password_client' => 0,
'revoked' => false,
- 'allowed_scopes' => ['oauth'],
+ 'allowed_scopes' => ['email'],
]);
$client->id = \config('auth.synapse.client_id');
$client->save();
diff --git a/config.prod/src/database/seeds/PassportSeeder.php b/config.prod/src/database/seeds/PassportSeeder.php
--- a/config.prod/src/database/seeds/PassportSeeder.php
+++ b/config.prod/src/database/seeds/PassportSeeder.php
@@ -41,7 +41,7 @@
'personal_access_client' => 0,
'password_client' => 0,
'revoked' => false,
- 'allowed_scopes' => ['oauth'],
+ 'allowed_scopes' => ['email'],
]);
$client->id = \config('auth.synapse.client_id');
$client->save();
diff --git a/src/app/Auth/IdentityEntity.php b/src/app/Auth/IdentityEntity.php
new file mode 100644
--- /dev/null
+++ b/src/app/Auth/IdentityEntity.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Auth;
+
+use App\User;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use OpenIDConnect\Claims\Traits\WithClaims;
+use OpenIDConnect\Interfaces\IdentityEntityInterface;
+
+class IdentityEntity implements IdentityEntityInterface
+{
+ use EntityTrait;
+ use WithClaims;
+
+ /**
+ * The user to collect the additional information for
+ */
+ protected User $user;
+
+ /**
+ * The identity repository creates this entity and provides the user id
+ * @param mixed $identifier
+ */
+ public function setIdentifier($identifier): void
+ {
+ $this->identifier = $identifier;
+ $this->user = User::findOrFail($identifier);
+ }
+
+ /**
+ * When building the id_token, this entity's claims are collected
+ */
+ public function getClaims(): array
+ {
+ // TODO: Other claims
+ // TODO: Should we use this in AuthController::oauthUserInfo() for some de-duplicaton?
+
+ return [
+ 'email' => $this->user->email,
+ ];
+ }
+}
diff --git a/src/app/Auth/IdentityRepository.php b/src/app/Auth/IdentityRepository.php
new file mode 100644
--- /dev/null
+++ b/src/app/Auth/IdentityRepository.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Auth;
+
+use OpenIDConnect\Interfaces\IdentityEntityInterface;
+use OpenIDConnect\Interfaces\IdentityRepositoryInterface;
+
+class IdentityRepository implements IdentityRepositoryInterface
+{
+ public function getByIdentifier(string $identifier): IdentityEntityInterface
+ {
+ $identityEntity = new IdentityEntity();
+ $identityEntity->setIdentifier($identifier);
+
+ return $identityEntity;
+ }
+}
diff --git a/src/app/Auth/PassportClient.php b/src/app/Auth/PassportClient.php
--- a/src/app/Auth/PassportClient.php
+++ b/src/app/Auth/PassportClient.php
@@ -16,9 +16,7 @@
/**
* The allowed scopes for tokens instantiated by this client
- *
- * @return Array
- * */
+ */
public function getAllowedScopes(): array
{
if ($this->allowed_scopes) {
diff --git a/src/app/Console/Kernel.php b/src/app/Console/Kernel.php
--- a/src/app/Console/Kernel.php
+++ b/src/app/Console/Kernel.php
@@ -37,6 +37,9 @@
// https://laravel.com/docs/10.x/upgrade#redis-cache-tags
$schedule->command('cache:prune-stale-tags')->hourly();
+
+ // This removes passport expired/revoked tokens and auth codes from the database
+ $schedule->command('passport:purge')->dailyAt('06:30');
}
/**
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
@@ -92,44 +92,78 @@
/**
* Approval request for the oauth authorization endpoint
*
- *
* * The user is authenticated via the regular login page
* * We assume implicit consent in the Authorization page
* * Ultimately we return an authorization code to the caller via the redirect_uri
*
* The implementation is based on Laravel\Passport\Http\Controllers\AuthorizationController
*
- * @param \Illuminate\Http\Request $request The API request.
+ * @param ServerRequestInterface $psrRequest PSR request
+ * @param \Illuminate\Http\Request $request The API request
+ * @param AuthorizationServer $server Authorization server
*
* @return \Illuminate\Http\JsonResponse
*/
- public function oauthApprove(
- ServerRequestInterface $psrRequest,
- Request $request,
- AuthorizationServer $server
- ) {
- if ($request->response_type != "code") {
- return response()->json(['status' => 'error', 'errors' => ["invalid response_type"]], 422);
- }
-
- $user = Auth::guard()->user();
- if (!$user) {
- \Log::warning("User not found");
- return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401);
+ public function oauthApprove(ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server)
+ {
+ if ($request->response_type != 'code') {
+ return self::errorResponse(422, self::trans('validation.invalidvalueof', ['attribute' => 'response_type']));
}
try {
+ // league/oauth2-server/src/Grant/ code expects GET parameters, but we're using POST here
+ $psrRequest = $psrRequest->withQueryParams($request->input());
+
$authRequest = $server->validateAuthorizationRequest($psrRequest);
+
+ $user = Auth::guard()->user();
+
+ // 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());
+ } catch (\League\OAuth2\Server\Exception\OAuthServerException $e) {
+ // Note: We don't want 401 or 400 codes here, use 422 which is used in our API
+ $code = $e->getHttpStatusCode();
+ return self::errorResponse($code < 500 ? 422 : 500, $e->getMessage());
} catch (\Exception $e) {
- return response()->json(['status' => 'error', 'message' => "Failed to validate"], 401);
+ return self::errorResponse(422, self::trans('auth.error.invalidrequest'));
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'redirectUrl' => $response->getHeader('Location')[0],
+ ]);
+ }
+
+ /**
+ * Get the authenticated User information (using access token claims)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function oauthUserInfo()
+ {
+ $user = Auth::guard()->user();
+
+ $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();
}
- //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);
+ // 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
- # This will generate a 302 redirect to the redirect_uri with the generated authorization code
- return $server->completeAuthorizationRequest($authRequest, new Psr7Response());
+ return response()->json($response);
}
/**
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -16,6 +16,8 @@
*/
public function register(): void
{
+ // This must be here, not in PassportServiceProvider
+ Passport::ignoreRoutes();
}
/**
diff --git a/src/app/Providers/PassportServiceProvider.php b/src/app/Providers/PassportServiceProvider.php
--- a/src/app/Providers/PassportServiceProvider.php
+++ b/src/app/Providers/PassportServiceProvider.php
@@ -4,11 +4,11 @@
use Defuse\Crypto\Key as EncryptionKey;
use Defuse\Crypto\Encoding as EncryptionEncoding;
-use League\OAuth2\Server\AuthorizationServer;
-use Laravel\Passport\Passport;
use Laravel\Passport\Bridge;
+use Laravel\Passport\Passport;
+use OpenIDConnect\Laravel\PassportServiceProvider as ServiceProvider;
-class PassportServiceProvider extends \Laravel\Passport\PassportServiceProvider
+class PassportServiceProvider extends ServiceProvider
{
/**
* Register any authentication / authorization services.
@@ -17,15 +17,18 @@
*/
public function boot()
{
+ parent::boot();
+
+ // Passport::ignoreRoutes() is in the AppServiceProvider
Passport::enablePasswordGrant();
- Passport::ignoreRoutes();
- Passport::tokensCan([
+ $scopes = [
'api' => 'Access API',
'mfa' => 'Access MFA API',
'fs' => 'Access Files API',
- 'oauth' => 'Access OAUTH API',
- ]);
+ ];
+
+ Passport::tokensCan(array_merge($scopes, \config('openid.passport.tokens_can')));
Passport::tokensExpireIn(now()->addMinutes(\config('auth.token_expiry_minutes')));
Passport::refreshTokensExpireIn(now()->addMinutes(\config('auth.refresh_token_expiry_minutes')));
@@ -35,32 +38,15 @@
Passport::tokenModel()::observe(\App\Observers\Passport\TokenObserver::class);
}
- /**
- * Make the authorization service instance.
- *
- * @return \League\OAuth2\Server\AuthorizationServer
- */
- public function makeAuthorizationServer()
- {
- return new AuthorizationServer(
- $this->app->make(Bridge\ClientRepository::class),
- $this->app->make(Bridge\AccessTokenRepository::class),
- $this->app->make(Bridge\ScopeRepository::class),
- $this->makeCryptKey('private'),
- $this->makeEncryptionKey(app('encrypter')->getKey())
- );
- }
-
-
/**
* Create a Key instance for encrypting the refresh token
*
* Based on https://github.com/laravel/passport/pull/820
*
* @param string $keyBytes
- * @return \Defuse\Crypto\Key
+ * @return \Defuse\Crypto\Key|string
*/
- private function makeEncryptionKey($keyBytes)
+ protected function getEncryptionKey($keyBytes)
{
// First, we will encode Laravel's encryption key into a format that the Defuse\Crypto\Key class can use,
// so we can instantiate a new Key object. We need to do this as the Key class has a private constructor method
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -85,6 +85,20 @@
return $start->diffInDays($end) + 1;
}
+ /**
+ * Default route handler
+ */
+ public static function defaultView()
+ {
+ // Return 404 for requests to the API end-points that do not exist
+ if (strpos(request()->path(), 'api/') === 0) {
+ return \App\Http\Controllers\Controller::errorResponse(404);
+ }
+
+ $env = self::uiEnv();
+ return view($env['view'])->with('env', $env);
+ }
+
/**
* Download a file from the interwebz and store it locally.
*
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -21,6 +21,7 @@
"dyrynda/laravel-nullable-fields": "^4.3.0",
"garethp/php-ews": "dev-master",
"guzzlehttp/guzzle": "^7.8.0",
+ "jeremy379/laravel-openid-connect": "^2.3",
"kolab/net_ldap3": "dev-master",
"laravel/framework": "^10.15.0",
"laravel/horizon": "^5.9",
@@ -39,7 +40,7 @@
},
"require-dev": {
"code-lts/doctum": "^5.5.1",
- "laravel/dusk": "~7.9.1",
+ "laravel/dusk": "~8.2.2",
"mockery/mockery": "^1.5",
"larastan/larastan": "^2.0",
"phpstan/phpstan": "^1.4",
diff --git a/src/config/openid.php b/src/config/openid.php
new file mode 100644
--- /dev/null
+++ b/src/config/openid.php
@@ -0,0 +1,85 @@
+<?php
+
+return [
+ 'passport' => [
+
+ /**
+ * Place your Passport and OpenID Connect scopes here.
+ * To receive an `id_token`, you should at least provide the openid scope.
+ */
+ 'tokens_can' => [
+ 'openid' => 'Enable OpenID Connect',
+ 'email' => 'Information about your email address',
+ // 'profile' => 'Information about your profile',
+ // 'phone' => 'Information about your phone numbers',
+ // 'address' => 'Information about your address',
+ // 'login' => 'See your login information',
+ ],
+ ],
+
+ /**
+ * Place your custom claim sets here.
+ */
+ 'custom_claim_sets' => [
+ // 'login' => [
+ // 'last-login',
+ // ],
+ // 'company' => [
+ // 'company_name',
+ // 'company_address',
+ // 'company_phone',
+ // 'company_email',
+ // ],
+ ],
+
+ /**
+ * You can override the repositories below.
+ */
+ 'repositories' => [
+ // 'identity' => \OpenIDConnect\Repositories\IdentityRepository::class,
+ 'identity' => \App\Auth\IdentityRepository::class,
+ ],
+
+ 'routes' => [
+ /**
+ * When set to true, this package will expose the OpenID Connect Discovery endpoint.
+ * - /.well-known/openid-configuration
+ */
+ 'discovery' => true,
+ /**
+ * When set to true, this package will expose the JSON Web Key Set endpoint.
+ */
+ 'jwks' => false,
+ /**
+ * Optional URL to change the JWKS path to align with your custom Passport routes.
+ * Defaults to /oauth/jwks
+ */
+ 'jwks_url' => '/oauth/jwks',
+ ],
+
+ /**
+ * Settings for the discovery endpoint
+ */
+ 'discovery' => [
+ /**
+ * Hide scopes that aren't from the OpenID Core spec from the Discovery,
+ * default = false (all scopes are listed)
+ */
+ 'hide_scopes' => false,
+ ],
+
+ /**
+ * The signer to be used
+ */
+ 'signer' => \Lcobucci\JWT\Signer\Rsa\Sha256::class,
+
+ /**
+ * Optional associative array that will be used to set headers on the JWT
+ */
+ 'token_headers' => [],
+
+ /**
+ * By default, microseconds are included.
+ */
+ 'use_microseconds' => true,
+];
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -87,7 +87,7 @@
meta: { requiresAuth: true, perm: 'files' }
},
{
- path: '/authorize',
+ path: '/oauth/authorize',
name: 'authorize',
component: AuthorizeComponent,
meta: { requiresAuth: true }
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
@@ -19,6 +19,7 @@
'logoutsuccess' => 'Successfully logged out.',
'error.password' => "Invalid password",
+ 'error.invalidrequest' => "Invalid authorization request.",
'error.geolocation' => "Country code mismatch",
'error.nofound' => "User not found",
'error.2fa' => "Second factor failure",
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -130,6 +130,7 @@
'url' => 'The :attribute must be a valid URL.',
'uuid' => 'The :attribute must be a valid UUID.',
+ 'invalidvalueof' => 'Invalid value of request property: :attribute.',
'2fareq' => 'Second factor code is required.',
'2fainvalid' => 'Second factor code is invalid.',
'emailinvalid' => 'The specified email address is invalid.',
diff --git a/src/resources/vue/Authorize.vue b/src/resources/vue/Authorize.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Authorize.vue
@@ -0,0 +1,30 @@
+<template>
+ <div class="container">
+ </div>
+</template>
+
+<script>
+ export default {
+ created() {
+ // Just auto approve for now
+ // If we wanted we could use this page to list what is being authorized,
+ // and allow the user to approve/reject.
+ // Note that in case of SSO it is also expected that there's no user interaction at all,
+ // isn't it? Maybe we should show the details once per client_id+user_id combination.
+ this.submitApproval()
+ },
+ methods: {
+ submitApproval() {
+ let props = ['client_id', 'redirect_uri', 'state', 'nonce', 'scope', 'response_type', 'response_mode']
+ let post = this.$root.pick(this.$route.query, props)
+
+ axios.post('/api/oauth/approve', post, { loading: true })
+ .then(response => {
+ // Follow the redirect to the external page
+ window.location.href = response.data.redirectUrl;
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -15,26 +15,8 @@
|
*/
-Route::group(
- [
- 'middleware' => 'api',
- 'prefix' => 'oauth'
- ],
- function () {
- Route::group(
- ['middleware' => ['auth:api', 'scope:oauth']],
- function () {
- Route::get('info', [API\AuthController::class, 'info']);
- },
- );
- Route::group(
- ['middleware' => 'auth:api'],
- function () {
- Route::post('approve', [API\AuthController::class, 'oauthApprove']);
- }
- );
- }
-);
+Route::post('oauth/approve', [API\AuthController::class, 'oauthApprove'])
+ ->middleware(['auth:api']);
Route::group(
[
@@ -45,7 +27,7 @@
Route::post('login', [API\AuthController::class, 'login']);
Route::group(
- ['middleware' => 'auth:api'],
+ ['middleware' => ['auth:api', 'scope:api']],
function () {
Route::get('info', [API\AuthController::class, 'info']);
Route::post('info', [API\AuthController::class, 'info']);
diff --git a/src/routes/web.php b/src/routes/web.php
--- a/src/routes/web.php
+++ b/src/routes/web.php
@@ -2,6 +2,7 @@
use App\Http\Controllers;
use Illuminate\Support\Facades\Route;
+use Laravel\Passport\Http\Controllers as PassportControllers;
Route::get('204', function () {
return response()->noContent();
@@ -15,22 +16,12 @@
//'domain' => \config('app.website_domain'),
],
function () {
- Route::get('content/page/{page}', Controllers\ContentController::class . '@pageContent')
+ Route::get('content/page/{page}', [Controllers\ContentController::class. 'pageContent'])
->where('page', '(.*)');
- Route::get('content/faq/{page}', Controllers\ContentController::class . '@faqContent')
+ Route::get('content/faq/{page}', [Controllers\ContentController::class, 'faqContent'])
->where('page', '(.*)');
- Route::fallback(
- function () {
- // Return 404 for requests to the API end-points that do not exist
- if (strpos(request()->path(), 'api/') === 0) {
- return Controllers\Controller::errorResponse(404);
- }
-
- $env = \App\Utils::uiEnv();
- return view($env['view'])->with('env', $env);
- }
- );
+ Route::fallback([\App\Utils::class, 'defaultView']);
}
);
@@ -41,23 +32,26 @@
function () {
// We manually specify a subset of endpoints from https://github.com/laravel/passport/blob/11.x/routes/web.php
// after having disabled automatic routes via Passport::ignoreRoutes()
- Route::post('/token', [
- 'uses' => '\Laravel\Passport\Http\Controllers\AccessTokenController@issueToken',
- 'as' => 'token',
- // 'middleware' => 'throttle',
- ]);
+ Route::post('/token', [PassportControllers\AccessTokenController::class, 'issueToken'])
+ ->name('passport.token'); // needed for .well-known/openid-configuration handler
Route::middleware(['web', 'auth'])->group(function () {
- Route::get('/tokens', [
- 'uses' => '\Laravel\Passport\Http\Controllers\AuthorizedAccessTokenController@forUser',
- 'as' => 'tokens.index',
- ]);
+ Route::get('/tokens', [PassportControllers\AuthorizedAccessTokenController::class, 'forUser'])
+ ->name('passport.tokens.index');
- Route::delete('/tokens/{token_id}', [
- 'uses' => '\Laravel\Passport\Http\Controllers\AuthorizedAccessTokenController@destroy',
- 'as' => 'tokens.destroy',
- ]);
+ Route::delete('/tokens/{token_id}', [PassportControllers\AuthorizedAccessTokenController::class, 'destroy'])
+ ->name('passport.tokens.destroy');
});
+
+ // TODO: Enable CORS on this endpoint, it is "SHOULD" in OIDC spec.
+ // TODO: More scopes e.g. profile
+ // TODO: This should be both GET and POST per OIDC spec. GET is recommended though.
+ Route::get('/userinfo', [Controllers\API\AuthController::class, 'oauthUserInfo'])
+ ->middleware(['auth:api', 'scope:email'])
+ ->name('openid.userinfo'); // needed for .well-known/openid-configuration handler
+
+ Route::get('/authorize', [\App\Utils::class, 'defaultView'])
+ ->name('passport.authorizations.authorize'); // needed for .well-known/openid-configuration handler
}
);
@@ -66,6 +60,7 @@
'prefix' => '.well-known'
],
function () {
- Route::get('/mta-sts.txt', [Controllers\WellKnownController::class, "mtaSts"]);
+ // .well-known/openid-configuration is handled by an external package (see config/openid.php)
+ Route::get('/mta-sts.txt', [Controllers\WellKnownController::class, 'mtaSts']);
}
);
diff --git a/src/tests/Browser/AuthorizeTest.php b/src/tests/Browser/AuthorizeTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/AuthorizeTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Tests\Browser;
+
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class AuthorizeTest extends TestCaseDusk
+{
+ private $client;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // Create a client for tests
+ $this->client = \App\Auth\PassportClient::firstOrCreate(
+ ['id' => 'test'],
+ [
+ 'user_id' => null,
+ 'name' => 'Test',
+ 'secret' => '123',
+ 'provider' => 'users',
+ 'redirect' => 'https://kolab.org',
+ 'personal_access_client' => 0,
+ 'password_client' => 0,
+ 'revoked' => false,
+ 'allowed_scopes' => ['email'],
+ ]
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->client->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test /oauth/authorize page
+ */
+ public function testAuthorize(): void
+ {
+ $url = '/oauth/authorize?' . http_build_query([
+ 'client_id' => $this->client->id,
+ 'response_type' => 'code',
+ 'scope' => 'email',
+ 'state' => 'state',
+ ]);
+
+ $this->browse(function (Browser $browser) use ($url) {
+ $redirect_check = "window.location.host == 'kolab.org'"
+ . " && window.location.search.match(/^\?code=[a-f0-9]+&state=state/)";
+
+ // Unauthenticated user
+ $browser->visit($url)
+ ->on(new Home())
+ ->submitLogon('john@kolab.org', 'simple123')
+ ->waitUntil($redirect_check);
+
+ // Authenticated user
+ $browser->visit($url)
+ ->waitUntil($redirect_check);
+
+ // Error handling (invalid response_type)
+ $browser->visit('oauth/authorize?response_type=invalid')
+ ->assertErrorPage(422)
+ ->assertToast(Toast::TYPE_ERROR, 'Invalid value of request property: response_type.');
+ });
+ }
+}
diff --git a/src/tests/Browser/Components/Error.php b/src/tests/Browser/Components/Error.php
--- a/src/tests/Browser/Components/Error.php
+++ b/src/tests/Browser/Components/Error.php
@@ -16,6 +16,7 @@
403 => "Access denied",
404 => "Not found",
405 => "Method not allowed",
+ 422 => "Unprocessable Content",
500 => "Internal server error",
];
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
@@ -15,19 +15,7 @@
*/
protected function resetAuth()
{
- $guards = array_keys(config('auth.guards'));
-
- foreach ($guards as $guard) {
- $guard = $this->app['auth']->guard($guard);
-
- if ($guard instanceof \Illuminate\Auth\SessionGuard) {
- $guard->logout();
- }
- }
-
- $protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards');
- $protectedProperty->setAccessible(true);
- $protectedProperty->setValue($this->app['auth'], []);
+ $this->app['auth']->forgetGuards();
}
/**
@@ -326,4 +314,271 @@
$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('Invalid value of request property: response_type.', $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('Client authentication failed', $json['message']);
+
+ $client = \App\Auth\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('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->assertNotEquals($json['access_token'], $params['access_token']);
+ $this->assertNotEquals($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->assertEquals($user->id, $json['sub']);
+ $this->assertEquals($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 OpenID-Connect Authorization Code Flow
+ */
+ public function testOIDCAuthorizationCodeFlow(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $client = \App\Auth\PassportClient::find(\config('auth.synapse.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
+ $post = [
+ 'client_id' => $client->id,
+ 'response_type' => 'code',
+ 'scope' => 'openid email',
+ '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.synapse.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(url('/'), $token['iss']);
+ $this->assertSame($user->email, $token['email']);
+
+ // 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->assertEquals($user->id, $json['sub']);
+ $this->assertEquals($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);
+ }
}
diff --git a/src/tests/Feature/Controller/WellKnownTest.php b/src/tests/Feature/Controller/WellKnownTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/WellKnownTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use Tests\TestCase;
+
+class WellKnownTest extends TestCase
+{
+ /**
+ * Test ./well-known/openid-configuration
+ */
+ public function testOpenidConfiguration(): void
+ {
+ $href = 'https://' . \config('app.domain');
+
+ $response = $this->get('.well-known/openid-configuration');
+ $response->assertStatus(200)
+ ->assertJson([
+ 'issuer' => $href,
+ 'authorization_endpoint' => $href . '/oauth/authorize',
+ 'token_endpoint' => $href . '/oauth/token',
+ 'userinfo_endpoint' => $href . '/oauth/userinfo',
+ 'grant_types_supported' => [
+ 'authorization_code',
+ 'client_credentials',
+ 'refresh_token',
+ 'password',
+ ],
+ 'response_types_supported' => [
+ 'code'
+ ],
+ 'id_token_signing_alg_values_supported' => [
+ 'RS256'
+ ],
+ 'scopes_supported' => [
+ 'openid',
+ 'email',
+ ],
+ ]);
+ }
+
+ /**
+ * Test ./well-known/mta-sts.txt
+ */
+ public function testMtaSts(): void
+ {
+ $domain = \config('app.domain');
+
+ $response = $this->get('.well-known/mta-sts.txt');
+ $response->assertStatus(200)
+ ->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
+ ->assertContent("version: STSv1\nmode: enforce\nmx: {$domain}\nmax_age: 604800");
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Thu, Sep 19, 7:28 PM (49 m, 31 s)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
9462528
Default Alt Text
D4866.id13933.diff (38 KB)

Event Timeline