diff --git a/app/config/console.php b/app/config/console.php index bf1850182a9..865891e1721 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -57,7 +57,29 @@ 'oAuthProviders' => [ 'githubEnabled' => true, 'githubSecret' => System::getEnv('_APP_CONSOLE_GITHUB_SECRET', ''), - 'githubAppid' => System::getEnv('_APP_CONSOLE_GITHUB_APP_ID', '') + 'githubAppid' => System::getEnv('_APP_CONSOLE_GITHUB_APP_ID', ''), + // OIDC is only enabled when a client ID is provided together with a usable + // endpoint configuration: either a discovery well-known URL, or all three + // explicit endpoints (authorization, token, userinfo). Requiring at least + // one of these prevents advertising a login option that cannot redirect to + // the identity provider because every endpoint would resolve to an empty URL. + 'oidcEnabled' => !empty(System::getEnv('_APP_CONSOLE_OIDC_CLIENT_ID', '')) + && ( + !empty(System::getEnv('_APP_CONSOLE_OIDC_WELLKNOWN_ENDPOINT', '')) + || ( + !empty(System::getEnv('_APP_CONSOLE_OIDC_AUTHORIZATION_ENDPOINT', '')) + && !empty(System::getEnv('_APP_CONSOLE_OIDC_TOKEN_ENDPOINT', '')) + && !empty(System::getEnv('_APP_CONSOLE_OIDC_USERINFO_ENDPOINT', '')) + ) + ), + 'oidcAppid' => System::getEnv('_APP_CONSOLE_OIDC_CLIENT_ID', ''), + 'oidcSecret' => \json_encode([ + 'clientSecret' => System::getEnv('_APP_CONSOLE_OIDC_CLIENT_SECRET', ''), + 'authorizationEndpoint' => System::getEnv('_APP_CONSOLE_OIDC_AUTHORIZATION_ENDPOINT', ''), + 'tokenEndpoint' => System::getEnv('_APP_CONSOLE_OIDC_TOKEN_ENDPOINT', ''), + 'userinfoEndpoint' => System::getEnv('_APP_CONSOLE_OIDC_USERINFO_ENDPOINT', ''), + 'wellKnownEndpoint' => System::getEnv('_APP_CONSOLE_OIDC_WELLKNOWN_ENDPOINT', ''), + ]), ], 'smtpBaseTemplate' => APP_BRANDED_EMAIL_BASE_TEMPLATE, ]; diff --git a/app/config/variables.php b/app/config/variables.php index bd91706cf51..011dce6d9ad 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -232,6 +232,60 @@ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_CONSOLE_OIDC_CLIENT_ID', + 'description' => 'The client ID for the OIDC provider used to login to the Appwrite console.', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_OIDC_CLIENT_SECRET', + 'description' => 'The client secret for the OIDC provider used to login to the Appwrite console.', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_OIDC_AUTHORIZATION_ENDPOINT', + 'description' => 'The authorization endpoint for the OIDC provider used to login to the Appwrite console.', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_OIDC_TOKEN_ENDPOINT', + 'description' => 'The token endpoint for the OIDC provider used to login to the Appwrite console.', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_OIDC_USERINFO_ENDPOINT', + 'description' => 'The userinfo endpoint for the OIDC provider used to login to the Appwrite console.', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONSOLE_OIDC_WELLKNOWN_ENDPOINT', + 'description' => 'The well-known configuration endpoint for the OIDC provider used to login to the Appwrite console.', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], [ 'name' => '_APP_CONSOLE_URL_SCHEME', 'description' => 'Console URL scheme used when the backend generates links to the console (OAuth callbacks, emails, error page CTAs, VCS comments). Set to \'root\' for the new console served at the root path (appwrite/new), or \'legacy\' for the older console served under the /console path prefix. The default value is \'legacy\'.', diff --git a/tests/unit/Auth/OAuth2/OidcTest.php b/tests/unit/Auth/OAuth2/OidcTest.php new file mode 100644 index 00000000000..6a378de34cb --- /dev/null +++ b/tests/unit/Auth/OAuth2/OidcTest.php @@ -0,0 +1,200 @@ +createOidc(\json_encode([ + 'access_token' => 'access-token', + 'scope' => 'openid profile email', + 'token_type' => 'bearer', + ], JSON_THROW_ON_ERROR)); + + $this->assertSame('access-token', $oidc->getAccessToken('authorization-code')); + } + + public function testGetUserID(): void + { + $oidc = $this->createOidcUserInfo(\json_encode([ + 'sub' => 'user-id-123', + 'email' => 'user@example.com', + 'name' => 'John Doe' + ], JSON_THROW_ON_ERROR)); + + $this->assertSame('user-id-123', $oidc->getUserID('access-token')); + } + + public function testGetUserEmail(): void + { + $oidc = $this->createOidcUserInfo(\json_encode([ + 'sub' => 'user-id-123', + 'email' => 'user@example.com', + 'name' => 'John Doe' + ], JSON_THROW_ON_ERROR)); + + $this->assertSame('user@example.com', $oidc->getUserEmail('access-token')); + } + + public function testGetUserName(): void + { + $oidc = $this->createOidcUserInfo(\json_encode([ + 'sub' => 'user-id-123', + 'email' => 'user@example.com', + 'name' => 'John Doe' + ], JSON_THROW_ON_ERROR)); + + $this->assertSame('John Doe', $oidc->getUserName('access-token')); + } + + /** + * When all three explicit endpoints are configured, getLoginURL must use + * the authorizationEndpoint from the secret without hitting the network. + */ + public function testGetLoginUrlUsesExplicitAuthorizationEndpoint(): void + { + $oidc = new Oidc('client-id', $this->defaultSecret, 'https://example.com/callback'); + + $url = $oidc->getLoginURL(); + + $this->assertStringStartsWith('https://example.com/auth?', $url); + $this->assertStringContainsString('client_id=client-id', $url); + $this->assertStringContainsString('response_type=code', $url); + $this->assertStringContainsString('scope=openid+profile+email', $url); + } + + /** + * When only a well-known endpoint is provided (no explicit endpoints), + * getLoginURL must fetch the discovery document to resolve the + * authorization URL. Verify it calls the well-known URL exactly once + * and builds the redirect using the discovered authorization_endpoint. + */ + public function testGetLoginUrlFallsBackToWellKnownDiscovery(): void + { + $secret = \json_encode([ + 'clientSecret' => 'client-secret', + 'wellKnownEndpoint' => 'https://idp.example.com/.well-known/openid-configuration', + ], JSON_THROW_ON_ERROR); + + $discovery = \json_encode([ + 'authorization_endpoint' => 'https://idp.example.com/oauth2/authorize', + 'token_endpoint' => 'https://idp.example.com/oauth2/token', + 'userinfo_endpoint' => 'https://idp.example.com/oauth2/userinfo', + ], JSON_THROW_ON_ERROR); + + /** @var Oidc&MockObject $oidc */ + $oidc = $this->getMockBuilder(Oidc::class) + ->setConstructorArgs(['client-id', $secret, 'https://example.com/callback']) + ->onlyMethods(['request']) + ->getMock(); + + $oidc + ->expects($this->once()) + ->method('request') + ->with('GET', 'https://idp.example.com/.well-known/openid-configuration') + ->willReturn($discovery); + + $url = $oidc->getLoginURL(); + + $this->assertStringStartsWith('https://idp.example.com/oauth2/authorize?', $url); + $this->assertStringContainsString('client_id=client-id', $url); + } + + /** + * A secret with no endpoints at all (client ID only, no well-known, no + * explicit endpoints) must throw — confirming the incomplete-config guard + * in console.php is necessary to prevent advertising a broken login option. + */ + public function testGetLoginUrlThrowsWhenNoEndpointConfigured(): void + { + $secret = \json_encode(['clientSecret' => 'client-secret'], JSON_THROW_ON_ERROR); + + /** @var Oidc&MockObject $oidc */ + $oidc = $this->getMockBuilder(Oidc::class) + ->setConstructorArgs(['client-id', $secret, 'https://example.com/callback']) + ->onlyMethods(['request']) + ->getMock(); + + // request() is called once for the empty well-known URL and returns empty + $oidc + ->expects($this->once()) + ->method('request') + ->willReturn(''); + + $this->expectException(\Exception::class); + + $oidc->getLoginURL(); + } + + private function createOidc(string $response, string $code = 'authorization-code'): Oidc&MockObject + { + $oidc = $this->getMockBuilder(Oidc::class) + ->setConstructorArgs(['client-id', $this->defaultSecret, 'https://example.com/callback']) + ->onlyMethods(['request']) + ->getMock(); + + $oidc + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + 'https://example.com/token', + ['Content-Type: application/x-www-form-urlencoded'], + $this->callback(function (mixed $payload) use ($code): bool { + if (!\is_string($payload)) { + return false; + } + + \parse_str($payload, $params); + + $this->assertSame([ + 'code' => $code, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://example.com/callback', + 'scope' => 'openid profile email', + 'grant_type' => 'authorization_code', + ], $params); + + return true; + }), + ) + ->willReturn($response); + + return $oidc; + } + + private function createOidcUserInfo(string $response): Oidc&MockObject + { + $oidc = $this->getMockBuilder(Oidc::class) + ->setConstructorArgs(['client-id', $this->defaultSecret, 'https://example.com/callback']) + ->onlyMethods(['request']) + ->getMock(); + + $oidc + ->expects($this->once()) + ->method('request') + ->with( + 'GET', + 'https://example.com/userinfo', + ['Authorization: Bearer access-token'] + ) + ->willReturn($response); + + return $oidc; + } +}