diff --git a/src/Configuration/SdkConfiguration.php b/src/Configuration/SdkConfiguration.php index b042814b..11798ef1 100644 --- a/src/Configuration/SdkConfiguration.php +++ b/src/Configuration/SdkConfiguration.php @@ -70,7 +70,7 @@ final class SdkConfiguration implements ConfigurableContract * @param null|string $redirectUri authentication callback URI, as defined in your Auth0 Application settings * @param null|string $clientSecret client Secret, found in the Auth0 Application settings * @param null|array $audience One or more API identifiers, found in your Auth0 API settings. The SDK uses the first value for building links. If provided, at least one of these values must match the 'aud' claim to validate an ID Token successfully. - * @param null|array $organization One or more Organization IDs, found in your Auth0 Organization settings. The SDK uses the first value for building links. If provided, at least one of these values must match the 'org_id' claim to validate an ID Token successfully. + * @param null|array $organization Allowlist containing one or more organization IDs/names. Reference your Auth0 organization settings for these values. By default, the SDK will use the first value provided when generating authorization links. * @param bool $usePkce Defaults to true. Use PKCE (Proof Key of Code Exchange) with Authorization Code Flow requests. See https://auth0.com/docs/flows/call-your-api-using-the-authorization-code-flow-with-pkce * @param null|array $scope One or more scopes to request for Tokens. See https://auth0.com/docs/scopes * @param string $responseMode Defaults to 'query.' Where to extract request parameters from, either 'query' for GET or 'form_post' for POST requests. @@ -425,7 +425,7 @@ public function getManagementTokenCache(?Throwable $exceptionIfNull = null): ?Ca /** * @param ?Throwable $exceptionIfNull * - * @return null|array the allowlist of Organization IDs + * @return null|array The configured allowlist of organization IDs/names. */ public function getOrganization(?Throwable $exceptionIfNull = null): ?array { @@ -808,7 +808,7 @@ public function pushAudience(array | string $audiences): ?array } /** - * @param array|string $organizations a string or array of strings representing Organization IDs to add to the allowlist + * @param array|string $organizations A string or array of strings representing organization IDs/names to add to the organization allowlist. * * @return null|array */ @@ -1097,7 +1097,7 @@ public function setManagementTokenCache(?CacheItemPoolInterface $managementToken } /** - * @param null|array $organization an allowlist of Organization IDs + * @param null|array $organization An allowlist of organizations IDs/names. */ public function setOrganization(?array $organization = null): self { diff --git a/src/Exception/InvalidTokenException.php b/src/Exception/InvalidTokenException.php index b68c821d..63652674 100644 --- a/src/Exception/InvalidTokenException.php +++ b/src/Exception/InvalidTokenException.php @@ -62,11 +62,6 @@ final class InvalidTokenException extends Exception implements Auth0Exception */ public const MSG_MISMATCHED_NONCE_CLAIM = 'Nonce (nonce) claim mismatch in the token; expected "%s", found "%s"'; - /** - * @var string - */ - public const MSG_MISMATCHED_ORG_ID_CLAIM = 'Organization Id (org_id) claim value mismatch in the token; expected "%s", found "%s"'; - /** * @var string */ @@ -115,12 +110,27 @@ final class InvalidTokenException extends Exception implements Auth0Exception /** * @var string */ - public const MSG_MISSING_ORG_ID_CLAIM = 'Organization Id (org_id) claim must be a string present in the token'; + public const MSG_MISSING_SUB_CLAIM = 'Subject (sub) claim must be a string present in the token'; /** * @var string */ - public const MSG_MISSING_SUB_CLAIM = 'Subject (sub) claim must be a string present in the token'; + public const MSG_ORGANIZATION_CLAIM_BAD = 'Token organization claim (`org_id` or `org_name`) must be a string'; + + /** + * @var string + */ + public const MSG_ORGANIZATION_CLAIM_MISSING = 'Token organization claim (`org_id` or `org_name`) was not found'; + + /** + * @var string + */ + public const MSG_ORGANIZATION_CLAIM_UNEXPECTED = 'Token organization claim (`org_id` or `org_name`) was not expected'; + + /** + * @var string + */ + public const MSG_ORGANIZATION_CLAIM_UNMATCHED = 'Token organization claim (`org_id` or `org_name`) is not allowed'; /** * @var string @@ -221,14 +231,6 @@ public static function mismatchedNonceClaim( return new self(sprintf(self::MSG_MISMATCHED_NONCE_CLAIM, $expected, $found), 0, $previous); } - public static function mismatchedOrgIdClaim( - string $expected, - string $found, - ?Throwable $previous = null, - ): self { - return new self(sprintf(self::MSG_MISMATCHED_ORG_ID_CLAIM, $expected, $found), 0, $previous); - } - public static function missingAlgHeader( ?Throwable $previous = null, ): self { @@ -283,12 +285,6 @@ public static function missingNonceClaim( return new self(self::MSG_MISSING_NONCE_CLAIM, 0, $previous); } - public static function missingOrgIdClaim( - ?Throwable $previous = null, - ): self { - return new self(self::MSG_MISSING_ORG_ID_CLAIM, 0, $previous); - } - public static function missingSubClaim( ?Throwable $previous = null, ): self { diff --git a/src/Token.php b/src/Token.php index e80bcf37..e5c3bf41 100644 --- a/src/Token.php +++ b/src/Token.php @@ -166,6 +166,19 @@ public function getNonce(): ?string } public function getOrganization(): ?string + { + if (null !== ($claim = $this->getOrganizationId())) { + return $claim; + } + + if (null !== ($claim = $this->getOrganizationName())) { + return $claim; + } + + return null; + } + + public function getOrganizationId(): ?string { if (is_string($claim = $this->getParser()->getClaim('org_id'))) { return $claim; @@ -174,6 +187,15 @@ public function getOrganization(): ?string return null; } + public function getOrganizationName(): ?string + { + if (is_string($claim = $this->getParser()->getClaim('org_name'))) { + return $claim; + } + + return null; + } + public function getSubject(): ?string { if (is_string($claim = $this->getParser()->getClaim('sub'))) { diff --git a/src/Token/Validator.php b/src/Token/Validator.php index 89d56ad2..9dd854a6 100644 --- a/src/Token/Validator.php +++ b/src/Token/Validator.php @@ -202,23 +202,59 @@ public function nonce( } /** - * Validate the 'org_id' claim. + * Validate the 'org_id' and `org_name` claims. * - * @param array $expects An array of allowed values for the 'org_id' claim. Successful if ANY match. + * @param array $expects An array of allowed values for the 'org_id' or `org_name` claim. Successful if ANY match. * * @throws InvalidTokenException when claim validation fails */ public function organization( array $expects, ): self { - $claim = $this->getClaim('org_id'); + $allowedOrganizations = array_filter(array_values($expects)); + $organizationId = $this->getClaim('org_id'); + $organizationName = $this->getClaim('org_name'); - if (null === $claim || ! is_string($claim)) { - throw InvalidTokenException::missingOrgIdClaim(); + // No claims or SDK allowlist configured, so skip validation. Pass. + if (['*'] === $allowedOrganizations) { + return $this; + } + + // If a claim is present, ensure it is a string; otherwise throw. + if ((null !== $organizationId && ! is_string($organizationId)) || (null !== $organizationName && ! is_string($organizationName))) { + throw new InvalidTokenException(InvalidTokenException::MSG_ORGANIZATION_CLAIM_BAD); + } + + // If an SDK allowlist has been configured, we need to run comparisons. + if ([] !== $allowedOrganizations) { + if (null === $organizationId && null === $organizationName) { + throw new InvalidTokenException(InvalidTokenException::MSG_ORGANIZATION_CLAIM_MISSING); + } + + if (null !== $organizationId) { + $allowedOrganizationIds = array_filter($allowedOrganizations, static fn ($org): bool => str_starts_with($org, 'org_')); + + // org_id claim is present and in the allowlist. Success. + if (in_array($organizationId, $allowedOrganizationIds, true)) { + return $this; + } + } + + if (null !== $organizationName) { + $allowedOrganizationNames = array_map('strtolower', array_filter($allowedOrganizations, static fn ($org): bool => ! str_starts_with($org, 'org_'))); + + // org_name claim is present and in the allowlist. Success. + if (in_array($organizationName, $allowedOrganizationNames, true)) { + return $this; + } + } + + throw new InvalidTokenException(InvalidTokenException::MSG_ORGANIZATION_CLAIM_UNMATCHED); } - if (! in_array($claim, $expects, true)) { - throw InvalidTokenException::mismatchedOrgIdClaim(implode(', ', $expects), $claim); + // A claim is present, but there is no allowlist configured. Throw. + if (null !== $organizationId || null !== $organizationName) { + throw new InvalidTokenException(InvalidTokenException::MSG_ORGANIZATION_CLAIM_UNEXPECTED); } return $this; diff --git a/tests/Unit/Auth0Test.php b/tests/Unit/Auth0Test.php index 0692d56d..f04f4b5d 100644 --- a/tests/Unit/Auth0Test.php +++ b/tests/Unit/Auth0Test.php @@ -428,7 +428,7 @@ public function defer( })->throws(InvalidTokenException::class); test('decode() compares `org_id` against `organization` configuration', function(): void { - $orgId = 'org8675309'; + $orgId = 'org_' . uniqid(); $token = (new TokenGenerator())->withHs256([ 'org_id' => $orgId, @@ -445,6 +445,42 @@ public function defer( expect($decoded->getOrganization())->toEqual($orgId); }); +test('decode() compares `org_name` against `organization` configuration', function(): void { + $orgName = uniqid(); + + $token = (new TokenGenerator())->withHs256([ + 'org_name' => $orgName, + 'iss' => 'https://' . $this->configuration['domain'] . '/' + ]); + + $auth0 = new Auth0($this->configuration + [ + 'tokenAlgorithm' => 'HS256', + 'organization' => [$orgName], + ]); + + $decoded = $auth0->decode($token); + + expect($decoded->getOrganization())->toEqual($orgName); +}); + +test('decode() does not match strings beginning with `org_` from the `organization` configuration against an `org_name` claim check', function(): void { + $orgName = 'org_' . uniqid(); + + $token = (new TokenGenerator())->withHs256([ + 'org_name' => $orgName, + 'iss' => 'https://' . $this->configuration['domain'] . '/' + ]); + + $auth0 = new Auth0($this->configuration + [ + 'tokenAlgorithm' => 'HS256', + 'organization' => [$orgName], + ]); + + $decoded = $auth0->decode($token); + + expect($decoded->getOrganization())->toEqual($orgName); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_UNMATCHED); + test('decode() throws an exception when `org_id` claim does not exist, but an `organization` is configured', function(): void { $token = (new TokenGenerator())->withHs256([ 'iss' => 'https://' . $this->configuration['domain'] . '/' @@ -452,15 +488,15 @@ public function defer( $auth0 = new Auth0($this->configuration + [ 'tokenAlgorithm' => 'HS256', - 'organization' => ['org8675309'], + 'organization' => ['org_' . uniqid()], ]); $auth0->decode($token); -})->throws(InvalidTokenException::class, InvalidTokenException::MSG_MISSING_ORG_ID_CLAIM); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_MISSING); test('decode() throws an exception when `org_id` does not match `organization` configuration', function(): void { - $expectedOrgId = uniqid(); - $tokenOrgId = uniqid(); + $expectedOrgId = 'org_' . uniqid(); + $tokenOrgId = 'org_' . uniqid(); $token = (new TokenGenerator())->withHs256([ 'org_id' => $tokenOrgId, @@ -473,7 +509,7 @@ public function defer( ]); $auth0->decode($token); -})->throws(InvalidTokenException::class); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_UNMATCHED); test('decode() can be used with access tokens', function (): void { $token = (new TokenGenerator())->withHs256([ diff --git a/tests/Unit/Token/ValidatorTest.php b/tests/Unit/Token/ValidatorTest.php index a155f00e..8d7d2e6f 100644 --- a/tests/Unit/Token/ValidatorTest.php +++ b/tests/Unit/Token/ValidatorTest.php @@ -16,7 +16,7 @@ 'auth_time' => time() - 100, 'exp' => time() + 1000, 'iat' => time() - 1000, - 'azp' => uniqid() + 'azp' => uniqid(), ]; }); @@ -92,3 +92,42 @@ unset($this->claims['sub']); (new Validator($this->claims))->subject(); })->throws(InvalidTokenException::class, InvalidTokenException::MSG_MISSING_SUB_CLAIM); + +test('organization() throws an exception when a `org_id` claim is expected but not found', function(): void { + (new Validator($this->claims))->organization(['org_123']); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_MISSING); + +test('organization() throws an exception when a `org_name` claim is expected but not found', function(): void { + (new Validator($this->claims))->organization(['organizationTesting123']); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_MISSING); + +test('organization() does not throw an exception when wildcard organizations are configured', function(): void { + $this->claims['org_id'] = uniqid(); + $validator = (new Validator($this->claims))->organization(['*']); + expect($validator)->toBeInstanceOf(Validator::class); +}); + +test('organization() throws an exception when either a `org_id` claim is an unexpected type', function(): void { + $this->claims['org_id'] = true; + (new Validator($this->claims))->organization(['org_123']); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_BAD); + +test('organization() throws an exception when either a `org_name` claim is an unexpected type', function(): void { + $this->claims['org_name'] = true; + (new Validator($this->claims))->organization(['organizationTesting123']); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_BAD); + +test('organization() throws an exception when an unexpected `org_id` claim is encountered', function(): void { + $this->claims['org_id'] = uniqid(); + (new Validator($this->claims))->organization([]); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_UNEXPECTED); + +test('organization() throws an exception when an unexpected `org_name` claim is encountered', function(): void { + $this->claims['org_name'] = uniqid(); + (new Validator($this->claims))->organization([]); +})->throws(InvalidTokenException::class, InvalidTokenException::MSG_ORGANIZATION_CLAIM_UNEXPECTED); + +test('organization() does not throw an exception when there are no organization claims and no allowlist configured', function(): void { + $validator = (new Validator($this->claims))->organization([]); + expect($validator)->toBeInstanceOf(Validator::class); +}); diff --git a/tests/Unit/TokenTest.php b/tests/Unit/TokenTest.php index 4a8dff63..2628c35a 100644 --- a/tests/Unit/TokenTest.php +++ b/tests/Unit/TokenTest.php @@ -155,7 +155,7 @@ function(): SdkConfiguration { array $claims ): void { $token = new Token($configuration, $jwt->token, Token::TYPE_ID_TOKEN); - expect($token->validate(null, null, ['__test_org__'], $claims['nonce'], 100))->toEqual($token); + expect($token->validate(null, null, ['org_123'], $claims['nonce'], 100))->toEqual($token); })->with(['mocked data' => [ function(): SdkConfiguration { $this->configuration->setDomain('domain.test'); @@ -164,7 +164,7 @@ function(): SdkConfiguration { $this->configuration->setClientSecret('__test_client_secret__'); return $this->configuration; }, - fn() => TokenGenerator::create(TokenGenerator::TOKEN_ID, TokenGenerator::ALG_HS256, ['org_id' => '__test_org__']), + fn() => TokenGenerator::create(TokenGenerator::TOKEN_ID, TokenGenerator::ALG_HS256, ['org_id' => 'org_123']), fn() => ['nonce' => '__test_nonce__'] ]]);