Skip to content

Commit 78470ec

Browse files
sam-munckeArtelasionutmanolache-okta
authored
feat: Add support for fetching rich consents (#139)
* Fix flakey JWT test * Adds implementation of Consent API client for retrieving rich-consents for CIBA flow * Add failure case test to ConsentSpec * Small changes to align with Android SDK implementation * Add README section for fetching consent * Code review (#140) * Code review * removed context path manipulation * Tests fixed * Changed method signature to correct --------- Co-authored-by: Ionut Manolache <[email protected]> * backward compatibility with guardian domains --------- Co-authored-by: Artem Bakanov <[email protected]> Co-authored-by: Ionut Manolache <[email protected]>
1 parent 70eb99e commit 78470ec

21 files changed

+708
-49
lines changed

Guardian.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@
9898
5FAE6729210250E300F149A3 /* AsymmetricPublicKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FAE6728210250E300F149A3 /* AsymmetricPublicKey.swift */; };
9999
5FF42BA521091AD10082459F /* NetworkOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FF42BA421091AD10082459F /* NetworkOperation.swift */; };
100100
5FF42BA821091F150082459F /* NetworkOperationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FF42BA721091F150082459F /* NetworkOperationSpec.swift */; };
101+
AB58F6102CF9EE4A00E642F6 /* ConsentAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58F60E2CF9EE4A00E642F6 /* ConsentAPI.swift */; };
102+
AB58F6112CF9EE4A00E642F6 /* ConsentAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58F60F2CF9EE4A00E642F6 /* ConsentAPIClient.swift */; };
103+
AB9381852D03359700C47B1E /* ConsentSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB9381842D03359700C47B1E /* ConsentSpec.swift */; };
101104
ED1E39A52B7E6C1300E8609A /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1E39A42B7E6C1300E8609A /* MockURLProtocol.swift */; };
102105
ED1E39A72B822A2500E8609A /* MockURLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1E39A62B822A2500E8609A /* MockURLResponse.swift */; };
103106
ED1E39A92B82A9B100E8609A /* MockURLProtocolCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1E39A82B82A9B100E8609A /* MockURLProtocolCondition.swift */; };
@@ -254,6 +257,9 @@
254257
5FAE6728210250E300F149A3 /* AsymmetricPublicKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsymmetricPublicKey.swift; sourceTree = "<group>"; };
255258
5FF42BA421091AD10082459F /* NetworkOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkOperation.swift; sourceTree = "<group>"; };
256259
5FF42BA721091F150082459F /* NetworkOperationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkOperationSpec.swift; sourceTree = "<group>"; };
260+
AB58F60E2CF9EE4A00E642F6 /* ConsentAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentAPI.swift; sourceTree = "<group>"; };
261+
AB58F60F2CF9EE4A00E642F6 /* ConsentAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentAPIClient.swift; sourceTree = "<group>"; };
262+
AB9381842D03359700C47B1E /* ConsentSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentSpec.swift; sourceTree = "<group>"; };
257263
ED1E39A42B7E6C1300E8609A /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = "<group>"; };
258264
ED1E39A62B822A2500E8609A /* MockURLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLResponse.swift; sourceTree = "<group>"; };
259265
ED1E39A82B82A9B100E8609A /* MockURLProtocolCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocolCondition.swift; sourceTree = "<group>"; };
@@ -419,6 +425,8 @@
419425
children = (
420426
2377A6421D806DFA00D6FB04 /* API.swift */,
421427
2374A9E01D670F5900737F2E /* APIClient.swift */,
428+
AB58F60E2CF9EE4A00E642F6 /* ConsentAPI.swift */,
429+
AB58F60F2CF9EE4A00E642F6 /* ConsentAPIClient.swift */,
422430
2377A6401D806D9900D6FB04 /* DeviceAPI.swift */,
423431
232A85D91D6BDFCA00048DF7 /* DeviceAPIClient.swift */,
424432
5F07A58E210A6E5900819FA2 /* NoContent.swift */,
@@ -500,6 +508,7 @@
500508
2356C82F1D88A10600B6C84A /* GuardianSpec.swift */,
501509
23D3E43F1D91BEA200F3FDE2 /* Base32Spec.swift */,
502510
233F75281D93076600B8C15C /* NotificationSpec.swift */,
511+
AB9381842D03359700C47B1E /* ConsentSpec.swift */,
503512
5F1DB45C1DA4750F00264437 /* AuthenticationSpec.swift */,
504513
2331BFEC1DD52ED50047F1D4 /* JWTSpec.swift */,
505514
);
@@ -756,6 +765,8 @@
756765
5F07A5AA210FAF9600819FA2 /* JWT.swift in Sources */,
757766
5F07A58D210A6DF000819FA2 /* CodableAdditions.swift in Sources */,
758767
5F07A58521092C7D00819FA2 /* ClientInfo.swift in Sources */,
768+
AB58F6102CF9EE4A00E642F6 /* ConsentAPI.swift in Sources */,
769+
AB58F6112CF9EE4A00E642F6 /* ConsentAPIClient.swift in Sources */,
759770
5F07A599210F6D1900819FA2 /* Operation.swift in Sources */,
760771
5F1604C721063FA000B0F25B /* Keys+Generation.swift in Sources */,
761772
2331C01A1DD5FC6F0047F1D4 /* Data+Base64URL.swift in Sources */,
@@ -790,6 +801,7 @@
790801
2356C8301D88A10600B6C84A /* GuardianSpec.swift in Sources */,
791802
2374A9F51D67339E00737F2E /* Matchers.swift in Sources */,
792803
23C67AEC1D81D6A400A38A2E /* MockNSURLSession.swift in Sources */,
804+
AB9381852D03359700C47B1E /* ConsentSpec.swift in Sources */,
793805
5F1604FB2107AF2800B0F25B /* OneTimePasswordGeneratorSpec.swift in Sources */,
794806
ED1E39A92B82A9B100E8609A /* MockURLProtocolCondition.swift in Sources */,
795807
5F1604C92106493600B0F25B /* SigningKeyStorageSpec.swift in Sources */,

Guardian/API/APIClient.swift

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@
2323
import Foundation
2424

2525
struct APIClient: API {
26-
26+
private let path = "appliance-mfa"
2727
let baseUrl: URL
2828
let telemetryInfo: Auth0TelemetryInfo?
2929

3030
init(baseUrl: URL, telemetryInfo: Auth0TelemetryInfo? = nil) {
31-
self.baseUrl = baseUrl
31+
self.baseUrl = baseUrl.appendingPathComponentIfNeeded(path)
3232
self.telemetryInfo = telemetryInfo
3333
}
3434

3535
func enroll(withTicket enrollmentTicket: String, identifier: String, name: String, notificationToken: String, verificationKey: VerificationKey) -> Request<Device, Enrollment> {
36-
let url = self.baseUrl.appendingPathComponent("api/enroll")
36+
let url = baseUrl.appendingPathComponent("api/enroll")
3737
do {
3838
let headers = ["Authorization": "Ticket id=\"\(enrollmentTicket)\""]
3939
guard let jwk = verificationKey.jwk else {
@@ -50,7 +50,7 @@ struct APIClient: API {
5050

5151
func resolve(transaction transactionToken: String, withChallengeResponse challengeResponse: String) -> Request<Transaction, NoContent> {
5252
let transaction = Transaction(challengeResponse: challengeResponse)
53-
let url = self.baseUrl.appendingPathComponent("api/resolve-transaction")
53+
let url = baseUrl.appendingPathComponent("api/resolve-transaction")
5454
return Request.new(method: .post, url: url, headers: ["Authorization": "Bearer \(transactionToken)"], body: transaction, telemetryInfo: self.telemetryInfo)
5555
}
5656

@@ -64,7 +64,7 @@ struct APIClient: API {
6464
let claims = BasicClaimSet(
6565
subject: userId,
6666
issuer: enrollmentId,
67-
audience: self.baseUrl.appendingPathComponent(DeviceAPIClient.path).absoluteString,
67+
audience: baseUrl.appendingPathComponent(DeviceAPIClient.path).absoluteString,
6868
expireAt: currentTime.addingTimeInterval(responseExpiration),
6969
issuedAt: currentTime
7070
)
@@ -74,3 +74,14 @@ struct APIClient: API {
7474
}
7575

7676
}
77+
78+
private extension URL {
79+
func appendingPathComponentIfNeeded(_ pathComponent: String) -> URL {
80+
guard
81+
lastPathComponent != pathComponent,
82+
!absoluteString.hasSuffix("guardian.auth0.com"),
83+
absoluteString.range(of: "guardian\\.[^\\.]*\\.auth0\\.com", options: .regularExpression) == nil
84+
else { return self }
85+
return appendingPathComponent(pathComponent)
86+
}
87+
}

Guardian/API/ConsentAPI.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// ConsentAPI.swift
2+
//
3+
// Copyright (c) 2024 Auth0 (http://auth0.com)
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in
13+
// all copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
// THE SOFTWARE.
22+
23+
import Foundation
24+
25+
/**
26+
`ConsentAPI` lets you retrieve consent objects from auth0's rich-consents API for authentication flows that require additional consent e.g. Client Initiated Backchannel Authentication (CIBA)
27+
28+
```
29+
let consent = Guardian
30+
.consent(forDomain: "tenant.region.auth0.com")
31+
```
32+
*/
33+
public protocol ConsentAPI {
34+
/**
35+
```
36+
let notification: Notification = // the notification received
37+
let consentId = notification.transactionLinkingId
38+
39+
Guardian
40+
.consent(forDomain: "tenant.region.auth0.com")
41+
.fetch(consentId: consentId, notificationToken: notification.transactionToken, signingKey: enrollment.signingKey)
42+
.start { result in
43+
switch result {
44+
case .success(let payload):
45+
// present consent object to user to accept/deny
46+
case .failure(let cause):
47+
// failed to retrieve consent
48+
}
49+
}
50+
```
51+
52+
- parameter consentId: the id of the consent object to fetch, this is obtained from the
53+
transaction linking id of the ncoming push notification where relevant
54+
- parameter transactionToken: the access token obtained from the incoming push notification
55+
- parameter signingKey: the private key used to sign Guardian AuthN requests
56+
57+
- returns: a request to execute
58+
*/
59+
func fetch(consentId: String, transactionToken: String, signingKey: SigningKey) -> Request<NoContent, Consent>
60+
}

Guardian/API/ConsentAPIClient.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// ConsentAPIClient.swift
2+
//
3+
// Copyright (c) 2024 Auth0 (http://auth0.com)
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in
13+
// all copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
// THE SOFTWARE.
22+
23+
import Foundation
24+
import CryptoKit
25+
26+
struct ConsentAPIClient : ConsentAPI {
27+
private let path: String = "rich-consents"
28+
29+
let url:URL
30+
let telemetryInfo: Auth0TelemetryInfo?
31+
32+
init(baseConsentUrl: URL, telemetryInfo: Auth0TelemetryInfo? = nil ) {
33+
let url = baseConsentUrl.appendingPathComponent(path, isDirectory: false)
34+
self.url = url
35+
self.telemetryInfo = telemetryInfo
36+
}
37+
38+
func fetch(consentId:String, transactionToken: String, signingKey: SigningKey) -> Request<NoContent, Consent> {
39+
let consentURL = self.url.appendingPathComponent(consentId)
40+
41+
do {
42+
let dpopAssertion = try self.proofOfPossesion(url: consentURL, transactionToken: transactionToken, signingKey: signingKey)
43+
return Request.new(
44+
method: .get,
45+
url: consentURL,
46+
headers: [
47+
"Authorization": "MFA-DPoP \(transactionToken)",
48+
"MFA-DPoP": dpopAssertion
49+
],
50+
telemetryInfo: self.telemetryInfo
51+
)
52+
}
53+
catch let error {
54+
return Request(method: .get, url: consentURL, error: error)
55+
}
56+
}
57+
58+
private func proofOfPossesion (url: URL, transactionToken: String, signingKey: SigningKey) throws -> String {
59+
guard let jwk = try signingKey.verificationKey().jwk else {
60+
throw GuardianError(code: .invalidJWK)
61+
}
62+
63+
let header = JWT<DPoPClaimSet>.Header(algorithm: .rs256, type: "dpop+jwt", jwk: jwk)
64+
let tokenHash = try self.authTokenHash(transactionToken: transactionToken);
65+
66+
let claims = DPoPClaimSet(
67+
httpURI: url.absoluteString,
68+
httpMethod: "GET",
69+
accessTokenHash: tokenHash,
70+
jti: UUID().uuidString,
71+
issuedAt: Date())
72+
73+
let jwt = try JWT<DPoPClaimSet>(claimSet: claims, header: header, key: signingKey.secKey)
74+
return jwt.string
75+
}
76+
77+
private func authTokenHash(transactionToken: String) throws -> String {
78+
guard let sha256 = A0SHA(algorithm: "sha256") else {
79+
throw GuardianError(code: .failedCreationDPoPProof)
80+
}
81+
82+
return sha256.hash(Data(transactionToken.utf8)).base64URLEncodedString()
83+
}
84+
}

Guardian/API/GuardianError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public struct GuardianError: Swift.Error {
4747
case failedStoreAsymmetricKey = "a0.guardian.internal.failed.store_assymmetric_key"
4848
case notFoundPrivateKey = "a0.guardian.internal.no.private_key"
4949
case cannotSignTransactionChallenge = "a0.guardian.internal.cannot.sign_challenge"
50+
case failedCreationDPoPProof = "a0.guardian.internal.failed.creation_dpop_proof"
5051
}
5152

5253
}

Guardian/API/Models.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ struct PushCredentials: Codable {
2727
let token: String
2828
}
2929

30-
public struct RSAPublicJWK: Codable {
30+
public struct RSAPublicJWK: Codable, Equatable {
3131
let keyType = "RSA"
3232
let usage = "sig"
3333
let algorithm = "RS256"
@@ -94,6 +94,36 @@ public struct UpdatedDevice: Codable {
9494
}
9595
}
9696

97+
public struct ConsentRequestedDetailsEntity: Decodable {
98+
public let audience: String
99+
public let scope: [String]
100+
public let bindingMessage: String
101+
102+
enum CodingKeys: String, CodingKey {
103+
case audience
104+
case scope
105+
case bindingMessage = "binding_message"
106+
}
107+
}
108+
109+
public struct Consent: Decodable {
110+
111+
public let consentId: String
112+
113+
public let requestedDetails: ConsentRequestedDetailsEntity
114+
115+
public let createdAt: String
116+
117+
public let expiresAt: String
118+
119+
enum CodingKeys: String, CodingKey {
120+
case consentId = "id"
121+
case requestedDetails = "requested_details"
122+
case createdAt = "created_at"
123+
case expiresAt = "expires_at"
124+
}
125+
}
126+
97127
public struct Auth0TelemetryInfo: Codable, Equatable {
98128
let appName: String
99129
let appVersion: String

Guardian/Crypto/JWT.swift

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,22 @@ struct JWT<S: Codable> {
5151
}
5252
}
5353
}
54-
54+
5555
struct Header: Codable {
5656
let algorithm: Algorithm
57-
let type: String = "JWT"
57+
let type: String
58+
let jwk: RSAPublicJWK? // Optional field
5859

5960
enum CodingKeys: String, CodingKey {
6061
case type = "typ"
6162
case algorithm = "alg"
63+
case jwk
64+
}
65+
66+
init(algorithm: Algorithm, type: String = "JWT", jwk: RSAPublicJWK? = nil) {
67+
self.algorithm = algorithm
68+
self.type = type
69+
self.jwk = jwk
6270
}
6371
}
6472

@@ -80,15 +88,20 @@ struct JWT<S: Codable> {
8088

8189
init(claimSet: S, algorithm: JWT.Algorithm = .rs256, key: SecKey) throws {
8290
let header = Header(algorithm: algorithm)
91+
try self.init(claimSet:claimSet, header: header, key: key)
92+
}
93+
94+
init(claimSet: S, header:Header, key: SecKey) throws {
8395
let encoder = JSONEncoder()
8496
encoder.dateEncodingStrategy = .secondsSince1970
97+
8598
let headerPart = try encoder.encode(header).base64URLEncodedString()
8699
let claimSetPart = try encoder.encode(claimSet).base64URLEncodedString()
87100
let signableParts = "\(headerPart).\(claimSetPart)"
88101
guard let value = signableParts.data(using: .utf8) else {
89102
throw JWT.Error.cannotSign
90103
}
91-
let signature = try algorithm.sign(value: value, key: key)
104+
let signature = try header.algorithm.sign(value: value, key: key)
92105
let signaturePart = signature.base64URLEncodedString()
93106
self.header = header
94107
self.claimSet = claimSet
@@ -166,3 +179,19 @@ struct GuardianClaimSet: Codable, Equatable {
166179
case reason = "auth0_guardian_reason"
167180
}
168181
}
182+
183+
struct DPoPClaimSet: Codable, Equatable {
184+
let httpURI: String
185+
let httpMethod: String
186+
let accessTokenHash: String
187+
let jti: String
188+
let issuedAt: Date
189+
190+
enum CodingKeys: String, CodingKey {
191+
case httpURI = "htu"
192+
case httpMethod = "htm"
193+
case accessTokenHash = "ath"
194+
case jti
195+
case issuedAt = "iat"
196+
}
197+
}

0 commit comments

Comments
 (0)