Skip to content

Commit 81870f0

Browse files
committed
Start with Nonce endpoint
1 parent 8155c69 commit 81870f0

10 files changed

Lines changed: 353 additions & 2 deletions

File tree

routing/routes/routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController;
2525
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController;
2626
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\JwtVcIssuerConfigurationController;
27+
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\NonceController;
2728
use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum;
2829
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
2930

@@ -126,6 +127,10 @@
126127
->controller([CredentialIssuerCredentialController::class, 'credential'])
127128
->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]);
128129

130+
$routes->add(RoutesEnum::CredentialIssuerNonce->name, RoutesEnum::CredentialIssuerNonce->value)
131+
->controller([NonceController::class, 'nonce'])
132+
->methods([HttpMethodsEnum::POST->value]);
133+
129134
/*****************************************************************************************************************
130135
* SD-JWT-based Verifiable Credentials (SD-JWT VC)
131136
****************************************************************************************************************/

src/Codebooks/RoutesEnum.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ enum RoutesEnum: string
6363

6464
case CredentialIssuerConfiguration = '.well-known/openid-credential-issuer';
6565
case CredentialIssuerCredential = 'credential-issuer/credential';
66+
case CredentialIssuerNonce = 'credential-issuer/nonce';
6667

6768
/*****************************************************************************************************************
6869
* SD-JWT-based Verifiable Credentials (SD-JWT VC)

src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public function configuration(): Response
7272
ClaimsEnum::CredentialEndpoint->value => $this->routes->urlCredentialIssuerCredential(),
7373

7474
// OPTIONAL
75-
// nonce_endpoint
75+
ClaimsEnum::NonceEndpoint->value => $this->routes->urlCredentialIssuerNonce(),
7676

7777
// OPTIONAL
7878
// deferred_credential_endpoint

src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
1616
use SimpleSAML\Module\oidc\Server\ResourceServer;
1717
use SimpleSAML\Module\oidc\Services\LoggerService;
18+
use SimpleSAML\Module\oidc\Services\NonceService;
1819
use SimpleSAML\Module\oidc\Utils\RequestParamsResolver;
1920
use SimpleSAML\Module\oidc\Utils\Routes;
2021
use SimpleSAML\OpenID\Codebooks\AtContextsEnum;
@@ -53,6 +54,7 @@ public function __construct(
5354
protected readonly UserRepository $userRepository,
5455
protected readonly Did $did,
5556
protected readonly IssuerStateRepository $issuerStateRepository,
57+
protected readonly NonceService $nonceService,
5658
) {
5759
if (!$this->moduleConfig->getVciEnabled()) {
5860
$this->loggerService->warning('Verifiable Credential capabilities not enabled.');
@@ -447,7 +449,28 @@ public function credential(Request $request): Response
447449
$proof->verifyWithKey($jwk);
448450

449451
$this->loggerService->debug('Proof verified successfully using did:key ' . $didKey);
450-
// Set it as a subject identifier (bind it).
452+
453+
// Verify nonce
454+
$nonce = $proof->getNonce();
455+
if ($nonce === null) {
456+
return $this->routes->newJsonErrorResponse(
457+
'invalid_proof',
458+
'Proof MUST contain a c_nonce.',
459+
);
460+
}
461+
462+
if (!$this->nonceService->validateNonce($nonce)) {
463+
return $this->routes->newJsonResponse(
464+
[
465+
'error' => 'invalid_nonce',
466+
'error_description' => 'c_nonce is invalid or expired.',
467+
'c_nonce' => $this->nonceService->generateNonce(),
468+
],
469+
400,
470+
);
471+
}
472+
473+
// Set it as a subject identifier (bind it).
451474
$sub = $didKey;
452475
} else {
453476
$this->loggerService->warning(
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Module\oidc\Controllers\VerifiableCredentials;
6+
7+
use SimpleSAML\Module\oidc\Services\LoggerService;
8+
use SimpleSAML\Module\oidc\Services\NonceService;
9+
use SimpleSAML\Module\oidc\Utils\Routes;
10+
use Symfony\Component\HttpFoundation\Response;
11+
12+
class NonceController
13+
{
14+
public function __construct(
15+
protected readonly NonceService $nonceService,
16+
protected readonly Routes $routes,
17+
protected readonly LoggerService $loggerService,
18+
) {
19+
}
20+
21+
/**
22+
* @throws \Exception
23+
*/
24+
public function nonce(): Response
25+
{
26+
$this->loggerService->debug('NonceController::nonce');
27+
28+
$nonce = $this->nonceService->generateNonce();
29+
30+
return $this->routes->newJsonResponse(
31+
['c_nonce' => $nonce],
32+
200,
33+
['Cache-Control' => 'no-store'],
34+
);
35+
}
36+
}

src/Services/Container.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse;
102102
use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer;
103103
use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator;
104+
use SimpleSAML\Module\oidc\Services\NonceService;
104105
use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder;
105106
use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb;
106107
use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver;
@@ -583,6 +584,9 @@ public function __construct()
583584

584585
$errorResponder = new ErrorResponder($psrHttpBridge);
585586
$this->services[ErrorResponder::class] = $errorResponder;
587+
588+
$nonceService = new NonceService($jws, $moduleConfig, $loggerService);
589+
$this->services[NonceService::class] = $nonceService;
586590
}
587591

588592
/**

src/Services/NonceService.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Module\oidc\Services;
6+
7+
use SimpleSAML\Module\oidc\ModuleConfig;
8+
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
9+
use SimpleSAML\OpenID\Jws;
10+
11+
class NonceService
12+
{
13+
public function __construct(
14+
protected readonly Jws $jws,
15+
protected readonly ModuleConfig $moduleConfig,
16+
protected readonly LoggerService $loggerService,
17+
) {
18+
}
19+
20+
/**
21+
* @throws \Exception
22+
*/
23+
public function generateNonce(): string
24+
{
25+
$signatureKeyPair = $this->moduleConfig->getVciSignatureKeyPairBag()->getFirstOrFail();
26+
$currentTimestamp = $this->jws->helpers()->dateTime()->getUtc()->getTimestamp();
27+
28+
// Nonce is valid for 5 minutes (300 seconds)
29+
// TODO mivanci Consider making this configurable.
30+
$expiryTimestamp = $currentTimestamp + 300;
31+
32+
$payload = [
33+
ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(),
34+
ClaimsEnum::Iat->value => $currentTimestamp,
35+
ClaimsEnum::Exp->value => $expiryTimestamp,
36+
'nonce_val' => bin2hex(random_bytes(16)),
37+
];
38+
39+
$header = [
40+
ClaimsEnum::Kid->value => $signatureKeyPair->getKeyPair()->getKeyId(),
41+
];
42+
43+
return $this->jws->parsedJwsFactory()->fromData(
44+
$signatureKeyPair->getKeyPair()->getPrivateKey(),
45+
$signatureKeyPair->getSignatureAlgorithm(),
46+
$payload,
47+
$header,
48+
)->getToken();
49+
}
50+
51+
public function validateNonce(string $nonce): bool
52+
{
53+
try {
54+
$parsedJws = $this->jws->parsedJwsFactory()->fromToken($nonce);
55+
56+
// Verify signature
57+
$signatureKeyPair = $this->moduleConfig->getVciSignatureKeyPairBag()->getFirstOrFail();
58+
$parsedJws->verifyWithKey($signatureKeyPair->getKeyPair()->getPublicKey()->jwk()->all());
59+
60+
// Verify issuer
61+
if ($parsedJws->getIssuer() !== $this->moduleConfig->getIssuer()) {
62+
$this->loggerService->warning('Nonce validation failed: invalid issuer.');
63+
return false;
64+
}
65+
66+
// Verify expiration
67+
$currentTimestamp = $this->jws->helpers()->dateTime()->getUtc()->getTimestamp();
68+
if ($parsedJws->getExpirationTime() < $currentTimestamp) {
69+
$this->loggerService->warning('Nonce validation failed: expired.');
70+
return false;
71+
}
72+
73+
return true;
74+
} catch (\Exception $e) {
75+
$this->loggerService->warning('Nonce validation failed: ' . $e->getMessage());
76+
return false;
77+
}
78+
}
79+
}

src/Utils/Routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ public function urlCredentialIssuerCredential(array $parameters = []): string
227227
return $this->getModuleUrl(RoutesEnum::CredentialIssuerCredential->value, $parameters);
228228
}
229229

230+
public function urlCredentialIssuerNonce(array $parameters = []): string
231+
{
232+
return $this->getModuleUrl(RoutesEnum::CredentialIssuerNonce->value, $parameters);
233+
}
234+
230235
/*****************************************************************************************************************
231236
* SD-JWT-based Verifiable Credentials (SD-JWT VC)
232237
****************************************************************************************************************/
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Test\Module\oidc\unit\Controllers\VerifiableCredentials;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\MockObject\MockObject;
9+
use PHPUnit\Framework\TestCase;
10+
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\NonceController;
11+
use SimpleSAML\Module\oidc\Services\LoggerService;
12+
use SimpleSAML\Module\oidc\Services\NonceService;
13+
use SimpleSAML\Module\oidc\Utils\Routes;
14+
use Symfony\Component\HttpFoundation\JsonResponse;
15+
16+
#[CoversClass(NonceController::class)]
17+
class NonceControllerTest extends TestCase
18+
{
19+
protected MockObject $nonceServiceMock;
20+
protected MockObject $routesMock;
21+
protected MockObject $loggerServiceMock;
22+
23+
public function setUp(): void
24+
{
25+
$this->nonceServiceMock = $this->createMock(NonceService::class);
26+
$this->routesMock = $this->createMock(Routes::class);
27+
$this->loggerServiceMock = $this->createMock(LoggerService::class);
28+
}
29+
30+
/**
31+
* @throws \Exception
32+
*/
33+
public function testNonce(): void
34+
{
35+
$this->nonceServiceMock->expects($this->once())
36+
->method('generateNonce')
37+
->willReturn('mocked_nonce');
38+
39+
$responseMock = $this->createMock(JsonResponse::class);
40+
$this->routesMock->expects($this->once())
41+
->method('newJsonResponse')
42+
->with(['c_nonce' => 'mocked_nonce'], 200, ['Cache-Control' => 'no-store'])
43+
->willReturn($responseMock);
44+
45+
$sut = new NonceController($this->nonceServiceMock, $this->routesMock, $this->loggerServiceMock);
46+
$response = $sut->nonce();
47+
48+
$this->assertSame($responseMock, $response);
49+
}
50+
}

0 commit comments

Comments
 (0)