From c7cf24132e1498ca22dc0b5459ab6fb3e9dc8f63 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 29 Nov 2023 14:21:19 +0800 Subject: [PATCH] feat: support organizations (#6) * feat: support organizations * chore: add organization doc link * chore: update docs --- composer.json | 2 +- docs/api/classes/Logto/Sdk/LogtoClient.md | 36 +++++++++ docs/api/classes/Logto/Sdk/LogtoConfig.md | 14 ++++ .../classes/Logto/Sdk/Models/IdTokenClaims.md | 35 ++++++++- docs/api/classes/Logto/Sdk/Oidc/OidcCore.md | 35 ++++++++- .../Logto/Sdk/Oidc/UserInfoResponse.md | 35 ++++++++- docs/api/classes/Logto/Sdk/Storage/Storage.md | 2 + docs/api/index.md | 2 + docs/tutorial.md | 74 ++++++++++++++----- samples/index.php | 40 +++++++--- src/Constants/ReservedResource.php | 11 +++ src/Constants/ReservedScope.php | 11 +++ src/Constants/UserScope.php | 32 ++++++++ src/LogtoClient.php | 39 +++++++++- src/LogtoConfig.php | 12 +++ src/Models/IdTokenClaims.php | 6 ++ src/Oidc/OidcCore.php | 30 +++++++- src/Oidc/UserInfoResponse.php | 6 ++ src/Storage/StorageKey.php | 2 + tests/LogtoClientTest.php | 53 ++++++++++++- 20 files changed, 434 insertions(+), 43 deletions(-) create mode 100644 src/Constants/ReservedResource.php create mode 100644 src/Constants/ReservedScope.php create mode 100644 src/Constants/UserScope.php diff --git a/composer.json b/composer.json index 426cfc6..112add3 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ } }, "scripts": { - "dev": "php -S localhost:5000 samples/index.php", + "dev": ["Composer\\Config::disableProcessTimeout", "php -S localhost:5000 -t samples"], "test": "phpunit tests", "docs": "rm -rf docs/api && php phpDocumentor.phar && prettier --write docs/api" }, diff --git a/docs/api/classes/Logto/Sdk/LogtoClient.md b/docs/api/classes/Logto/Sdk/LogtoClient.md index 4a42abf..be99932 100644 --- a/docs/api/classes/Logto/Sdk/LogtoClient.md +++ b/docs/api/classes/Logto/Sdk/LogtoClient.md @@ -100,6 +100,24 @@ public getAccessToken(string $resource = ''): ?string --- +### getOrganizationToken + +Get the access token for the given organization ID. If the access token is +expired, it will be refreshed automatically. If no refresh token is found, +null will be returned. + +```php +public getOrganizationToken(string $organizationId): ?string +``` + +**Parameters:** + +| Parameter | Type | Description | +| ----------------- | ---------- | ----------- | +| `$organizationId` | **string** | | + +--- + ### getAccessTokenClaims Get the claims in the access token for the given resource. If the access token @@ -118,6 +136,24 @@ public getAccessTokenClaims(string $resource = ''): \Logto\Sdk\Models\ --- +### getOrganizationTokenClaims + +Get the claims in the access token for the given organization ID. If the access +token is expired, it will be refreshed automatically. If it's unable to refresh +the access token, an exception will be thrown. + +```php +public getOrganizationTokenClaims(string $organizationId): \Logto\Sdk\Models\AccessTokenClaims +``` + +**Parameters:** + +| Parameter | Type | Description | +| ----------------- | ---------- | ----------- | +| `$organizationId` | **string** | | + +--- + ### getRefreshToken Get the refresh token string. diff --git a/docs/api/classes/Logto/Sdk/LogtoConfig.md b/docs/api/classes/Logto/Sdk/LogtoConfig.md index 5af89f1..0e8c5e4 100644 --- a/docs/api/classes/Logto/Sdk/LogtoConfig.md +++ b/docs/api/classes/Logto/Sdk/LogtoConfig.md @@ -109,6 +109,20 @@ public __construct(string $endpoint, string $appId, ?string $appSecret = null, ? --- +### hasOrganizationScope + +Check if the organization scope is requested by the configuration. + +```php +public hasOrganizationScope(): bool +``` + +**See Also:** + +- \Logto\Sdk\Constants\UserScope::organizations - + +--- + --- > Automatically generated from source code comments using [phpDocumentor](http://www.phpdoc.org/) and [saggre/phpdocumentor-markdown](https://github.com/Saggre/phpDocumentor-markdown) diff --git a/docs/api/classes/Logto/Sdk/Models/IdTokenClaims.md b/docs/api/classes/Logto/Sdk/Models/IdTokenClaims.md index d493ffe..eb6bbde 100644 --- a/docs/api/classes/Logto/Sdk/Models/IdTokenClaims.md +++ b/docs/api/classes/Logto/Sdk/Models/IdTokenClaims.md @@ -137,12 +137,42 @@ public ?bool $phone_number_verified --- +### roles + +The user's roles. + +```php +public ?array $roles +``` + +--- + +### organizations + +The user's organization IDs. + +```php +public ?array $organizations +``` + +--- + +### organization_roles + +The user's organization roles. + +```php +public ?array $organization_roles +``` + +--- + ## Methods ### \_\_construct ```php -public __construct(string $iss, string $sub, string $aud, int $exp, int $iat, ?string $at_hash = null, ?string $name = null, ?string $username = null, ?string $picture = null, ?string $email = null, ?bool $email_verified = null, ?string $phone_number = null, ?bool $phone_number_verified = null, mixed $extra): mixed +public __construct(string $iss, string $sub, string $aud, int $exp, int $iat, ?string $at_hash = null, ?string $name = null, ?string $username = null, ?string $picture = null, ?string $email = null, ?bool $email_verified = null, ?string $phone_number = null, ?bool $phone_number_verified = null, ?array $roles = null, ?array $organizations = null, ?array $organization_roles = null, mixed $extra): mixed ``` **Parameters:** @@ -162,6 +192,9 @@ public __construct(string $iss, string $sub, string $aud, int $exp, int $iat, ?s | `$email_verified` | **?bool** | | | `$phone_number` | **?string** | | | `$phone_number_verified` | **?bool** | | +| `$roles` | **?array** | | +| `$organizations` | **?array** | | +| `$organization_roles` | **?array** | | | `$extra` | **mixed** | | --- diff --git a/docs/api/classes/Logto/Sdk/Oidc/OidcCore.md b/docs/api/classes/Logto/Sdk/Oidc/OidcCore.md index 488e084..6f49915 100644 --- a/docs/api/classes/Logto/Sdk/Oidc/OidcCore.md +++ b/docs/api/classes/Logto/Sdk/Oidc/OidcCore.md @@ -10,9 +10,10 @@ instance methods. ## Constants -| Constant | Visibility | Type | Value | -| :--------------- | :--------- | :--- | :-------------------------------------------------------------------- | -| `DEFAULT_SCOPES` | public | | ['openid', 'offline_access', 'profile'] | +| Constant | Visibility | Type | Value | +| :------------------------ | :--------- | :--- | :--------------------------------------------------------------------------------------------------------------------------------------- | +| `DEFAULT_SCOPES` | public | | [\Logto\Sdk\Constants\ReservedScope::openId, \Logto\Sdk\Constants\ReservedScope::offlineAccess, \Logto\Sdk\Constants\UserScope::profile] | +| `ORGANIZATION_URN_PREFIX` | public | | 'urn:logto:organization:' | ## Properties @@ -109,6 +110,31 @@ See [Client Creates the Code Challenge](https://www.rfc-editor.org/rfc/rfc7636.h --- +### buildOrganizationUrn + +Build the organization URN for the given organization ID. + +```php +public static buildOrganizationUrn(string $organizationId): string +``` + +For example, if the organization ID is `123`, the organization URN will be +`urn:logto:organization:123`. + +- This method is **static**. + +**Parameters:** + +| Parameter | Type | Description | +| ----------------- | ---------- | ----------- | +| `$organizationId` | **string** | | + +**See Also:** + +- - [RFC 0001](https://github.com/logto-io/rfcs) to learn more. + +--- + ### \_\_construct Initialize the OIDC core with the provider metadata. You can use the @@ -177,6 +203,9 @@ Fetch the token for the given resource from the token endpoint using the refresh public fetchTokenByRefreshToken(string $clientId, ?string $clientSecret, string $refreshToken, string $resource = ''): \Logto\Sdk\Oidc\TokenResponse ``` +If the resource is an organization URN, the organization ID will be extracted from +the URN and the `organization_id` parameter will be sent to the token endpoint. + **Parameters:** | Parameter | Type | Description | diff --git a/docs/api/classes/Logto/Sdk/Oidc/UserInfoResponse.md b/docs/api/classes/Logto/Sdk/Oidc/UserInfoResponse.md index 3fe0a9e..743072b 100644 --- a/docs/api/classes/Logto/Sdk/Oidc/UserInfoResponse.md +++ b/docs/api/classes/Logto/Sdk/Oidc/UserInfoResponse.md @@ -89,6 +89,36 @@ public ?bool $phone_number_verified --- +### roles + +The user's roles. + +```php +public ?array $roles +``` + +--- + +### organizations + +The user's organization IDs. + +```php +public ?array $organizations +``` + +--- + +### organization_roles + +The user's organization roles. + +```php +public ?array $organization_roles +``` + +--- + ### custom_data The custom data of the user, can be any JSON object. @@ -115,7 +145,7 @@ public ?array $identities ### \_\_construct ```php -public __construct(string $sub, ?string $name = null, ?string $username = null, ?string $picture = null, ?string $email = null, ?bool $email_verified = null, ?string $phone_number = null, ?bool $phone_number_verified = null, mixed $custom_data = null, ?array $identities = null, mixed $extra): mixed +public __construct(string $sub, ?string $name = null, ?string $username = null, ?string $picture = null, ?string $email = null, ?bool $email_verified = null, ?string $phone_number = null, ?bool $phone_number_verified = null, ?array $roles = null, ?array $organizations = null, ?array $organization_roles = null, mixed $custom_data = null, ?array $identities = null, mixed $extra): mixed ``` **Parameters:** @@ -130,6 +160,9 @@ public __construct(string $sub, ?string $name = null, ?string $username = null, | `$email_verified` | **?bool** | | | `$phone_number` | **?string** | | | `$phone_number_verified` | **?bool** | | +| `$roles` | **?array** | | +| `$organizations` | **?array** | | +| `$organization_roles` | **?array** | | | `$custom_data` | **mixed** | | | `$identities` | **?array** | | | `$extra` | **mixed** | | diff --git a/docs/api/classes/Logto/Sdk/Storage/Storage.md b/docs/api/classes/Logto/Sdk/Storage/Storage.md index d1729e8..17d8391 100644 --- a/docs/api/classes/Logto/Sdk/Storage/Storage.md +++ b/docs/api/classes/Logto/Sdk/Storage/Storage.md @@ -1,3 +1,5 @@ +--- + # Storage The storage interface for the Logto client. Logto client will use this diff --git a/docs/api/index.md b/docs/api/index.md index 61585fc..f2fd16d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,3 +1,5 @@ +--- + # Logto PHP SDK This is an automatically generated documentation for **Logto PHP SDK**. diff --git a/docs/tutorial.md b/docs/tutorial.md index 377d45b..db7a50b 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -7,22 +7,22 @@ This tutorial will show you how to integrate Logto into your PHP web application ## Table of contents -- [Logto PHP SDK tutorial](#logto-php-sdk-tutorial) - - [Table of contents](#table-of-contents) - - [Installation](#installation) - - [Integration](#integration) - - [Init LogtoClient](#init-logtoclient) - - [Implement the sign-in route](#implement-the-sign-in-route) - - [Implement the callback route](#implement-the-callback-route) - - [Implement the home page](#implement-the-home-page) - - [Implement the sign-out route](#implement-the-sign-out-route) - - [Checkpoint: Test your application](#checkpoint-test-your-application) - - [Protect your routes](#protect-your-routes) - - [Scopes and claims](#scopes-and-claims) - - [Special ID token claims](#special-id-token-claims) - - [API resources](#api-resources) - - [Configure Logto client](#configure-logto-client) - - [Fetch access token for the API resource](#fetch-access-token-for-the-api-resource) +- [Table of contents](#table-of-contents) +- [Installation](#installation) +- [Integration](#integration) + - [Init LogtoClient](#init-logtoclient) + - [Implement the sign-in route](#implement-the-sign-in-route) + - [Implement the callback route](#implement-the-callback-route) + - [Implement the home page](#implement-the-home-page) + - [Implement the sign-out route](#implement-the-sign-out-route) + - [Checkpoint: Test your application](#checkpoint-test-your-application) +- [Protect your routes](#protect-your-routes) +- [Scopes and claims](#scopes-and-claims) + - [Special ID token claims](#special-id-token-claims) +- [API resources](#api-resources) + - [Configure Logto client](#configure-logto-client) + - [Fetch access token for the API resource](#fetch-access-token-for-the-api-resource) + - [Fetch organization token for user](#fetch-organization-token-for-user) ## Installation @@ -200,7 +200,20 @@ By default, Logto SDK requests three scopes: `openid`, `profile`, and `offline_a $client = new LogtoClient( new LogtoConfig( // ...other configs - scopes: ["email", "phone"], // Add more scopes + scopes: ["email", "phone"], // Update per your needs + ), +); +``` + +Alternatively, you can use the `UserScope` enum to add scopes: + +```php +use Logto\Sdk\Constants\UserScope; + +$client = new LogtoClient( + new LogtoConfig( + // ...other configs + scopes: [UserScope::email, UserScope::phone], // Update per your needs ), ); ``` @@ -280,3 +293,30 @@ $accessToken = $client->getAccessToken("https://shopping.your-app.com/api"); This method will return a JWT access token that can be used to access the API resource, if the user has the proper permissions. If the current cached access token has expired, this method will automatically try to use the refresh token to get a new access token. If failed by any reason, this method will return `null`. + +### Fetch organization token for user + +If organization is new to you, please read [🏢 Organizations (Multi-tenancy)](https://docs.logto.io/docs/recipes/organizations/) to get started. + +You need to add `UserScope.organizations` scope when configuring the Logto client: + +```php +use Logto\Sdk\Constants\UserScope; + +$client = new LogtoClient( + new LogtoConfig( + // ...other configs + scopes: [UserScope::organizations], // Add scopes + ), +); +``` + +Once the user is signed in, you can fetch the organization token for the user: + +```php +# Replace the parameter with a valid organization ID. +# Valid organization IDs for the user can be found in the ID token claim `organizations`. +$organizationToken = $client->getOrganizationToken("organization-id"); +# or +$claims = $client->getOrganizationTokenClaims("organization-id"); +``` diff --git a/samples/index.php b/samples/index.php index 066b4de..a8c0430 100644 --- a/samples/index.php +++ b/samples/index.php @@ -10,6 +10,7 @@ use Logto\Sdk\LogtoClient; use Logto\Sdk\LogtoConfig; + use Logto\Sdk\Constants\UserScope; $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); $dotenv->load(); @@ -17,11 +18,11 @@ $resources = ['https://default.logto.app/api', 'https://shopping.api']; $client = new LogtoClient( new LogtoConfig( - endpoint: "http://localhost:3001", + endpoint: $_ENV['LOGTO_ENDPOINT'], appId: $_ENV['LOGTO_APP_ID'], appSecret: $_ENV['LOGTO_APP_SECRET'], - resources: $resources, - scopes: ['email'], + // resources: $resources, // Uncomment this line to specify resources + scopes: [UserScope::email, UserScope::organizations, UserScope::organizationRoles], // Update per your needs ) ); @@ -29,17 +30,34 @@ case '/': case null: if (!$client->isAuthenticated()) { - echo ''; + echo 'Sign in'; break; } - echo ''; - echo '
'; - var_dump($client->fetchUserInfo()); - echo '
'; - var_dump($client->getIdTokenClaims()); - echo '
'; - var_dump($client->getAccessTokenClaims($resources[0])); + echo 'View organization token
'; + echo 'Sign out'; + echo '

Userinfo

'; + echo '
';
+      echo var_export($client->fetchUserInfo(), true);
+      echo '
'; + echo '

ID token claims

'; + echo '
';
+      echo var_export($client->getIdTokenClaims(), true);
+      echo '

'; + // var_dump($client->getAccessTokenClaims($resources[0])); // Uncomment this line to see the access token claims + break; + + case '/organizations': + if (!$client->isAuthenticated()) { + echo 'Sign in'; + break; + } + + echo 'Sign out'; + echo '

Organization token claims

'; + echo '
';
+      echo var_export($client->getOrganizationTokenClaims(''), true); // Replace  with a valid organization ID
+      echo '
'; break; case '/sign-in': diff --git a/src/Constants/ReservedResource.php b/src/Constants/ReservedResource.php new file mode 100644 index 0000000..0d4d4f4 --- /dev/null +++ b/src/Constants/ReservedResource.php @@ -0,0 +1,11 @@ +access_token; } + /** + * Get the access token for the given organization ID. If the access token is + * expired, it will be refreshed automatically. If no refresh token is found, + * null will be returned. + */ + function getOrganizationToken(string $organizationId): ?string + { + return $this->getAccessToken(OidcCore::buildOrganizationUrn($organizationId)); + } + /** * Get the claims in the access token for the given resource. If the access token * is expired, it will be refreshed automatically. If it's unable to refresh the @@ -132,6 +143,16 @@ function getAccessTokenClaims(string $resource = ''): AccessTokenClaims return new AccessTokenClaims(...json_decode(base64_decode(explode('.', $this->getAccessToken($resource))[1]), true)); } + /** + * Get the claims in the access token for the given organization ID. If the access + * token is expired, it will be refreshed automatically. If it's unable to refresh + * the access token, an exception will be thrown. + */ + function getOrganizationTokenClaims(string $organizationId): AccessTokenClaims + { + return $this->getAccessTokenClaims(OidcCore::buildOrganizationUrn($organizationId)); + } + /** * Get the refresh token string. */ @@ -274,12 +295,24 @@ public function fetchUserInfo(): UserInfoResponse protected function buildSignInUrl(string $redirectUri, string $codeChallenge, string $state, ?InteractionMode $interactionMode): string { + $pickValue = function (string|\BackedEnum $value): string { + return $value instanceof \BackedEnum ? $value->value : $value; + }; $config = $this->config; + $scopes = array_unique( + array_map($pickValue, array_merge($config->scopes ?: [], $this->oidcCore::DEFAULT_SCOPES)) + ); + $resources = array_unique( + $config->hasOrganizationScope() + ? array_merge($config->resources ?: [], [ReservedResource::organizations->value]) + : ($config->resources ?: []) + ); + $query = http_build_query([ 'client_id' => $config->appId, 'redirect_uri' => $redirectUri, 'response_type' => 'code', - 'scope' => implode(' ', array_merge($config->scopes ?: [], $this->oidcCore::DEFAULT_SCOPES)), + 'scope' => implode(' ', $scopes), 'prompt' => $config->prompt->value, 'code_challenge' => $codeChallenge, 'code_challenge_method' => 'S256', @@ -291,9 +324,9 @@ protected function buildSignInUrl(string $redirectUri, string $codeChallenge, st '?' . $query . ( - $config->resources ? + count($resources) > 0 ? # Resources need to use the same key name as the query string - '&' . implode('&', array_map(fn($resource) => "resource=" . urlencode($resource), $config->resources)) : + '&' . implode('&', array_map(fn($resource) => "resource=" . urlencode($resource), $resources)) : '' ); } diff --git a/src/LogtoConfig.php b/src/LogtoConfig.php index d1568c3..c724568 100644 --- a/src/LogtoConfig.php +++ b/src/LogtoConfig.php @@ -1,6 +1,8 @@ scopes ?: []) || in_array(UserScope::organizations->value, $this->scopes ?: []); + } } diff --git a/src/Models/IdTokenClaims.php b/src/Models/IdTokenClaims.php index 11d4291..f3a2356 100644 --- a/src/Models/IdTokenClaims.php +++ b/src/Models/IdTokenClaims.php @@ -30,6 +30,12 @@ public function __construct( public ?string $phone_number = null, /** Whether the phone number is verified. */ public ?bool $phone_number_verified = null, + /** The user's roles. */ + public ?array $roles = null, + /** The user's organization IDs. */ + public ?array $organizations = null, + /** The user's organization roles. */ + public ?array $organization_roles = null, ...$extra ) { $this->extra = $extra; diff --git a/src/Oidc/OidcCore.php b/src/Oidc/OidcCore.php index f904f95..47dec27 100644 --- a/src/Oidc/OidcCore.php +++ b/src/Oidc/OidcCore.php @@ -5,8 +5,9 @@ use GuzzleHttp\Psr7\HttpFactory; use GuzzleHttp\Client; use Logto\Sdk\LogtoException; +use Logto\Sdk\Constants\ReservedScope; +use Logto\Sdk\Constants\UserScope; use Logto\Sdk\Models\OidcProviderMetadata; -use Logto\Sdk\Utilities; use Phpfastcache\CacheManager; use Firebase\JWT\JWT; @@ -17,7 +18,8 @@ */ class OidcCore { - public const DEFAULT_SCOPES = ['openid', 'offline_access', 'profile']; + public const DEFAULT_SCOPES = [ReservedScope::openId, ReservedScope::offlineAccess, UserScope::profile]; + public const ORGANIZATION_URN_PREFIX = 'urn:logto:organization:'; /** * Create a OidcCore instance for the given Logto endpoint using the discovery URL. @@ -61,6 +63,19 @@ static function generateCodeChallenge(string $codeVerifier): string return JWT::urlsafeB64Encode(hash('sha256', $codeVerifier, true)); } + /** + * Build the organization URN for the given organization ID. + * + * For example, if the organization ID is `123`, the organization URN will be + * `urn:logto:organization:123`. + * + * @see [RFC 0001](https://github.com/logto-io/rfcs) to learn more. + */ + static function buildOrganizationUrn(string $organizationId): string + { + return self::ORGANIZATION_URN_PREFIX . $organizationId; + } + // ==================== End of static members ==================== protected CachedKeySet $jwkSet; @@ -121,16 +136,23 @@ public function fetchTokenByCode(string $clientId, ?string $clientSecret, string return new TokenResponse(...json_decode($response, true)); } - /** Fetch the token for the given resource from the token endpoint using the refresh token. */ + /** + * Fetch the token for the given resource from the token endpoint using the refresh token. + * + * If the resource is an organization URN, the organization ID will be extracted from + * the URN and the `organization_id` parameter will be sent to the token endpoint. + */ public function fetchTokenByRefreshToken(string $clientId, ?string $clientSecret, string $refreshToken, string $resource = ''): TokenResponse { + $isOrganizationResource = str_starts_with($resource, self::ORGANIZATION_URN_PREFIX); $response = $this->client->post($this->metadata->token_endpoint, [ 'form_params' => [ 'grant_type' => 'refresh_token', 'client_id' => $clientId, 'client_secret' => $clientSecret, 'refresh_token' => $refreshToken, - 'resource' => $resource ?: null, + 'resource' => $isOrganizationResource ? null : ($resource ?: null), + 'organization_id' => $isOrganizationResource ? substr($resource, strlen(self::ORGANIZATION_URN_PREFIX)) : null, ], ])->getBody()->getContents(); return new TokenResponse(...json_decode($response, true)); diff --git a/src/Oidc/UserInfoResponse.php b/src/Oidc/UserInfoResponse.php index 41ebec4..e0bf3b3 100644 --- a/src/Oidc/UserInfoResponse.php +++ b/src/Oidc/UserInfoResponse.php @@ -23,6 +23,12 @@ public function __construct( public ?string $phone_number = null, /** Whether the phone number is verified. */ public ?bool $phone_number_verified = null, + /** The user's roles. */ + public ?array $roles = null, + /** The user's organization IDs. */ + public ?array $organizations = null, + /** The user's organization roles. */ + public ?array $organization_roles = null, /** The custom data of the user, can be any JSON object. */ public mixed $custom_data = null, /** diff --git a/src/Storage/StorageKey.php b/src/Storage/StorageKey.php index 1101da3..8b64e0e 100644 --- a/src/Storage/StorageKey.php +++ b/src/Storage/StorageKey.php @@ -1,4 +1,6 @@ getInstance(new LogtoConfig(endpoint: "http://localhost:3001", appId: "app-id", scopes: ["email", "phone"])); + $client = $this->getInstance(new LogtoConfig(endpoint: "http://localhost:3001", appId: "app-id", scopes: ["email", "phone", "email"])); $this->assertSame( $client->signIn("redirectUri"), "https://logto.app/oidc/auth?client_id=app-id&redirect_uri=redirectUri&response_type=code&scope=email+phone+openid+offline_access+profile&prompt=consent&code_challenge=codeChallenge&code_challenge_method=S256&state=state" @@ -106,13 +107,22 @@ function test_signIn_multipleScopes() function test_signIn_allConfigs() { - $client = $this->getInstance(new LogtoConfig(endpoint: "http://localhost:3001", appId: "app-id", scopes: ["email", "phone"], resources: ["https://resource1", "https://resource2"], prompt: Prompt::login)); + $client = $this->getInstance(new LogtoConfig(endpoint: "http://localhost:3001", appId: "app-id", scopes: ["email", UserScope::phone], resources: ["https://resource1", "https://resource2"], prompt: Prompt::login)); $this->assertSame( $client->signIn("redirectUri", InteractionMode::signUp), "https://logto.app/oidc/auth?client_id=app-id&redirect_uri=redirectUri&response_type=code&scope=email+phone+openid+offline_access+profile&prompt=login&code_challenge=codeChallenge&code_challenge_method=S256&state=state&interaction_mode=signUp&resource=https%3A%2F%2Fresource1&resource=https%3A%2F%2Fresource2" ); } + function test_signIn_organizationScope() + { + $client = $this->getInstance(new LogtoConfig(endpoint: "http://localhost:3001", appId: "app-id", scopes: ["email", UserScope::organizations])); + $this->assertSame( + $client->signIn("redirectUri"), + "https://logto.app/oidc/auth?client_id=app-id&redirect_uri=redirectUri&response_type=code&scope=email+urn%3Alogto%3Ascope%3Aorganizations+openid+offline_access+profile&prompt=consent&code_challenge=codeChallenge&code_challenge_method=S256&state=state&resource=urn%3Alogto%3Aresource%3Aorganizations" + ); + } + function test_signOut() { $client = $this->getInstance(); @@ -244,6 +254,17 @@ function test_getAccessToken_useRefreshToken() $this->assertSame($client->getAccessToken(), "accessToken"); } + function test_getOrganizationToken() + { + $client = $this->getInstance(); + $this->assertNull($client->getOrganizationToken('1')); + $client->storage->set( + StorageKey::accessTokenMap, + '{"":{"token":"access_token","expiresAt": 9999999999}, "urn:logto:organization:1":{"token":"access_token_foo","expiresAt": 9999999999}}', + ); + $this->assertSame($client->getOrganizationToken('1'), "access_token_foo"); + } + function test_getAccessTokenClaims() { $client = $this->getInstance(); @@ -272,6 +293,34 @@ function test_getAccessTokenClaims() ); } + function test_getOrganizationTokenClaims() + { + $client = $this->getInstance(); + + // Not able to parse null + $this->expectException(TypeError::class); + $this->assertNull($client->getOrganizationTokenClaims('1')); + + // Assign a valid access token raw string + $accessToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJpc3MiOiJodHRwczovL2xvZ3RvLmFwcCIsImF1ZCI6Imh0dHBzOi8vbG9ndG8uYXBwL2FwaSIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNjE2NDQ2MzAwLCJzdWIiOiJ1c2VyMSIsInNjb3BlIjoiYWRtaW4gdXNlciIsImNsaWVudF9pZCI6InNhcXJlMW9xYmtwajZ6aHE4NWhvMCJ9.12345678901234567890123456789012345678901234567890"; + $client->storage->set( + StorageKey::accessTokenMap, + '{"urn:logto:organization:1":{"token":"' . $accessToken . '","expiresAt": 9999999999}}', + ); + $this->assertEquals( + $client->getOrganizationTokenClaims('1'), + new AccessTokenClaims( + iss: "https://logto.app", + aud: "https://logto.app/api", + exp: 9999999999, + iat: 1616446300, + sub: "user1", + scope: "admin user", + client_id: "saqre1oqbkpj6zhq85ho0", + ) + ); + } + function test_getIdToken() { $client = $this->getInstance();