diff --git a/bin/podman_shared b/bin/podman_shared --- a/bin/podman_shared +++ b/bin/podman_shared @@ -293,9 +293,12 @@ podman__run_synapse() { $PODMAN run -dt --pod $POD --name $POD-synapse --replace \ - $SYNAPSE_STORAGE \ + $SYNAPSE_STORAGE \ + -v $CERTS_PATH:/etc/certs:ro \ -e APP_DOMAIN \ -e KOLAB_URL="http://127.0.0.1:8000" \ + -e SYNAPSE_OAUTH_CLIENT_ID="${PASSPORT_SYNAPSE_OAUTH_CLIENT_ID:?"missing env variable"}" \ + -e SYNAPSE_OAUTH_CLIENT_SECRET="${PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET:?"missing env variable"}" \ synapse:latest } diff --git a/ci/env b/ci/env --- a/ci/env +++ b/ci/env @@ -152,6 +152,8 @@ APP_KEY=base64:EFXja/fHF01EMKiXW200b5zWOynbPzAHfUM78bOp+28= PASSPORT_PROXY_OAUTH_CLIENT_ID=5909ca4f-df7e-45fe-b355-e7c195aef117 PASSPORT_PROXY_OAUTH_CLIENT_SECRET=3URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM= +PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=2909ca4f-df7e-45fe-b355-e7c195aef112 +PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=2URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM= DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/ KOLAB_GIT_REF=master diff --git a/ci/testctl b/ci/testctl --- a/ci/testctl +++ b/ci/testctl @@ -112,7 +112,8 @@ export MARIADB_STORAGE=--mount=type=tmpfs,tmpfs-size=512M,destination=/var/lib/mysql,U=true export REDIS_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/var/lib/redis,U=true export MINIO_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/data,U=true - +export PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=2909ca4f-df7e-45fe-b355-e7c195aef112 +export PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=2URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM= export PODMAN_IGNORE_CGROUPSV1_WARNING=true 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 @@ -30,5 +30,20 @@ ]); $client->id = \config('auth.proxy.client_id'); $client->save(); + + // Create a client for synapse oauth + $client = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => "Synapse oauth client", + 'secret' => \config('auth.synapse.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain') . "/_synapse/client/oidc/callback", + 'personal_access_client' => 0, + 'password_client' => 0, + 'revoked' => false, + '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 @@ -30,5 +30,20 @@ ]); $client->id = \config('auth.proxy.client_id'); $client->save(); + + // Create a client for synapse oauth + $client = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => "Synapse oauth client", + 'secret' => \config('auth.synapse.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain') . "/_synapse/client/oidc/callback", + 'personal_access_client' => 0, + 'password_client' => 0, + 'revoked' => false, + 'allowed_scopes' => ['email'], + ]); + $client->id = \config('auth.synapse.client_id'); + $client->save(); } } diff --git a/docker/synapse/Dockerfile b/docker/synapse/Dockerfile --- a/docker/synapse/Dockerfile +++ b/docker/synapse/Dockerfile @@ -20,14 +20,14 @@ openssl-devel \ sed \ wget && \ - pip3 install matrix-synapse && \ + pip3 install matrix-synapse authlib && \ dnf clean all COPY /rootfs / RUN id default || (groupadd -g 1001 default && useradd -d /opt/app-root/ -u 1001 -g 1001 default) -RUN PATHS=(/opt/app-root/src) && \ +RUN PATHS=(/opt/app-root/src /etc/pki/ca-trust/extracted/ /etc/pki/ca-trust/source/anchors/) && \ mkdir -p ${PATHS[@]} && \ chmod -R 777 ${PATHS[@]} && \ chown -R 1001:0 ${PATHS[@]} && \ diff --git a/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml b/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml --- a/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml +++ b/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml @@ -109,22 +109,32 @@ federation_domain_whitelist: - APP_DOMAIN -# oidc_providers: -# - idp_id: kolab -# idp_name: "Kolab" -# issuer: "https://127.0.0.1:8443/auth/realms/{realm_name}" -# client_id: "synapse" -# client_secret: "copy secret generated from above" -# scopes: ["openid", "profile"] -# user_mapping_provider: -# config: -# localpart_template: "\{\{ user.preferred_username }}" -# display_name_template: "\{\{ user.name }}" +sso: + client_whitelist: + - https://APP_DOMAIN/ + update_profile_information: true + +oidc_providers: + - idp_id: kolab + idp_name: "Kolab" + discover: false + issuer: "https://APP_DOMAIN" + authorization_endpoint: "https://APP_DOMAIN/authorize" + #These connections go over localhost, but must still be https (otherwise it doesn't work). Also the certificate must match, so we can't use 127.0.0.1. + token_endpoint: "https://APP_DOMAIN:6443/oauth/token" + userinfo_endpoint: "https://APP_DOMAIN:6443/api/oauth/info" + client_id: "SYNAPSE_OAUTH_CLIENT_ID" + client_secret: "SYNAPSE_OAUTH_CLIENT_SECRET" + client_auth_method: client_secret_post + allow_existing_users: true + allow_registration: false + scopes: ['oauth'] + user_mapping_provider: + config: + subject_claim: "id" + email_template: "{{ user.email }}" + display_name_template: "{{ user.settings.first_name }}" -modules: -- module: kolab_auth_provider.KolabAuthProvider - config: - kolab_url: "KOLAB_URL" ## API Configuration ## diff --git a/docker/synapse/rootfs/opt/app-root/src/init.sh b/docker/synapse/rootfs/opt/app-root/src/init.sh --- a/docker/synapse/rootfs/opt/app-root/src/init.sh +++ b/docker/synapse/rootfs/opt/app-root/src/init.sh @@ -1,11 +1,18 @@ #!/bin/bash set -e +if [[ -f /etc/certs/ca.cert ]]; then + cp /etc/certs/ca.cert /etc/pki/ca-trust/source/anchors/ + update-ca-trust +fi + sed -i -r \ -e "s|APP_DOMAIN|$APP_DOMAIN|g" \ -e "s|KOLAB_URL|$KOLAB_URL|g" \ -e "s|TURN_SHARED_SECRET|$TURN_SHARED_SECRET|g" \ -e "s|TURN_URIS|$TURN_URIS|g" \ + -e "s|SYNAPSE_OAUTH_CLIENT_ID|$SYNAPSE_OAUTH_CLIENT_ID|g" \ + -e "s|SYNAPSE_OAUTH_CLIENT_SECRET|$SYNAPSE_OAUTH_CLIENT_SECRET|g" \ /opt/app-root/src/homeserver.yaml exec synctl --no-daemonize start ${CONFIGFILE:-/opt/app-root/src/homeserver.yaml} diff --git a/kolabctl b/kolabctl --- a/kolabctl +++ b/kolabctl @@ -110,6 +110,16 @@ echo "PASSPORT_PROXY_OAUTH_CLIENT_SECRET=${PASSPORT_PROXY_OAUTH_CLIENT_SECRET}" >> src/.env fi + if ! grep -q "PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=" src/.env; then + PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=$(uuidgen); + echo "PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=${PASSPORT_SYNAPSE_OAUTH_CLIENT_ID}" >> src/.env + fi + + if ! grep -q "PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=" src/.env; then + PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=$(openssl rand -base64 32); + echo "PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=${PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET}" >> src/.env + fi + if ! grep -q "PASSPORT_PUBLIC_KEY=|PASSPORT_PRIVATE_KEY=" src/.env; then PASSPORT_PRIVATE_KEY=$(openssl genrsa 4096); echo "PASSPORT_PRIVATE_KEY=\"${PASSPORT_PRIVATE_KEY}\"" >> src/.env 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 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 @@ +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 @@ -9,6 +9,9 @@ use Illuminate\Support\Facades\Validator; use Laravel\Passport\TokenRepository; use Laravel\Passport\RefreshTokenRepository; +use League\OAuth2\Server\AuthorizationServer; +use Psr\Http\Message\ServerRequestInterface; +use Nyholm\Psr7\Response as Psr7Response; class AuthController extends Controller { @@ -86,6 +89,83 @@ return self::logonResponse($user, $request->password, $request->secondfactor); } + /** + * 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 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 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 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: 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); + } + /** * Get the user (geo) location * 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,7 +16,7 @@ */ public function register(): void { - Passport::enablePasswordGrant(); + // 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,11 +17,18 @@ */ public function boot() { - Passport::tokensCan([ + parent::boot(); + + // Passport::ignoreRoutes() is in the AppServiceProvider + Passport::enablePasswordGrant(); + + $scopes = [ 'api' => 'Access API', 'mfa' => 'Access MFA API', 'fs' => 'Access Files 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'))); @@ -31,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/auth.php b/src/config/auth.php --- a/src/config/auth.php +++ b/src/config/auth.php @@ -135,6 +135,11 @@ 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), ], + 'synapse' => [ + 'client_id' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_ID'), + 'client_secret' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET'), + ], + 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; 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 @@ -1,4 +1,5 @@ import LoginComponent from '../../vue/Login' +import AuthorizeComponent from '../../vue/Authorize' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' @@ -85,6 +86,12 @@ component: FileListComponent, meta: { requiresAuth: true, perm: 'files' } }, + { + path: '/oauth/authorize', + name: 'authorize', + component: AuthorizeComponent, + meta: { requiresAuth: true } + }, { path: '/login', name: 'login', 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,31 @@ + + + + diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -15,6 +15,9 @@ | */ +Route::post('oauth/approve', [API\AuthController::class, 'oauthApprove']) + ->middleware(['auth:api']); + Route::group( [ 'middleware' => 'api', @@ -24,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"); + } +}