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 @@ +identifier = $identifier; + $this->user = User::findOrFail($identifier); + } + + /** + * When building the id_token, this entity's claims are collected + */ + public function getClaims(): array + { + // TODO: Other clains + // 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 @@ +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,80 @@ /** * 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')); } - //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: Support various response_mode modes - # This will generate a 302 redirect to the redirect_uri with the generated authorization code - return $server->completeAuthorizationRequest($authRequest, new Psr7Response()); + 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: 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()->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 @@ -5,10 +5,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 +18,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 +39,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 @@ + [ + + /** + * 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 @@ + + + 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 @@ +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 @@ +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"); + } +}