Skip to content

Commit 923c868

Browse files
committed
Create URL Protection Space from HTTPURLResponse
Basic implementation of realm parsing. Also fixes access to "Www-Authenticate" header in some places, as HTTPURLResponse does capitalize headers now.
1 parent e1ceed8 commit 923c868

File tree

4 files changed

+202
-17
lines changed

4 files changed

+202
-17
lines changed

Sources/FoundationNetworking/URLProtectionSpace.swift

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -332,19 +332,93 @@ open class URLProtectionSpace : NSObject, NSCopying {
332332

333333
extension URLProtectionSpace {
334334
//an internal helper to create a URLProtectionSpace from a HTTPURLResponse
335-
static func create(using response: HTTPURLResponse) -> URLProtectionSpace {
335+
static func create(with response: HTTPURLResponse) -> URLProtectionSpace? {
336336
let host = response.url?.host ?? ""
337-
let port = response.url?.port ?? 80 //HTTP
338-
let _protocol = response.url?.scheme
339-
guard let wwwAuthHeader = response.allHeaderFields["Www-Authenticate"] as? String else {
340-
fatalError("Authentication failed but no Www-Authenticate header in response")
337+
338+
let scheme = response.url?.scheme
339+
340+
let port: Int = {
341+
if let port = response.url?.port {
342+
return port
343+
}
344+
345+
switch scheme {
346+
case "http":
347+
return 80
348+
case "https":
349+
return 443
350+
default:
351+
return 80
352+
}
353+
}()
354+
355+
// Assuming HTTPURLResponse does capitalization of standard headers
356+
guard let authenticateValue = response.allHeaderFields["Www-Authenticate"] as? String else {
357+
return nil
341358
}
342359

343-
//The format of the authentication header is `<auth-scheme> realm="<realm value>"`
344-
//Example: `Basic realm="Fake Realm"`
345-
let authMethod = wwwAuthHeader.components(separatedBy: " ")[0]
346-
let realm = String(String(wwwAuthHeader.components(separatedBy: "realm=")[1].dropFirst()).dropLast())
347-
return URLProtectionSpace(host: host, port: port, protocol: _protocol, realm: realm, authenticationMethod: authMethod)
360+
// Typical Www-Authenticate header is something like
361+
// Www-Authenticate: Digest realm="test", domain="/HTTP/Digest", nonce="e3d002b9b2080453fdacea2d89f2d102"
362+
363+
let components = authenticateValue.components(separatedBy: " ")
364+
let authMethod: String? = {
365+
guard let authMethod = components.first else {
366+
return nil
367+
}
368+
369+
switch authMethod.lowercased() {
370+
case "basic":
371+
return NSURLAuthenticationMethodHTTPBasic
372+
case "digest":
373+
return NSURLAuthenticationMethodHTTPDigest
374+
case "ntlm":
375+
return NSURLAuthenticationMethodNTLM
376+
case "negotiate":
377+
return NSURLAuthenticationMethodNegotiate
378+
default:
379+
return nil
380+
}
381+
}()
382+
383+
let realm: String? = {
384+
guard let openingQuoteIndex = authenticateValue.range(of: "realm=\"", options: .caseInsensitive)?.upperBound else {
385+
return nil
386+
}
387+
388+
// Basic implementation.
389+
// Doesn't follow complete spec of quoted strings (RFC-7230, p. 3.2.6).
390+
// Handles escaping of quotes only.
391+
392+
let realmStart = openingQuoteIndex
393+
394+
guard let closingQuoteIndex = { () -> String.Index? in
395+
var searchFrom = realmStart
396+
while(true) {
397+
// Look for quote
398+
guard let quoteRange = authenticateValue.range(of: "\"", range: searchFrom..<authenticateValue.endIndex) else {
399+
return nil
400+
}
401+
// Check is it escaped
402+
guard authenticateValue[authenticateValue.index(before: quoteRange.lowerBound)] != "\\" else {
403+
searchFrom = quoteRange.upperBound
404+
continue
405+
}
406+
return quoteRange.lowerBound
407+
}
408+
}() else {
409+
return nil
410+
}
411+
412+
let quotedRealm = authenticateValue[realmStart..<closingQuoteIndex]
413+
let realm = quotedRealm.replacingOccurrences(of: "\\\"", with: "\"")
414+
return realm
415+
}()
416+
417+
return URLProtectionSpace(host: host,
418+
port: port,
419+
protocol: scheme,
420+
realm: realm,
421+
authenticationMethod: authMethod)
348422
}
349423
}
350424

Sources/FoundationNetworking/URLSession/HTTP/HTTPURLProtocol.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ internal class _HTTPURLProtocol: _NativeProtocol {
134134
}
135135

136136
// We opt not to cache any requests or responses that contain authorization headers.
137-
if httpResponse.allHeaderFields["WWW-Authenticate"] != nil ||
137+
if httpResponse.allHeaderFields["Www-Authenticate"] != nil ||
138138
httpResponse.allHeaderFields["Proxy-Authenticate"] != nil ||
139139
httpRequest.allHTTPHeaderFields?["Authorization"] != nil ||
140140
httpRequest.allHTTPHeaderFields?["Proxy-Authorization"] != nil {

Sources/FoundationNetworking/URLSession/URLSessionTask.swift

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -775,11 +775,8 @@ extension _ProtocolClient : URLProtocolClient {
775775
let host = response.url?.host ?? ""
776776
let port = response.url?.port ?? 80 //we're doing http
777777
let _protocol = response.url?.scheme
778-
if response.allHeaderFields["WWW-Authenticate"] != nil {
779-
let wwwAuthHeaderValue = response.allHeaderFields["WWW-Authenticate"] as! String
780-
let authMethod = wwwAuthHeaderValue.components(separatedBy: " ")[0]
781-
let realm = String(String(wwwAuthHeaderValue.components(separatedBy: "realm=")[1].dropFirst()).dropLast())
782-
return URLProtectionSpace(host: host, port: port, protocol: _protocol, realm: realm, authenticationMethod: authMethod)
778+
if response.allHeaderFields["Www-Authenticate"] != nil {
779+
return URLProtectionSpace.create(with: response)
783780
} else {
784781
return nil
785782
}

Tests/Foundation/Tests/TestURLProtectionSpace.swift

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,30 @@
77
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
88
//
99

10+
#if NS_FOUNDATION_ALLOWS_TESTABLE_IMPORT
11+
#if canImport(SwiftFoundationNetworking) && !DEPLOYMENT_RUNTIME_OBJC
12+
@testable import SwiftFoundationNetworking
13+
#else
14+
#if canImport(FoundationNetworking)
15+
@testable import FoundationNetworking
16+
#endif
17+
#endif
18+
#endif
19+
1020
class TestURLProtectionSpace : XCTestCase {
1121

1222
static var allTests: [(String, (TestURLProtectionSpace) -> () throws -> Void)] {
13-
return [
23+
var tests: [(String, (TestURLProtectionSpace) -> () throws -> ())] = [
1424
("test_description", test_description),
1525
]
26+
27+
#if NS_FOUNDATION_ALLOWS_TESTABLE_IMPORT
28+
tests.append(contentsOf: [
29+
("test_createWithHTTPURLresponse", test_createWithHTTPURLresponse),
30+
])
31+
#endif
32+
33+
return tests
1634
}
1735

1836
func test_description() {
@@ -36,4 +54,100 @@ class TestURLProtectionSpace : XCTestCase {
3654
XCTAssert(space.description.hasPrefix("<\(type(of: space))"))
3755
XCTAssert(space.description.hasSuffix(": Host:apple.com, Server:http, Auth-Scheme:NSURLAuthenticationMethodHTMLForm, Realm:(null), Port:80, Proxy:NO, Proxy-Type:(null)"))
3856
}
57+
58+
#if NS_FOUNDATION_ALLOWS_TESTABLE_IMPORT
59+
func test_createWithHTTPURLresponse() throws {
60+
// Real responce from outlook.office365.com
61+
let headerFields1 = [
62+
"Server": "Microsoft-IIS/10.0",
63+
"request-id": "c71c2202-4013-4d64-9319-d40aba6bbe5c",
64+
"WWW-Authenticate": "Basic Realm=\"\"",
65+
"X-Powered-By": "ASP.NET",
66+
"X-FEServer": "AM6PR0502CA0062",
67+
"Date": "Sat, 04 Apr 2020 16:19:39 GMT",
68+
"Content-Length": "0",
69+
]
70+
let response1 = try XCTUnwrap(HTTPURLResponse(url: URL(string: "https://outlook.office365.com/Microsoft-Server-ActiveSync")!,
71+
statusCode: 401,
72+
httpVersion: "HTTP/1.1",
73+
headerFields: headerFields1))
74+
let space1 = try XCTUnwrap(URLProtectionSpace.create(with: response1), "Failed to create protection space from valid response")
75+
76+
XCTAssertEqual(space1.authenticationMethod, NSURLAuthenticationMethodHTTPBasic)
77+
XCTAssertEqual(space1.protocol, "https")
78+
XCTAssertEqual(space1.host, "outlook.office365.com")
79+
XCTAssertEqual(space1.port, 443)
80+
XCTAssertEqual(space1.realm, "")
81+
82+
// Real response from jigsaw.w3.org
83+
let headerFields2 = [
84+
"date": "Sat, 04 Apr 2020 17:24:23 GMT",
85+
"content-length": "261",
86+
"content-type": "text/html;charset=ISO-8859-1",
87+
"server": "Jigsaw/2.3.0-beta3",
88+
"www-authenticate": "Basic realm=\"test\"",
89+
"strict-transport-security": "max-age=15552015; includeSubDomains; preload",
90+
"public-key-pins": "pin-sha256=\"cN0QSpPIkuwpT6iP2YjEo1bEwGpH/yiUn6yhdy+HNto=\"; pin-sha256=\"WGJkyYjx1QMdMe0UqlyOKXtydPDVrk7sl2fV+nNm1r4=\"; pin-sha256=\"LrKdTxZLRTvyHM4/atX2nquX9BeHRZMCxg3cf4rhc2I=\"; max-age=864000",
91+
"x-frame-options": "deny",
92+
"x-xss-protection": "1; mode=block",
93+
]
94+
let response2 = try XCTUnwrap(HTTPURLResponse(url: URL(string: "https://jigsaw.w3.org/HTTP/Basic/")!,
95+
statusCode: 401,
96+
httpVersion: "HTTP/2",
97+
headerFields: headerFields2))
98+
let space2 = try XCTUnwrap(URLProtectionSpace.create(with: response2), "Failed to create protection space from valid response")
99+
100+
XCTAssertEqual(space2.authenticationMethod, NSURLAuthenticationMethodHTTPBasic)
101+
XCTAssertEqual(space2.protocol, "https")
102+
XCTAssertEqual(space2.host, "jigsaw.w3.org")
103+
XCTAssertEqual(space2.port, 443)
104+
XCTAssertEqual(space2.realm, "test")
105+
106+
// More cases with partial response
107+
let authenticate3 = "Digest realm=\"Test \\\"quoted\\\"\", domain=\"/HTTP/Digest\", nonce=\"be2e96ad8ab8acb7ccfb49bc7e162914\""
108+
let response3 = try XCTUnwrap(HTTPURLResponse(url: URL(string: "http://jigsaw.w3.org/HTTP/Basic/")!,
109+
statusCode: 401,
110+
httpVersion: "HTTP/1.1",
111+
headerFields: ["www-authenticate" : authenticate3]))
112+
let space3 = try XCTUnwrap(URLProtectionSpace.create(with: response3), "Failed to create protection space from valid response")
113+
114+
XCTAssertEqual(space3.authenticationMethod, NSURLAuthenticationMethodHTTPDigest)
115+
XCTAssertEqual(space3.protocol, "http")
116+
XCTAssertEqual(space3.host, "jigsaw.w3.org")
117+
XCTAssertEqual(space3.port, 80)
118+
XCTAssertEqual(space3.realm, "Test \"quoted\"")
119+
120+
let response4 = try XCTUnwrap(HTTPURLResponse(url: URL(string: "http://apple.com:333")!,
121+
statusCode: 401,
122+
httpVersion: "HTTP/1.1",
123+
headerFields: ["www-authenTicate" : "NTLM realm=\"\\\"\""]))
124+
let space4 = try XCTUnwrap(URLProtectionSpace.create(with: response4), "Failed to create protection space from valid response")
125+
126+
XCTAssertEqual(space4.authenticationMethod, NSURLAuthenticationMethodNTLM)
127+
XCTAssertEqual(space4.protocol, "http")
128+
XCTAssertEqual(space4.host, "apple.com")
129+
XCTAssertEqual(space4.port, 333)
130+
XCTAssertEqual(space4.realm, "\"")
131+
132+
// Some broken headers
133+
let response5 = try XCTUnwrap(HTTPURLResponse(url: URL(string: "http://apple.com")!,
134+
statusCode: 401,
135+
httpVersion: "HTTP/1.1",
136+
headerFields: ["www-authenicate" : "Basic"]))
137+
let space5 = URLProtectionSpace.create(with: response5)
138+
XCTAssertNil(space5, "Should not create protection space for response without valid header")
139+
140+
let response6 = try XCTUnwrap(HTTPURLResponse(url: URL(string: "http://apple.com")!,
141+
statusCode: 401,
142+
httpVersion: "HTTP/1.1",
143+
headerFields: ["www-authenticate" : "NT LM realm="]))
144+
let space6 = try XCTUnwrap(URLProtectionSpace.create(with: response6), "Failed to create protection space from valid response")
145+
146+
XCTAssertEqual(space6.authenticationMethod, NSURLAuthenticationMethodDefault)
147+
XCTAssertEqual(space6.protocol, "http")
148+
XCTAssertEqual(space6.host, "apple.com")
149+
XCTAssertEqual(space6.port, 80)
150+
XCTAssertNil(space6.realm)
151+
}
152+
#endif
39153
}

0 commit comments

Comments
 (0)