Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 28 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,23 @@ $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::normalize([
'https://api.example.com/',
'https://mcp.example.com/',
]);

$isAllowed = ResourceIndicators::isSubset($resources, $previouslyGrantedResources);
$unchanged = ResourceIndicators::sameSet($resources, $previouslyGrantedResources);
$audience = ResourceIndicators::audience('https://cloud.example.com/v1/project', $resources);
```

## Tests

To run all unit tests, use the following Docker command:
Expand All @@ -282,4 +309,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
85 changes: 85 additions & 0 deletions src/Auth/OAuth2/ResourceIndicators.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

namespace Utopia\Auth\OAuth2;

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

$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 $normalized;
}

public 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']));
}
Comment thread
ChiragAgg5k marked this conversation as resolved.
Outdated
Comment thread
ChiragAgg5k marked this conversation as resolved.
Outdated

/**
* @param array<int, string> $requested
* @param array<int, string> $granted
*/
public static function isSubset(array $requested, array $granted): bool
{
return empty(\array_diff($requested, $granted));
}

/**
* @param array<int, string> $left
* @param array<int, string> $right
*/
public static function sameSet(array $left, array $right): bool
{
$sortedLeft = $left;
$sortedRight = $right;
\sort($sortedLeft, \SORT_STRING);
\sort($sortedRight, \SORT_STRING);

return $sortedLeft === $sortedRight;
}

/**
* @param array<int, string> $resources
* @return string|array<int, string>
*/
public static function audience(string $defaultAudience, array $resources): string|array
{
if (empty($resources)) {
return $defaultAudience;
}

$resourcesWithoutDefault = \array_values(
\array_filter($resources, fn ($resource) => $resource !== $defaultAudience)
);

return \array_values(\array_merge([$defaultAudience], $resourcesWithoutDefault));
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
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
86 changes: 86 additions & 0 deletions tests/Auth/OAuth2/ResourceIndicatorsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?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::normalize(null));
$this->assertSame([], ResourceIndicators::normalize(''));
}

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

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

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

ResourceIndicators::normalize('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::normalize('/relative');
}

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

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

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

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

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

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