Skip to content

Commit 477bfa2

Browse files
committed
Percent-encode the offer carried in the credential offer URI
1 parent 6cea05a commit 477bfa2

3 files changed

Lines changed: 87 additions & 16 deletions

File tree

docs/8-api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Response:
134134

135135
```json
136136
{
137-
"credential_offer_uri": "openid-credential-offer://?credential_offer={\"credential_issuer\":\"https:\\/\\/idp.mivanci.incubator.hexaa.eu\",\"credential_configuration_ids\":[\"ResearchAndScholarshipCredentialDcSdJwt\"],\"grants\":{\"authorization_code\":{\"issuer_state\":\"30616b68fa26b00c5a6391faffc02e4e4fd9b0023fd6a3aa29ec754e2f5e2871\"}}}"
137+
"credential_offer_uri": "openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fidp.mivanci.incubator.hexaa.eu%22%2C%22credential_configuration_ids%22%3A%5B%22ResearchAndScholarshipCredentialDcSdJwt%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%2230616b68fa26b00c5a6391faffc02e4e4fd9b0023fd6a3aa29ec754e2f5e2871%22%7D%7D%7D"
138138
}
139139

140140
```
@@ -168,7 +168,7 @@ Response:
168168

169169
```json
170170
{
171-
"credential_offer_uri": "openid-credential-offer://?credential_offer={\"credential_issuer\":\"https:\\/\\/idp.mivanci.incubator.hexaa.eu\",\"credential_configuration_ids\":[\"ResearchAndScholarshipCredentialDcSdJwt\"],\"grants\":{\"urn:ietf:params:oauth:grant-type:pre-authorized_code\":{\"pre-authorized_code\":\"_ffcdf6d86cd564c300346351dce0b4ccb2fde304e2\",\"tx_code\":{\"input_mode\":\"numeric\",\"length\":4,\"description\":\"Please provide the one-time code that was sent to e-mail testuser@example.com\"}}}}"
171+
"credential_offer_uri": "openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fidp.mivanci.incubator.hexaa.eu%22%2C%22credential_configuration_ids%22%3A%5B%22ResearchAndScholarshipCredentialDcSdJwt%22%5D%2C%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22_ffcdf6d86cd564c300346351dce0b4ccb2fde304e2%22%2C%22tx_code%22%3A%7B%22input_mode%22%3A%22numeric%22%2C%22length%22%3A4%2C%22description%22%3A%22Please%20provide%20the%20one-time%20code%20that%20was%20sent%20to%20e-mail%20testuser%40example.com%22%7D%7D%7D%7D"
172172
}
173173
```
174174

src/Factories/CredentialOfferUriFactory.php

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public function __construct(
5050
/**
5151
* @param string[] $credentialConfigurationIds
5252
* @throws \SimpleSAML\OpenID\Exceptions\OpenIdException
53+
* @throws \JsonException
5354
*/
5455
public function buildForAuthorization(
5556
array $credentialConfigurationIds,
@@ -89,19 +90,13 @@ public function buildForAuthorization(
8990
],
9091
);
9192

92-
$credentialOfferValue = $credentialOffer->jsonSerialize();
93-
$parameterName = ParametersEnum::CredentialOfferUri->value;
94-
if (is_array($credentialOfferValue)) {
95-
$parameterName = ParametersEnum::CredentialOffer->value;
96-
$credentialOfferValue = json_encode($credentialOfferValue);
97-
}
98-
99-
return "openid-credential-offer://?$parameterName=$credentialOfferValue";
93+
return $this->buildUri($credentialOffer->jsonSerialize());
10094
}
10195

10296
/**
10397
* @param string[] $credentialConfigurationIds
10498
* @throws \SimpleSAML\OpenID\Exceptions\OpenIdException
99+
* @throws \JsonException
105100
*/
106101
public function buildPreAuthorized(
107102
array $credentialConfigurationIds,
@@ -232,14 +227,34 @@ public function buildPreAuthorized(
232227
$this->sendTxCodeByEmail($txCode, $userEmail);
233228
}
234229

235-
$credentialOfferValue = $credentialOffer->jsonSerialize();
236-
$parameterName = ParametersEnum::CredentialOfferUri->value;
237-
if (is_array($credentialOfferValue)) {
230+
return $this->buildUri($credentialOffer->jsonSerialize());
231+
}
232+
233+
/**
234+
* Build the offer URI a wallet is sent to, carrying the offer either by value or by reference.
235+
*
236+
* The offer travels as a query parameter, so its value has to be percent encoded. A by-reference
237+
* offer is a URL which may carry a query string of its own, and a by-value offer is JSON, full of
238+
* characters a query cannot hold literally. Appended raw, an '&' in either would split the value
239+
* into a second parameter and a '#' would truncate it into a fragment.
240+
*
241+
* @param string|mixed[] $credentialOffer A URI to the offer, or the offer parameters themselves.
242+
* @throws \JsonException
243+
*/
244+
protected function buildUri(string|array $credentialOffer): string
245+
{
246+
if (is_array($credentialOffer)) {
238247
$parameterName = ParametersEnum::CredentialOffer->value;
239-
$credentialOfferValue = json_encode($credentialOfferValue);
248+
$parameterValue = json_encode($credentialOffer, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
249+
} else {
250+
$parameterName = ParametersEnum::CredentialOfferUri->value;
251+
$parameterValue = $credentialOffer;
240252
}
241253

242-
return "openid-credential-offer://?$parameterName=$credentialOfferValue";
254+
return 'openid-credential-offer://?' . http_build_query(
255+
[$parameterName => $parameterValue],
256+
encoding_type: PHP_QUERY_RFC3986,
257+
);
243258
}
244259

245260
/**

tests/unit/src/Factories/CredentialOfferUriFactoryTest.php

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PHPUnit\Framework\Attributes\UsesClass;
1111
use PHPUnit\Framework\MockObject\MockObject;
1212
use PHPUnit\Framework\TestCase;
13+
use ReflectionMethod;
1314
use RuntimeException;
1415
use SimpleSAML\Module\oidc\Bridges\SspBridge;
1516
use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils;
@@ -98,6 +99,43 @@ public function testFallbackUserIdentifierDoesNotLogAttributesOrExceptionDetails
9899
$this->assertStringNotContainsString($sensitiveExceptionValue, $logs);
99100
}
100101

102+
public function testByValueOfferSurvivesQueryParsing(): void
103+
{
104+
// Appended raw, the '&' would split the offer into a second query parameter and the '#' would
105+
// truncate it into a fragment, so a wallet would never see the whole issuer.
106+
$issuer = 'https://issuer.example.org/vci?tenant=a&region=b#frag';
107+
108+
$credentialOfferUri = $this->factory(
109+
$this->createMock(LoggerService::class),
110+
$this->createMock(UserIdentifierResolver::class),
111+
issuer: $issuer,
112+
)->buildForAuthorization(['credential-configuration']);
113+
114+
$parameters = $this->parseOfferUriQuery($credentialOfferUri);
115+
116+
$this->assertSame(['credential_offer'], array_keys($parameters));
117+
$offer = json_decode($parameters['credential_offer'], true, 512, JSON_THROW_ON_ERROR);
118+
$this->assertSame($issuer, $offer['credential_issuer']);
119+
}
120+
121+
public function testByReferenceOfferSurvivesQueryParsing(): void
122+
{
123+
// An offer passed by reference is a URL which may carry a query string of its own.
124+
$offerUri = 'https://issuer.example.org/offers/1?tenant=a&format=jwt#frag';
125+
126+
$factory = $this->factory(
127+
$this->createMock(LoggerService::class),
128+
$this->createMock(UserIdentifierResolver::class),
129+
);
130+
131+
$parameters = $this->parseOfferUriQuery(
132+
(new ReflectionMethod($factory, 'buildUri'))->invoke($factory, $offerUri),
133+
);
134+
135+
$this->assertSame(['credential_offer_uri'], array_keys($parameters));
136+
$this->assertSame($offerUri, $parameters['credential_offer_uri']);
137+
}
138+
101139
public function testBuildTxCodeGeneratesFourDigitNumericCode(): void
102140
{
103141
$txCode = $this->factory(
@@ -116,14 +154,15 @@ private function factory(
116154
?ClientRepository $clientRepository = null,
117155
?UserRepository $userRepository = null,
118156
?UserEntityFactory $userEntityFactory = null,
157+
string $issuer = 'https://issuer.example.org',
119158
): CredentialOfferUriFactory {
120159
$moduleConfig = $this->createMock(ModuleConfig::class);
121160
$moduleConfig->method('getVciCredentialConfigurationIdsSupported')
122161
->willReturn(['credential-configuration']);
123162
$moduleConfig->method('getUserIdentifierAttributes')->willReturn(['uid']);
124163
$moduleConfig->method('getDefaultUsersEmailAttributeName')->willReturn('mail');
125164
$moduleConfig->method('getAuthCodeDuration')->willReturn(new DateInterval('PT10M'));
126-
$moduleConfig->method('getIssuer')->willReturn('https://issuer.example.org');
165+
$moduleConfig->method('getIssuer')->willReturn($issuer);
127166

128167
$random = $this->createMock(Random::class);
129168
$random->method('generateID')->willReturn('pre-authorized-code-secret');
@@ -149,6 +188,23 @@ private function factory(
149188
);
150189
}
151190

191+
/**
192+
* Parse the query of an offer URI back into parameters. parse_url() rejects the
193+
* openid-credential-offer:// scheme outright, so the prefix is stripped by hand.
194+
*
195+
* @return array<string, string>
196+
*/
197+
private function parseOfferUriQuery(string $credentialOfferUri): array
198+
{
199+
$prefix = 'openid-credential-offer://?';
200+
$this->assertStringStartsWith($prefix, $credentialOfferUri);
201+
202+
parse_str(substr($credentialOfferUri, strlen($prefix)), $parameters);
203+
204+
/** @var array<string, string> $parameters */
205+
return $parameters;
206+
}
207+
152208
private function captureLogs(LoggerService&MockObject $logger, string $level): void
153209
{
154210
$logger->method($level)->willReturnCallback(

0 commit comments

Comments
 (0)