From 5807b99fe089ff848b2e22d09a625721632eff39 Mon Sep 17 00:00:00 2001 From: Chehak7 Date: Sun, 9 Aug 2026 00:34:27 +0530 Subject: [PATCH 1/2] feat(auth): add OIDC as a login provider (#12999) --- app/config/console.php | 11 ++- app/config/variables.php | 54 +++++++++++++ tests/unit/Auth/OAuth2/OidcTest.php | 121 ++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 tests/unit/Auth/OAuth2/OidcTest.php diff --git a/app/config/console.php b/app/config/console.php index bf1850182a9..b795cd6dd93 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -57,7 +57,16 @@ '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', ''), + 'oidcEnabled' => !empty(System::getEnv('_APP_CONSOLE_OIDC_CLIENT_ID', '')), + '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..0ace45f2435 --- /dev/null +++ b/tests/unit/Auth/OAuth2/OidcTest.php @@ -0,0 +1,121 @@ +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')); + } + + 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; + } +} From a77345ec6edaab04a6579a9c58e6e7ab3f58de88 Mon Sep 17 00:00:00 2001 From: Chehak7 Date: Sun, 9 Aug 2026 01:16:14 +0530 Subject: [PATCH 2/2] fix(auth): require complete endpoint config before enabling console OIDC (#12999) A client ID alone is not sufficient to enable OIDC on the Console: without either a well-known discovery URL or all three explicit endpoints the provider cannot build the authorization redirect URL, resulting in a login option that always fails. oidcEnabled now requires client ID plus one of: - _APP_CONSOLE_OIDC_WELLKNOWN_ENDPOINT, or - all three of AUTHORIZATION / TOKEN / USERINFO endpoint vars Tests added: - getLoginURL uses explicit authorizationEndpoint from config - getLoginURL falls back to well-known discovery when explicit endpoints absent - getLoginURL throws when neither endpoint nor well-known is provided --- app/config/console.php | 15 +++++- tests/unit/Auth/OAuth2/OidcTest.php | 79 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/app/config/console.php b/app/config/console.php index b795cd6dd93..865891e1721 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -58,7 +58,20 @@ 'githubEnabled' => true, 'githubSecret' => System::getEnv('_APP_CONSOLE_GITHUB_SECRET', ''), 'githubAppid' => System::getEnv('_APP_CONSOLE_GITHUB_APP_ID', ''), - 'oidcEnabled' => !empty(System::getEnv('_APP_CONSOLE_OIDC_CLIENT_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', ''), diff --git a/tests/unit/Auth/OAuth2/OidcTest.php b/tests/unit/Auth/OAuth2/OidcTest.php index 0ace45f2435..6a378de34cb 100644 --- a/tests/unit/Auth/OAuth2/OidcTest.php +++ b/tests/unit/Auth/OAuth2/OidcTest.php @@ -61,6 +61,85 @@ public function testGetUserName(): void $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)