Skip to content

Commit

Permalink
[SDK-4393] feat(auth): Support Organization Name with Authorization A…
Browse files Browse the repository at this point in the history
…PI (#719)

<!--
Please only send pull requests to branches that are actively supported.
Pull requests without an adequate title, description, or tests will be
closed.
-->

### Changes

> **Note**
> Your Auth0 tenant must have this feature enabled to use this.

This PR adds support for authorizing using an organization name. This
enhances the existing support for authorizing using an organization ID.
Example usage follows:

✨ (New) Authorization using an organization name:

```php
new Auth0(
  new SdkConfiguration(
    organization: ['example-org-name'],
  )
);
```

(Existing) support for authorization using an organization ID:

```php
new Auth0(
  new SdkConfiguration(
    organization: ['org_123456'],
  )
);
```

(Updated) The SDK treats the `organization` parameter as an allowlist
for applications that need to work with multiple organizations. It now
accepts organization names, as well as existing support for IDs.

```php
$sdk = new Auth0(
  new SdkConfiguration(
    organization: ['org_123456', 'example-org-name', 'another-org-name'],
  )
);
```

(Existing) When redirecting for authorization, the `organization`
allowlist's first value is used by default. This behavior can be
overridden using method parameters:

```php
// Redirects to /authorize?organization=org_123456&...
header('Location: ' . $sdk->login());
```

```php
// Redirects to /authorize?organization=org_000000&...
header('Location: ' . $sdk->login(params: ['organization': 'org_000000'));
```

```php
// Redirects to /authorize?organization=example-org-name&...
header('Location: ' . $sdk->login(params: ['organization': 'example-org-name'));
```

```php
// Redirects to /authorize?...
header('Location: ' . $sdk->login(params: ['organization': null));
```

### References

<!--
  Link to any associated issues.
-->

Please review the internal Jira ticket SDK-4393 for further information.

### Testing

<!--
Tests must be added for new functionality, and existing tests should
complete without errors.
  100% test coverage is required.
-->

- Tests have been updated to support the new functionality and maintain
100% coverage.
- Run `composer test` from a clone of the branch to test locally.
- Review the CI test results on the PR otherwise.

### Contributor Checklist

- [x] I have read the [Auth0 general contribution
guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md)
- [x] I have read the [Auth0 code of
conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md)
  • Loading branch information
evansims committed Jul 19, 2023
1 parent efa11eb commit 958e06a
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 41 deletions.
8 changes: 4 additions & 4 deletions src/Configuration/SdkConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $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<string> $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<string> $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<string> $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.
Expand Down Expand Up @@ -425,7 +425,7 @@ public function getManagementTokenCache(?Throwable $exceptionIfNull = null): ?Ca
/**
* @param ?Throwable $exceptionIfNull
*
* @return null|array<string> the allowlist of Organization IDs
* @return null|array<string> The configured allowlist of organization IDs/names.
*/
public function getOrganization(?Throwable $exceptionIfNull = null): ?array
{
Expand Down Expand Up @@ -808,7 +808,7 @@ public function pushAudience(array | string $audiences): ?array
}

/**
* @param array<string>|string $organizations a string or array of strings representing Organization IDs to add to the allowlist
* @param array<string>|string $organizations A string or array of strings representing organization IDs/names to add to the organization allowlist.
*
* @return null|array<string>
*/
Expand Down Expand Up @@ -1097,7 +1097,7 @@ public function setManagementTokenCache(?CacheItemPoolInterface $managementToken
}

/**
* @param null|array<string> $organization an allowlist of Organization IDs
* @param null|array<string> $organization An allowlist of organizations IDs/names.
*/
public function setOrganization(?array $organization = null): self
{
Expand Down
38 changes: 17 additions & 21 deletions src/Exception/InvalidTokenException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions src/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'))) {
Expand Down
50 changes: 43 additions & 7 deletions src/Token/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,23 +202,59 @@ public function nonce(
}

/**
* Validate the 'org_id' claim.
* Validate the 'org_id' and `org_name` claims.
*
* @param array<string> $expects An array of allowed values for the 'org_id' claim. Successful if ANY match.
* @param array<string> $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;
Expand Down
48 changes: 42 additions & 6 deletions tests/Unit/Auth0Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -445,22 +445,58 @@ 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'] . '/'
]);

$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,
Expand All @@ -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([
Expand Down
41 changes: 40 additions & 1 deletion tests/Unit/Token/ValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
'auth_time' => time() - 100,
'exp' => time() + 1000,
'iat' => time() - 1000,
'azp' => uniqid()
'azp' => uniqid(),
];
});

Expand Down Expand Up @@ -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);
});
4 changes: 2 additions & 2 deletions tests/Unit/TokenTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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__']
]]);

Expand Down

0 comments on commit 958e06a

Please sign in to comment.