Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,23 @@ $accessToken = new AccessToken(
// Issue a signed RS256 access token
$jwt = $accessToken->issue(
subject: 'user-123', // "sub" — the resource owner
audience: 'https://api.example.com', // "aud" — the resource server
audience: ['https://api.example.com'], // "aud" — the resource server
clientId: 'client-abc', // "client_id" — the client it was issued to
authTime: time(), // "auth_time" — when the user authenticated
duration: 3600, // Lifetime in seconds ("exp")
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,30 @@ $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\InvalidResourceException;
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);
// Falls back to the default only when no resource was requested.
$audience = $resources->audience('https://cloud.example.com/v1/project');
$serialized = $resources->toArray();

// Invalid resources throw InvalidResourceException with the RFC 8707 error code:
// InvalidResourceException::ERROR_CODE === 'invalid_target'
```

## Tests

To run all unit tests, use the following Docker command:
Expand All @@ -282,4 +316,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 array<int, string> $audience The "aud" claim (the resource server identifiers).
* @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,
array $audience,
string $clientId,
Comment thread
ChiragAgg5k marked this conversation as resolved.
int $authTime,
int $duration,
Expand Down
11 changes: 11 additions & 0 deletions src/Auth/OAuth2/InvalidResourceException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Utopia\Auth\OAuth2;

class InvalidResourceException extends \InvalidArgumentException
{
/**
* RFC 8707 Section 2.
*/
public const ERROR_CODE = 'invalid_target';
}
108 changes: 108 additions & 0 deletions src/Auth/OAuth2/ResourceIndicators.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace Utopia\Auth\OAuth2;

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

/**
* @param array<int, string> $resources
*/
private function __construct(array $resources)
{
if ($resources !== \array_values($resources)) {
throw new InvalidResourceException('resources must be a list of absolute URIs.');
}

$seen = [];

foreach ($resources as $resource) {
switch (true) {
case !\is_string($resource) || $resource === '':
throw new InvalidResourceException('resource must be a non-empty absolute URI.');

case \is_string($resource) && !self::isValid($resource):
throw new InvalidResourceException('resource must be an absolute URI without a fragment.');

case \in_array($resource, $seen, true):
throw new InvalidResourceException('resources must not contain duplicates.');
}

$seen[] = $resource;
}

$this->resources = $resources;
Comment thread
ChiragAgg5k marked this conversation as resolved.
}

/**
* @param string|array<int, mixed>|null $value
*
* @throws InvalidResourceException
*/
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 (!\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 array<int, string>
*/
public function audience(string $defaultAudience): array
{
if ($this->resources === []) {
return [$defaultAudience];
}

return $this->resources;
}

/**
* @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']));
}
}
33 changes: 21 additions & 12 deletions tests/Auth/Issuers/Asymmetric/AccessTokenTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ private function decodeSegment(string $segment): array

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

// RFC 9068 §2.1: access tokens carry the "at+jwt" media type.
Expand All @@ -49,13 +49,13 @@ public function testHeaderType(): void
public function testClaims(): void
{
$before = \time();
$token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600, ['read', 'write']);
$token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600, ['read', 'write']);
$after = \time();

$claims = $this->decodeSegment(\explode('.', $token)[1]);

$this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']);
$this->assertEquals('https://api.example.com', $claims['aud']);
$this->assertEquals(['https://api.example.com'], $claims['aud']);
$this->assertEquals('user-123', $claims['sub']);
$this->assertEquals('client-abc', $claims['client_id']);
$this->assertEquals('read write', $claims['scope']);
Expand All @@ -68,9 +68,18 @@ public function testClaims(): void
$this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $jti);
}

public function testAudienceClaim(): 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);
$token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600);

$parts = \explode('.', $token);
$result = \openssl_verify(
Expand All @@ -85,15 +94,15 @@ public function testSignatureIsValid(): void

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

$this->assertArrayNotHasKey('scope', $claims);
}

public function testScopeCannotBeInjectedViaClaimsWhenEmpty(): void
{
$token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600, [], null, [
$token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600, [], null, [
'scope' => 'admin',
]);
$claims = $this->decodeSegment(\explode('.', $token)[1]);
Expand All @@ -103,7 +112,7 @@ public function testScopeCannotBeInjectedViaClaimsWhenEmpty(): void

public function testScopeCannotBeOverriddenViaClaims(): void
{
$token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600, ['read'], null, [
$token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600, ['read'], null, [
'scope' => 'admin',
]);
$claims = $this->decodeSegment(\explode('.', $token)[1]);
Expand All @@ -113,23 +122,23 @@ public function testScopeCannotBeOverriddenViaClaims(): void

public function testJtiIsGeneratedAndUnique(): void
{
$first = $this->decodeSegment(\explode('.', $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600))[1]);
$second = $this->decodeSegment(\explode('.', $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600))[1]);
$first = $this->decodeSegment(\explode('.', $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600))[1]);
$second = $this->decodeSegment(\explode('.', $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600))[1]);

$this->assertNotEquals($first['jti'], $second['jti']);
}

public function testCustomJti(): void
{
$token = $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600, [], 'fixed-jti');
$token = $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600, [], 'fixed-jti');
$claims = $this->decodeSegment(\explode('.', $token)[1]);

$this->assertEquals('fixed-jti', $claims['jti']);
}

public function testAdditionalClaims(): void
{
$token = $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600, [], null, [
$token = $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600, [], null, [
'tokenId' => 'identity-row-1',
]);
$claims = $this->decodeSegment(\explode('.', $token)[1]);
Expand All @@ -139,7 +148,7 @@ public function testAdditionalClaims(): void

public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void
{
$token = $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600, [], null, [
$token = $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600, [], null, [
'sub' => 'attacker',
'iss' => 'https://evil.example.com',
'client_id' => 'evil',
Expand Down
Loading
Loading