Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F15416427
D4866.id13941.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Size
38 KB
Referenced Files
None
Subscribers
None
D4866.id13941.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Fri, Sep 20, 11:41 PM (21 h, 53 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
9462528
Default Alt Text
D4866.id13941.diff (38 KB)
Attached To
Mode
D4866: OAuth fixes and tests
Attached
Detach File
Event Timeline
Log In to Comment