Skip to content

Commit 89bcd8f

Browse files
szijpeterJakeFernandes98
authored andcommitted
feat(wallet): finalize authenticated OpenID4VP presentation flow
* feat(verifier2): expose authenticated request objects * feat(wallet): finalize authenticated OpenID4VP presentation flow
1 parent f6b304d commit 89bcd8f

32 files changed

Lines changed: 1412 additions & 101 deletions

File tree

waltid-applications/mobile-e2e-fixtures/ios/TestHelpers/DemoBackend.swift

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ public final class DemoBackend {
192192
"credentials": [scenario.verifierCredentialQuery],
193193
],
194194
]
195+
if !bindClientIDToResponseURI {
196+
coreFlow["signed_request"] = true
197+
}
195198
if let requestedSessionID {
196199
let responseURI = Self.verifierBaseURL
197200
.appendingPathComponent("verification-session")
@@ -229,16 +232,66 @@ public final class DemoBackend {
229232
userInfo: [NSLocalizedDescriptionKey: "Public demo verifier2 did not preserve the requested session ID"]
230233
)
231234
}
232-
let requestURL = response["bootstrapAuthorizationRequestUrl"] as? String
233-
?? response["authorizationRequestUrl"] as? String
234-
?? response["fullAuthorizationRequestUrl"] as? String
235-
guard let requestURL, !requestURL.isEmpty else {
235+
guard let inlineRequestURL = response["fullAuthorizationRequestUrl"] as? String,
236+
let inlineComponents = URLComponents(string: inlineRequestURL) else {
236237
throw NSError(
237238
domain: "WalletE2E",
238239
code: 302,
239-
userInfo: [NSLocalizedDescriptionKey: "Missing authorization request URL in public demo verifier2 response: \(response)"]
240+
userInfo: [NSLocalizedDescriptionKey: "Public demo verifier2 response is missing fullAuthorizationRequestUrl: \(response)"]
240241
)
241242
}
243+
guard let requestURL = response["bootstrapAuthorizationRequestUrl"] as? String,
244+
let bootstrapComponents = URLComponents(string: requestURL) else {
245+
throw NSError(
246+
domain: "WalletE2E",
247+
code: 303,
248+
userInfo: [NSLocalizedDescriptionKey: "Public demo verifier2 response is missing bootstrapAuthorizationRequestUrl: \(response)"]
249+
)
250+
}
251+
252+
let inlineQuery = inlineComponents.queryItems ?? []
253+
let bootstrapQuery = bootstrapComponents.queryItems ?? []
254+
if bindClientIDToResponseURI {
255+
let expectedResponseURI = Self.verifierBaseURL
256+
.appendingPathComponent("verification-session")
257+
.appendingPathComponent(sessionID)
258+
.appendingPathComponent("response")
259+
.absoluteString
260+
let clientID = inlineQuery.first(where: { $0.name == "client_id" })?.value
261+
let responseURI = inlineQuery.first(where: { $0.name == "response_uri" })?.value
262+
guard clientID == "redirect_uri:\(expectedResponseURI)", responseURI == expectedResponseURI else {
263+
throw NSError(
264+
domain: "WalletE2E",
265+
code: 309,
266+
userInfo: [NSLocalizedDescriptionKey: "Public demo verifier2 response-bound client_id/response_uri mismatch: \(response)"]
267+
)
268+
}
269+
guard bootstrapQuery.contains(where: { $0.name == "request_uri" && !($0.value ?? "").isEmpty }) else {
270+
throw NSError(
271+
domain: "WalletE2E",
272+
code: 310,
273+
userInfo: [NSLocalizedDescriptionKey: "Public demo verifier2 response-bound bootstrap URL is missing request_uri: \(response)"]
274+
)
275+
}
276+
} else {
277+
guard let inlineRequest = inlineQuery.first(where: { $0.name == "request" })?.value,
278+
!inlineRequest.isEmpty,
279+
inlineQuery.contains(where: { $0.name == "request_uri" }) == false else {
280+
throw NSError(
281+
domain: "WalletE2E",
282+
code: 302,
283+
userInfo: [NSLocalizedDescriptionKey: "Public demo verifier2 has not deployed the signed inline Request Object contract: fullAuthorizationRequestUrl must contain request and must not contain request_uri. Deploy the verifier2 change before running mobile E2E tests. Response: \(response)"]
284+
)
285+
}
286+
guard bootstrapQuery.contains(where: { $0.name == "request_uri" && !($0.value ?? "").isEmpty }),
287+
bootstrapQuery.contains(where: { $0.name == "request_uri_method" && $0.value == "post" }) else {
288+
throw NSError(
289+
domain: "WalletE2E",
290+
code: 303,
291+
userInfo: [NSLocalizedDescriptionKey: "Public demo verifier2 has not deployed the signed POST bootstrap contract: bootstrapAuthorizationRequestUrl must contain request_uri and request_uri_method=post. Deploy the verifier2 change before running mobile E2E tests. Response: \(response)"]
292+
)
293+
}
294+
}
242295

243296
return DemoVerifierSession(sessionID: sessionID, authorizationRequestUri: requestURL)
244297
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
3+
public enum X509RequestObjectFixture {
4+
/// SHA-256 Base64URL hash of the leaf certificate in `signedRequestObject`.
5+
public static let clientID = "x509_hash:IT2EMOaIMUTx1zj0yteLZyOgkXF6Siw2t_K42tsKT_I"
6+
7+
/// Test-only CA for the leaf in `signedRequestObject`, valid until July 2036.
8+
public static let trustAnchorPEM = """
9+
-----BEGIN CERTIFICATE-----
10+
MIIB6zCCAZGgAwIBAgIUSFgRXRcRirEjuPvPM38K5ZGrPpcwCgYIKoZIzj0EAwIw
11+
QzEvMC0GA1UEAwwmd2FsdC5pZCBBcHAtSG9zdGVkIFg1MDkgSGFzaCBUZXN0IFJv
12+
b3QxEDAOBgNVBAoMB3dhbHQuaWQwHhcNMjYwNzI3MDkyMDIwWhcNMzYwNzI0MDky
13+
MDIwWjBDMS8wLQYDVQQDDCZ3YWx0LmlkIEFwcC1Ib3N0ZWQgWDUwOSBIYXNoIFRl
14+
c3QgUm9vdDEQMA4GA1UECgwHd2FsdC5pZDBZMBMGByqGSM49AgEGCCqGSM49AwEH
15+
A0IABFqSCpC466BkSl98zsxgt63FTIjBltCCxhDCHcTZ1+T8ljrbYGHxEVWAiq/T
16+
XFvAE971gsSm/3lZSgE0lk5pfrijYzBhMB0GA1UdDgQWBBTk0sEWqbKG1u+e8nSa
17+
+01kr6XitDAfBgNVHSMEGDAWgBTk0sEWqbKG1u+e8nSa+01kr6XitDAPBgNVHRMB
18+
Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNIADBFAiBPP37V
19+
E9U0V2Uax6E9uI00qAtm+feze/eCEtuMs3fRlwIhAP96ce+J+6kuWRvUSZPYISuf
20+
2JhW6xQPYQbAK6L6nIeR
21+
-----END CERTIFICATE-----
22+
"""
23+
24+
/// Fixed ES256 `x509_hash` Request Object with a matching `x5c` leaf.
25+
public static let signedRequestObject = [
26+
"eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWF1dGh6LXJlcStqd3QiLCJ4NWMiOlsiTUlJQjdUQ0NBWktnQXdJQkFnSVVMQ3QwbGllc2tseVpnMGxsLyt2M0NVWXAwQ2N3Q2dZSUtvWkl6ajBFQXdJd1F6RXZNQzBHQTFVRUF3d21kMkZzZEM1cFpDQkJjSEF0U0c5emRHVmtJRmcxTURrZ1NHRnphQ0JVWlhOMElGSnZiM1F4RURBT0JnTlZCQW9NQjNkaGJIUXVhV1F3SGhjTk1qWXdOekkzTURreU1ESXdXaGNOTXpZd056STBNRGt5TURJd1dqQkhNVE13TVFZRFZRUUREQ3AzWVd4MExtbGtJRUZ3Y0MxSWIzTjBaV1FnV0RVd09TQklZWE5vSUZSbGMzUWdWbVZ5YVdacFpYSXhFREFPQmdOVkJBb01CM2RoYkhRdWFXUXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBVEk1c2VTWGRSRUZnS1lLWnl5SVU4RStqajRjaDdoQUlyanA2YUl6eXBhQ2FvUC9XNmM3TGMwR04rY3o4RTF1YU1POFZjdmNGN1ZQUE9lUDkxWDAzWGlvMkF3WGpBTUJnTlZIUk1CQWY4RUFqQUFNQTRHQTFVZER3RUIvd1FFQXdJSGdEQWRCZ05WSFE0RUZnUVVTZUlNb05saUdQY29aUG1nT0Z6MlhTYUJqM3N3SHdZRFZSMGpCQmd3Rm9BVTVOTEJGcW15aHRidm52SjBtdnROWksrbDRyUXdDZ1lJS29aSXpqMEVBd0lEU1FBd1JnSWhBTjBUVGhlZW1aa1pYUk5jSFhUdENjaVJOc0o5czBWckpQR1J2V2I2UnlGU0FpRUEwTHhla09hK2pxRjhKTHArbzNXNzBMaDlCY253ZDRzRkFuQWJ5WFRNUHFFPSJdfQ",
27+
".eyJhdWQiOiJodHRwczovL3NlbGYtaXNzdWVkLm1lL3YyIiwiZXhwIjoyMDgyNzU4NDAwLCJjbGllbnRfaWQiOiJ4NTA5X2hhc2g6SVQyRU1PYUlNVVR4MXpqMHl0ZUxaeU9na1hGNlNpdzJ0X0s0MnRzS1RfSSIsInJlc3BvbnNlX3R5cGUiOiJ2cF90b2tlbiIsInJlc3BvbnNlX21vZGUiOiJkaXJlY3RfcG9zdCIsInJlc3BvbnNlX3VyaSI6Imh0dHBzOi8vdmVyaWZpZXIuZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJub25jZSI6ImFwcC1ob3N0ZWQteDUwOS1oYXNoLXRlc3QiLCJzdGF0ZSI6ImFwcC1ob3N0ZWQteDUwOS1oYXNoLXRlc3QiLCJkY3FsX3F1ZXJ5Ijp7ImNyZWRlbnRpYWxzIjpbeyJpZCI6InRlc3RfY3JlZGVudGlhbCIsImZvcm1hdCI6ImRjK3NkLWp3dCIsIm1ldGEiOnsidmN0X3ZhbHVlcyI6WyJ1cm46ZXhhbXBsZTp0ZXN0Il19fV0sImNyZWRlbnRpYWxfc2V0cyI6W3sib3B0aW9ucyI6W1sidGVzdF9jcmVkZW50aWFsIl1dLCJyZXF1aXJlZCI6ZmFsc2V9XX0sImNsaWVudF9tZXRhZGF0YSI6e319",
28+
".2qQUOrCDfL2SVBHa4z-KbkhjrzRFUft-jto17Mq3p4XhSyUOhlcl_FQlGNGJLHrLri06MGnVoO8dM8mCUofRPg",
29+
].joined()
30+
31+
public static let authorizationRequestURL: URL = {
32+
var components = URLComponents(string: "openid4vp://authorize")!
33+
components.queryItems = [
34+
URLQueryItem(name: "client_id", value: clientID),
35+
URLQueryItem(name: "request", value: signedRequestObject),
36+
]
37+
return components.url!
38+
}()
39+
}

waltid-applications/waltid-wallet-demo-ios/iosApp/iosAppTests/MobileWalletIntegrationTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,46 @@ final class MobileWalletIntegrationTests: XCTestCase {
132132

133133
// MARK: - Tests (mirror Android MobileWalletIntegrationTest.kt)
134134

135+
func testAppHostedWalletValidatesSignedRequestObjectCertificateChain() async throws {
136+
let wallet = try await Wallet(
137+
configuration: WalletConfiguration(
138+
walletID: testWalletId,
139+
clientIDTrustConfiguration: WalletClientIDTrustConfiguration(
140+
x509TrustAnchorsPEM: [X509RequestObjectFixture.trustAnchorPEM]
141+
)
142+
)
143+
)
144+
_ = try await wallet.bootstrap()
145+
146+
let result = try await wallet.previewPresentation(request: X509RequestObjectFixture.authorizationRequestURL)
147+
guard case let .ready(preview) = result else {
148+
XCTFail("The pinned X.509 Request Object should authenticate and produce a preview: \(result)")
149+
return
150+
}
151+
XCTAssertTrue(preview.credentialOptions.isEmpty)
152+
XCTAssertTrue(preview.credentialRequirements.isEmpty)
153+
}
154+
155+
func testAppHostedWalletRejectsUntrustedSignedRequestObjectCertificateChain() async throws {
156+
let wallet = try await Wallet(
157+
configuration: WalletConfiguration(
158+
walletID: testWalletId,
159+
clientIDTrustConfiguration: WalletClientIDTrustConfiguration(
160+
x509TrustAnchorsPEM: [EudiTestBackend.verifierTrustAnchorPEM]
161+
)
162+
)
163+
)
164+
165+
do {
166+
_ = try await wallet.previewPresentation(request: X509RequestObjectFixture.authorizationRequestURL)
167+
XCTFail("Expected the signed Request Object to be rejected without the pinned trust anchor")
168+
} catch {
169+
let description = String(describing: error)
170+
XCTAssertFalse(description.contains("No matches found for required credential queries"), description)
171+
XCTAssertTrue(description.contains("InvalidSignature"), description)
172+
}
173+
}
174+
135175
func testBootstrapCreatesKeyAndDid() async throws {
136176
let wallet = try await makeWallet()
137177

waltid-libraries/credentials/waltid-dcql/src/commonMain/kotlin/id/walt/dcql/CredentialModel.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ data class DcqlDisclosure(
2121
val name: String?,
2222
val value: JsonElement,
2323

24+
/** Full SD-JWT Claim Path locating this disclosure within the credential. */
25+
val location: List<JsonElement>? = null,
26+
2427
// A place to pass a reference to the original (using which this class was created)
2528
//val original: Any? = null // no longer needed?
2629
)
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
package id.walt.dcql
22

33
// Custom exception for match failures
4-
class DcqlMatchException(message: String) : Exception(message)
4+
open class DcqlMatchException(message: String) : Exception(message)
5+
6+
/** A well-formed request asks for a credential that is not available locally. */
7+
class RequiredCredentialUnavailableException(
8+
val queryIds: List<String>,
9+
message: String,
10+
) : DcqlMatchException(message)
11+
12+
/** The request contains a constraint that this matcher cannot evaluate safely. */
13+
class UnsupportedDcqlConstraintException(message: String) : DcqlMatchException(message)

0 commit comments

Comments
 (0)