Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,16 @@ $jwt = $accessToken->issue(
scopes: ['openid', 'profile', 'email']
);

// RFC 9068 also allows multiple resource-server audiences.
$jwt = $accessToken->issue(
subject: 'user-123',
audience: ['https://api.example.com', 'https://mcp.example.com'],
clientId: 'client-abc',
authTime: time(),
duration: 3600,
scopes: ['openid', 'profile']
);

// Publish the public key as a JWK so resource servers can verify tokens
$jwk = $accessToken->getPublicJwk();
$keyId = $accessToken->getKeyId();
Expand Down Expand Up @@ -256,6 +266,25 @@ $jwt = $idToken->issue(

> Both asymmetric and symmetric issuers accept an optional `keyId` constructor argument (the JWS `kid` header) for key rotation. For asymmetric issuers it is derived deterministically from the public key when omitted.

#### OAuth2 Resource Indicators (RFC 8707)

```php
<?php

use Utopia\Auth\OAuth2\ResourceIndicators;

$resources = ResourceIndicators::from([
'https://api.example.com/',
'https://mcp.example.com/',
]);
$previouslyGrantedResources = ResourceIndicators::from(['https://api.example.com/']);

$isAllowed = $resources->isSubsetOf($previouslyGrantedResources);
$unchanged = $resources->equals($previouslyGrantedResources);
$audience = $resources->audience('https://cloud.example.com/v1/project');
$serialized = $resources->toArray();
```

## Tests

To run all unit tests, use the following Docker command:
Expand All @@ -282,4 +311,4 @@ We truly ❤️ pull requests! If you wish to help, you can learn more about how

## Copyright and license

The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php)
The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php)
4 changes: 2 additions & 2 deletions src/Auth/Issuers/Asymmetric/AccessToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ protected function getType(): string
* authorization server, supplied to the constructor).
*
* @param string $subject The "sub" claim (the resource owner / user).
* @param string $audience The "aud" claim (the resource server identifier).
* @param string|array<int, string> $audience The "aud" claim (the resource server identifier).
* @param string $clientId The "client_id" claim (the client the token was issued to).
* @param int $authTime Time the end-user authenticated ("auth_time"), as a Unix timestamp.
* @param int $duration Lifetime of the token in seconds (used for "exp").
Expand All @@ -46,7 +46,7 @@ protected function getType(): string
*/
public function issue(
string $subject,
string $audience,
string|array $audience,
string $clientId,
Comment thread
ChiragAgg5k marked this conversation as resolved.
int $authTime,
int $duration,
Expand Down
95 changes: 95 additions & 0 deletions src/Auth/OAuth2/ResourceIndicators.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace Utopia\Auth\OAuth2;

class ResourceIndicators
{
/**
* @var array<int, string>
*/
private array $resources;

/**
* @param array<int, string> $resources
*/
private function __construct(array $resources)
{
$this->resources = $resources;
Comment thread
ChiragAgg5k marked this conversation as resolved.
}

/**
* @param string|array<int, mixed>|null $value
*/
public static function from(string|array|null $value): self
{
if ($value === null || $value === '') {
return new self([]);
}

$resources = \is_array($value) ? $value : [$value];
$normalized = [];

foreach ($resources as $resource) {
if (!\is_string($resource) || $resource === '') {
throw new \InvalidArgumentException('resource must be a non-empty absolute URI.');
}

if (!self::isValid($resource)) {
throw new \InvalidArgumentException('resource must be an absolute URI without a fragment.');
}

if (!\in_array($resource, $normalized, true)) {
$normalized[] = $resource;
}
}

return new self($normalized);
}

public function isSubsetOf(self $granted): bool
{
return empty(\array_diff($this->resources, $granted->resources));
}

public function equals(self $resources): bool
{
$left = $this->resources;
$right = $resources->resources;
\sort($left, \SORT_STRING);
\sort($right, \SORT_STRING);

return $left === $right;
}

/**
* @return string|array<int, string>
*/
public function audience(string $defaultAudience): string|array
{
$resourcesWithoutDefault = \array_values(
\array_filter($this->resources, fn ($resource) => $resource !== $defaultAudience)
);

$audience = \array_values(\array_merge([$defaultAudience], $resourcesWithoutDefault));

return \count($audience) === 1 ? $audience[0] : $audience;
}

/**
* @return array<int, string>
*/
public function toArray(): array
{
return $this->resources;
}

private static function isValid(string $resource): bool
{
$parts = \parse_url($resource);

return \is_array($parts)
&& !empty($parts['scheme'])
&& !isset($parts['fragment'])
&& (!empty($parts['host']) || !empty($parts['path']));
}
}
9 changes: 9 additions & 0 deletions tests/Auth/Issuers/Asymmetric/AccessTokenTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ public function testClaims(): void
$this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $jti);
}

public function testArrayAudience(): void
{
$audience = ['https://api.example.com', 'https://mcp.example.com'];
$token = $this->accessToken->issue('user-123', $audience, 'client-abc', 1000, 3600);
$claims = $this->decodeSegment(\explode('.', $token)[1]);

$this->assertEquals($audience, $claims['aud']);
}

public function testSignatureIsValid(): void
{
$token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600);
Expand Down
105 changes: 105 additions & 0 deletions tests/Auth/OAuth2/ResourceIndicatorsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

namespace Utopia\Tests\Auth\OAuth2;

use PHPUnit\Framework\TestCase;
use Utopia\Auth\OAuth2\ResourceIndicators;

class ResourceIndicatorsTest extends TestCase
{
public function testEmptyValueNormalizesToEmptyArray(): void
{
$this->assertSame([], ResourceIndicators::from(null)->toArray());
$this->assertSame([], ResourceIndicators::from('')->toArray());
}

public function testStringNormalizesToArray(): void
{
$this->assertSame(['https://api.example.com/'], ResourceIndicators::from('https://api.example.com/')->toArray());
}

public function testArrayIsNormalizedAndDeduplicated(): void
{
$this->assertSame(
['https://api.example.com/', 'urn:example:resource'],
ResourceIndicators::from([
'https://api.example.com/',
'urn:example:resource',
'https://api.example.com/',
])->toArray()
);
}

public function testFragmentIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('resource must be an absolute URI without a fragment.');

ResourceIndicators::from('https://api.example.com/#section');
}

public function testRelativeUriIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('resource must be an absolute URI without a fragment.');

ResourceIndicators::from('/relative');
}

public function testNonStringIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('resource must be a non-empty absolute URI.');

ResourceIndicators::from(['https://api.example.com/', 42]);
}

public function testSubset(): void
{
$this->assertTrue(
ResourceIndicators::from(['https://api.example.com/'])
->isSubsetOf(ResourceIndicators::from(['https://api.example.com/', 'https://files.example.com/']))
);
$this->assertFalse(
ResourceIndicators::from(['https://api.example.com/'])
->isSubsetOf(ResourceIndicators::from(['https://files.example.com/']))
);
}

public function testSameSetIgnoresOrder(): void
{
$this->assertTrue(
ResourceIndicators::from(['https://api.example.com/', 'https://files.example.com/'])
->equals(ResourceIndicators::from(['https://files.example.com/', 'https://api.example.com/']))
);
}

public function testAudienceUsesDefaultWhenNoResourcesRequested(): void
{
$this->assertSame(
'https://cloud.appwrite.io/v1/project1',
ResourceIndicators::from(null)->audience('https://cloud.appwrite.io/v1/project1')
);
}

public function testAudiencePrependsDefaultAndDeduplicatesIt(): void
{
$this->assertSame(
['https://cloud.appwrite.io/v1/project1', 'https://mcp.example.com/'],
ResourceIndicators::from([
'https://mcp.example.com/',
'https://cloud.appwrite.io/v1/project1',
])->audience('https://cloud.appwrite.io/v1/project1')
);
}

public function testAudienceReturnsStringWhenOnlyDefaultAudienceRemains(): void
{
$this->assertSame(
'https://cloud.appwrite.io/v1/project1',
ResourceIndicators::from([
'https://cloud.appwrite.io/v1/project1',
])->audience('https://cloud.appwrite.io/v1/project1')
);
}
}
Loading