Skip to content

Commit 281e288

Browse files
authored
Resource Indicators for OAuth 2.0 (RFC 8707) (#26)
2 parents f4309a8 + b80919f commit 281e288

File tree

9 files changed

+117
-86
lines changed

9 files changed

+117
-86
lines changed

Sources/Base/OAuth2AuthRequest.swift

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,8 @@ open class OAuth2AuthRequest {
204204
else {
205205
throw OAuth2Error.utf8EncodeError
206206
}
207-
finalParams.removeValue(forKey: "client_id")
208-
finalParams.removeValue(forKey: "client_secret")
207+
finalParams.removeValues(forKey: "client_id")
208+
finalParams.removeValues(forKey: "client_secret")
209209
}
210210
}
211211

@@ -251,23 +251,39 @@ public struct OAuth2RequestParams {
251251

252252
public subscript(key: String) -> String? {
253253
get {
254-
return params?[key]
254+
return params?[key]?.split(separator: "\n").map(String.init).first
255255
}
256256
set(newValue) {
257257
params = params ?? OAuth2StringDict()
258258
params![key] = newValue
259259
}
260260
}
261+
262+
/**
263+
Returns all the values associated with a given parameter as an array.
264+
265+
- parameter forKey: The name of the parameter to return.
266+
- returns: An array of strings, which may be empty if no values for the given parameter are found.
267+
*/
268+
public func getMultiple(forKey key: String) -> [String] {
269+
return params?[key]?.split(separator: "\n").map(String.init) ?? []
270+
}
271+
272+
public mutating func setMultiple(key: String, values: any Sequence<String>) {
273+
params = params ?? OAuth2StringDict()
274+
params![key] = values.sorted().joined(separator: "\n")
275+
}
261276

262277
/**
263-
Removes the given value from the receiver, if it is defined.
278+
Removes the given values from the receiver, if it is defined.
264279

265-
- parameter forKey: The key for the value to be removed
266-
- returns: The value that was removed, if any
280+
- parameter forKey: The key for the values to be removed
281+
- returns: The values that was removed, if any
267282
*/
268283
@discardableResult
269-
public mutating func removeValue(forKey key: String) -> String? {
270-
return params?.removeValue(forKey: key)
284+
public mutating func removeValues(forKey key: String) -> [String] {
285+
let removedValue = params?.removeValue(forKey: key)
286+
return removedValue?.split(separator: "\n").map(String.init) ?? []
271287
}
272288

273289
/// The number of items in the receiver.
@@ -320,7 +336,9 @@ public struct OAuth2RequestParams {
320336
public static func formEncodedQueryStringFor(_ params: OAuth2StringDict) -> String {
321337
var arr: [String] = []
322338
for (key, val) in params {
323-
arr.append("\(key)=\(val.wwwFormURLEncodedString)")
339+
for subVal in val.split(separator: "\n").map(String.init) {
340+
arr.append("\(key)=\(subVal.wwwFormURLEncodedString)")
341+
}
324342
}
325343
return arr.joined(separator: "&")
326344
}

Sources/Base/OAuth2ClientConfig.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ open class OAuth2ClientConfig {
3838
/// Where a logo/icon for the app can be found.
3939
public final var logoURL: URL?
4040

41-
/// The URL used for constructing target for resource-aware tokens.
42-
public final var resourceURL: URL?
41+
/// The URIs of the target services or resources used for constructing resource-aware tokens.
42+
/// See: https://datatracker.ietf.org/doc/html/rfc8707
43+
public final var resourceURIs: Set<String>?
4344

4445
/// The scope currently in use.
4546
open var scope: String?
@@ -136,8 +137,8 @@ open class OAuth2ClientConfig {
136137
if let logo = settings["logo_uri"] as? String {
137138
logoURL = URL(string: logo)
138139
}
139-
if let resource = settings["resource_uri"] as? String {
140-
resourceURL = URL(string: resource)
140+
if let resources = settings["resource_uris"] as? [String] {
141+
resourceURIs = Set(resources)
141142
}
142143

143144
// client authorization options

Sources/Base/OAuth2Error.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ public enum OAuth2Error: Error, CustomStringConvertible, Equatable {
5252
/// There is no redirect URL.
5353
case noRedirectURL
5454

55-
/// There is no resource URL.
56-
case noResourceURL
55+
/// There is no resource URI.
56+
case noResourceURI
5757

5858
/// There is no username.
5959
case noUsername
@@ -178,6 +178,9 @@ public enum OAuth2Error: Error, CustomStringConvertible, Equatable {
178178
/// The "device_code" has expired. Passes the underlying error_description.
179179
case expiredToken(String?)
180180

181+
/// The requested resource is invalid, missing, unknown, or malformed.
182+
case invalidTarget(String?)
183+
181184
/// Other response error, as defined in its String.
182185
case responseError(String)
183186

@@ -214,6 +217,8 @@ public enum OAuth2Error: Error, CustomStringConvertible, Equatable {
214217
return .slowDown(description)
215218
case "expired_token":
216219
return .expiredToken(description)
220+
case "invalid_target":
221+
return .invalidTarget(description)
217222
default:
218223
return .responseError(description ?? fallback ?? "Authorization error: \(code)")
219224
}
@@ -237,8 +242,8 @@ public enum OAuth2Error: Error, CustomStringConvertible, Equatable {
237242
return "Device code URL not set"
238243
case .noRedirectURL:
239244
return "Redirect URL not set"
240-
case .noResourceURL:
241-
return "Resource URL not set"
245+
case .noResourceURI:
246+
return "Resource URI not set"
242247
case .noUsername:
243248
return "No username"
244249
case .noPassword:
@@ -318,6 +323,8 @@ public enum OAuth2Error: Error, CustomStringConvertible, Equatable {
318323
return message ?? "The authorization request is still pending and polling should continue, but the interval must be increased by 5 seconds for this and all subsequent requests."
319324
case .expiredToken(let message):
320325
return message ?? "The \"device_code\" has expired, and the device authorization session has concluded."
326+
case .invalidTarget(let message):
327+
return message ?? "The requested resource is invalid, missing, unknown, or malformed."
321328
case .responseError(let message):
322329
return message
323330
}
@@ -336,7 +343,7 @@ public enum OAuth2Error: Error, CustomStringConvertible, Equatable {
336343
case (.noClientSecret, .noClientSecret): return true
337344
case (.noDeviceCodeURL, .noDeviceCodeURL): return true
338345
case (.noRedirectURL, .noRedirectURL): return true
339-
case (.noResourceURL, .noResourceURL): return true
346+
case (.noResourceURI, .noResourceURI): return true
340347
case (.noUsername, .noUsername): return true
341348
case (.noPassword, .noPassword): return true
342349
case (.alreadyAuthorizing, .alreadyAuthorizing): return true
@@ -374,6 +381,7 @@ public enum OAuth2Error: Error, CustomStringConvertible, Equatable {
374381
case (.authorizationPending, .authorizationPending): return true
375382
case (.slowDown, .slowDown): return true
376383
case (.expiredToken, .expiredToken): return true
384+
case (.invalidTarget, .invalidTarget): return true
377385
case (.responseError(let lhm), .responseError(let rhm)): return lhm == rhm
378386
default: return false
379387
}

Sources/Base/OAuth2Requestable.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,19 @@ open class OAuth2Requestable {
197197
- returns: A dictionary full of strings with the key-value pairs found in the query
198198
*/
199199
public final class func params(fromQuery query: String) -> OAuth2StringDict {
200-
let parts = query.split() { $0 == "&" }.map() { String($0) }
200+
let parts = query.split(separator: "&")
201201
var params = OAuth2StringDict(minimumCapacity: parts.count)
202+
202203
for part in parts {
203-
let subparts = part.split() { $0 == "=" }.map() { String($0) }
204-
if 2 == subparts.count {
205-
params[subparts[0]] = subparts[1].wwwFormURLDecodedString
204+
let subparts = part.split(separator: "=").map(String.init)
205+
guard subparts.count == 2 else {
206+
continue
206207
}
208+
209+
let (key, value) = (subparts[0], subparts[1].wwwFormURLDecodedString)
210+
params[key] = [params[key], value].compactMap { $0 }.joined(separator: "\n")
207211
}
212+
208213
return params
209214
}
210215
}

Sources/Flows/OAuth2.swift

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,9 @@ open class OAuth2: OAuth2Base {
327327
if let clientId = clientId {
328328
req.params["client_id"] = clientId
329329
}
330+
if let resourceURIs = clientConfig.resourceURIs {
331+
req.params.setMultiple(key: "resource", values: resourceURIs)
332+
}
330333
req.add(params: params)
331334

332335
return req
@@ -450,21 +453,20 @@ open class OAuth2: OAuth2Base {
450453

451454
This will set "grant_type" to "urn:ietf:params:oauth:grant-type:token-exchange", add the access token, and take care of the remaining parameters.
452455

453-
- parameter resourcePath: The path of the resource requesting for its own access token
454456
- parameter params: Additional parameters to pass during resource access token exchange
455457
- returns: An `OAuth2AuthRequest` instance that is configured for resource access token exchange
456458
*/
457-
open func tokenRequestForExchangeAccessTokenForResource(resourcePath: String, params: OAuth2StringDict? = nil) throws -> OAuth2AuthRequest {
459+
open func tokenRequestForExchangeAccessTokenForResource(params: OAuth2StringDict? = nil) throws -> OAuth2AuthRequest {
458460
guard let accessToken = clientConfig.accessToken, !accessToken.isEmpty else {
459461
throw OAuth2Error.noAccessToken
460462
}
461-
guard let resourceUrl = clientConfig.resourceURL else {
462-
throw OAuth2Error.noResourceURL
463+
guard let resourceURIs = clientConfig.resourceURIs, !resourceURIs.isEmpty else {
464+
throw OAuth2Error.noResourceURI
463465
}
464466

465467
let req = OAuth2AuthRequest(url: (clientConfig.tokenURL ?? clientConfig.authorizeURL))
466468
req.params["grant_type"] = OAuth2GrantTypes.tokenExchange
467-
req.params["resource"] = resourceUrl.appendingPathComponent(resourcePath).absoluteString
469+
req.params.setMultiple(key: "resource", values: resourceURIs)
468470
req.params["scope"] = clientConfig.scope
469471
req.params["requested_token_type"] = OAuth2TokenTypeIdentifiers.accessToken
470472
req.params["subject_token"] = accessToken
@@ -474,17 +476,21 @@ open class OAuth2: OAuth2Base {
474476
return req
475477
}
476478

479+
// TODO:
477480
/**
478481
Exchanges the access token for resource access token.
479482

480-
- parameter resourcePath: The path of the resource requesting for its own access token
481483
- parameter params: Optional key/value pairs to pass during token exchange
482484
- returns: Exchanged access token
483485
*/
484-
open func doExchangeAccessTokenForResource(resourcePath: String, params: OAuth2StringDict? = nil) async throws -> String {
486+
open func doExchangeAccessTokenForResource(params: OAuth2StringDict? = nil) async throws -> String {
485487
do {
486-
let post = try tokenRequestForExchangeAccessTokenForResource(resourcePath: resourcePath, params: params).asURLRequest(for: self)
487-
logger?.debug("OAuth2", msg: "Exchanging access token for resource \(resourcePath) from \(post.url?.description ?? "nil")")
488+
guard let resourceURIs = clientConfig.resourceURIs, !resourceURIs.isEmpty else {
489+
throw OAuth2Error.noResourceURI
490+
}
491+
492+
let post = try tokenRequestForExchangeAccessTokenForResource(params: params).asURLRequest(for: self)
493+
logger?.debug("OAuth2", msg: "Exchanging access token for resource(s) \(resourceURIs) from \(post.url?.description ?? "nil")")
488494

489495
let response = await perform(request: post)
490496
let data = try response.responseData()
@@ -498,7 +504,7 @@ open class OAuth2: OAuth2Base {
498504
}
499505
return exchangedAccessToken
500506
} catch let error {
501-
self.logger?.debug("OAuth2", msg: "Error exchanging access token for resource \(resourcePath): \(error)")
507+
self.logger?.debug("OAuth2", msg: "Error exchanging access token for resource(s): \(error)")
502508
throw error.asOAuth2Error
503509
}
504510
}

Sources/Flows/OAuth2CodeGrant.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ open class OAuth2CodeGrant: OAuth2 {
7272
if clientConfig.useProofKeyForCodeExchange {
7373
req.params["code_verifier"] = context.codeVerifier
7474
}
75+
if let resourceURIs = clientConfig.resourceURIs {
76+
req.params.setMultiple(key: "resource", values: resourceURIs)
77+
}
7578
return req
7679
}
7780

Tests/BaseTests/OAuth2AuthRequestTests.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,26 @@ class OAuth2AuthRequestTests: XCTestCase {
7777
XCTAssertEqual("AA", req.params["a"])
7878

7979
req.params["c"] = "A complicated/surprising name & character=fun"
80-
req.params.removeValue(forKey: "b")
80+
req.params.removeValues(forKey: "b")
8181
XCTAssertTrue(2 == req.params.count)
8282
let str = req.params.percentEncodedQueryString()
8383

8484
let parts = Set(str.split(separator: "&"))
8585
XCTAssertEqual(parts, Set(["a=AA", "c=A+complicated%2Fsurprising+name+%26+character%3Dfun"]))
8686
}
8787

88+
func testMultipleParamsWithSameKey() {
89+
let req = OAuth2AuthRequest(url: URL(string: "http://localhost")!)
90+
req.params.setMultiple(key: "multiple", values: ["a", "b", "c"])
91+
XCTAssertTrue(3 == req.params.getMultiple(forKey: "multiple").count)
92+
XCTAssertEqual(["a", "b", "c"], req.params.getMultiple(forKey: "multiple"))
93+
94+
let removedValues = req.params.removeValues(forKey: "multiple")
95+
XCTAssertTrue(3 == removedValues.count)
96+
XCTAssertEqual(["a", "b", "c"], removedValues)
97+
XCTAssertTrue(0 == req.params.getMultiple(forKey: "multiple").count)
98+
}
99+
88100
func testURLComponents() {
89101
let reqNoTLS = OAuth2AuthRequest(url: URL(string: "http://not.tls.com")!)
90102
do {

Tests/BaseTests/OAuth2Tests.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,23 @@ class OAuth2Tests: XCTestCase {
166166
XCTAssertEqual(params3["access_token"]!, "xxx==")
167167
XCTAssertEqual(params3["expires"]!, "2015-00-00")
168168
XCTAssertEqual(params3["more"]!, "spacey stuff with a +")
169+
170+
let params4 = OAuth2.params(fromQuery: "access_token=xxx&expires=2015-00-00&more=stuff1&more=stuff2")
171+
XCTAssert(3 == params4.count, "Expecting 3 URL params") // Query parameters with the same key are treated as a single multi-value parameter
172+
173+
XCTAssertEqual(params4["access_token"]!, "xxx")
174+
XCTAssertEqual(params4["expires"]!, "2015-00-00")
175+
XCTAssertEqual(params4["more"]!, "stuff1\nstuff2")
169176
}
170177

171178
func testQueryParamConversion() {
172-
let qry = OAuth2RequestParams.formEncodedQueryStringFor(["a": "AA", "b": "BB", "x": "yz"])
173-
XCTAssertEqual(14, qry.count, "Expecting a 14 character string")
179+
let qry = OAuth2RequestParams.formEncodedQueryStringFor(["a": "AA", "b": "BB", "x": "y\nz"])
180+
XCTAssertEqual(17, qry.count, "Expecting a 17 character string")
174181

175182
let dict = OAuth2.params(fromQuery: qry)
176183
XCTAssertEqual(dict["a"]!, "AA", "Must unpack `a`")
177184
XCTAssertEqual(dict["b"]!, "BB", "Must unpack `b`")
178-
XCTAssertEqual(dict["x"]!, "yz", "Must unpack `x`")
185+
XCTAssertEqual(dict["x"]!, "y\nz", "Must unpack `x`")
179186
}
180187

181188
func testQueryParamEncoding() {

0 commit comments

Comments
 (0)