Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion app/config/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Expand Down
54 changes: 54 additions & 0 deletions app/config/variables.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\'.',
Expand Down
200 changes: 200 additions & 0 deletions tests/unit/Auth/OAuth2/OidcTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Auth\OAuth2;

use Appwrite\Auth\OAuth2\Oidc;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class OidcTest extends TestCase
{
private string $defaultSecret = '{
"clientSecret": "client-secret",
"authorizationEndpoint": "https://example.com/auth",
"tokenEndpoint": "https://example.com/token",
"userinfoEndpoint": "https://example.com/userinfo"
}';

public function testAccessToken(): void
{
$oidc = $this->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;
}
}