1010use PHPUnit \Framework \Attributes \UsesClass ;
1111use PHPUnit \Framework \MockObject \MockObject ;
1212use PHPUnit \Framework \TestCase ;
13+ use ReflectionMethod ;
1314use RuntimeException ;
1415use SimpleSAML \Module \oidc \Bridges \SspBridge ;
1516use 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®ion=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