Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDK-4393] feat(auth): Support Organization Name with Authorization API #719

Merged
merged 11 commits into from
Jul 19, 2023
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
52 changes: 45 additions & 7 deletions src/Token/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,23 +202,61 @@ 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) {
// Normalize the org_name claim to lowercase for case insensitive comparisons.
$lowercaseOrganizationName = strtolower($organizationName);
evansims marked this conversation as resolved.
Show resolved Hide resolved
$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($lowercaseOrganizationName, $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