Skip to content

Commit 6897b19

Browse files
authored
Merge pull request #2761 from readdle/protection-space-realm
Create URL Protection Space from HTTPURLResponse
2 parents 05171ee + ca2fe94 commit 6897b19

File tree

5 files changed

+537
-30
lines changed

5 files changed

+537
-30
lines changed

Sources/FoundationNetworking/URLProtectionSpace.swift

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -332,19 +332,32 @@ 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 {
336-
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")
335+
static func create(with response: HTTPURLResponse) -> URLProtectionSpace? {
336+
// Using first challenge, as we don't support multiple challenges yet
337+
guard let challenge = _HTTPURLProtocol._HTTPMessage._Challenge.challenges(from: response).first else {
338+
return nil
341339
}
340+
guard let url = response.url, let host = url.host, let proto = url.scheme, proto == "http" || proto == "https" else {
341+
return nil
342+
}
343+
let port = url.port ?? (proto == "http" ? 80 : 443)
344+
return URLProtectionSpace(host: host,
345+
port: port,
346+
protocol: proto,
347+
realm: challenge.parameter(withName: "realm")?.value,
348+
authenticationMethod: challenge.authenticationMethod)
349+
}
350+
}
342351

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)
352+
extension _HTTPURLProtocol._HTTPMessage._Challenge {
353+
var authenticationMethod: String? {
354+
if authScheme.caseInsensitiveCompare(_HTTPURLProtocol._HTTPMessage._Challenge.AuthSchemeBasic) == .orderedSame {
355+
return NSURLAuthenticationMethodHTTPBasic
356+
} else if authScheme.caseInsensitiveCompare(_HTTPURLProtocol._HTTPMessage._Challenge.AuthSchemeDigest) == .orderedSame {
357+
return NSURLAuthenticationMethodHTTPDigest
358+
} else {
359+
return nil
360+
}
348361
}
349362
}
350363

Sources/FoundationNetworking/URLSession/HTTP/HTTPMessage.swift

Lines changed: 321 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,209 @@ extension _HTTPURLProtocol._HTTPMessage {
105105
struct _Version: RawRepresentable {
106106
let rawValue: String
107107
}
108+
/// An authentication challenge parsed from `WWW-Authenticate` header field.
109+
///
110+
/// Only parts necessary for Basic auth scheme are implemented at the moment.
111+
/// - SeeAlso: https://tools.ietf.org/html/rfc7235#section-4.1
112+
struct _Challenge {
113+
static let AuthSchemeBasic = "basic"
114+
static let AuthSchemeDigest = "digest"
115+
/// A single auth challenge parameter
116+
struct _AuthParameter {
117+
let name: String
118+
let value: String
119+
}
120+
let authScheme: String
121+
let authParameters: [_AuthParameter]
122+
}
108123
}
109124
extension _HTTPURLProtocol._HTTPMessage._Version {
110125
init?(versionString: String) {
111126
rawValue = versionString
112127
}
113128
}
114-
129+
extension _HTTPURLProtocol._HTTPMessage._Challenge {
130+
/// Case-insensitively searches for auth parameter with specified name
131+
func parameter(withName name: String) -> _AuthParameter? {
132+
return authParameters.first { $0.name.caseInsensitiveCompare(name) == .orderedSame }
133+
}
134+
}
135+
extension _HTTPURLProtocol._HTTPMessage._Challenge {
136+
/// Creates authentication challenges from provided `HTTPURLResponse`.
137+
///
138+
/// The value of `WWW-Authenticate` field is used for parsing authentication challenges
139+
/// of supported type.
140+
///
141+
/// - note: `Basic` is the only supported scheme at the moment.
142+
/// - parameter response: A response to get header value from.
143+
/// - returns: An array of supported challenges found in response.
144+
/// # Reference
145+
/// - [RFC 7235 - Hypertext Transfer Protocol (HTTP/1.1): Authentication](https://tools.ietf.org/html/rfc7235)
146+
/// - [RFC 7617 - The 'Basic' HTTP Authentication Scheme](https://tools.ietf.org/html/rfc7617)
147+
static func challenges(from response: HTTPURLResponse) -> [_HTTPURLProtocol._HTTPMessage._Challenge] {
148+
guard let authenticateValue = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
149+
return []
150+
}
151+
return challenges(from: authenticateValue)
152+
}
153+
/// Creates authentication challenges from provided field value.
154+
///
155+
/// Field value is expected to conform [RFC 7235 Section 4.1](https://tools.ietf.org/html/rfc7235#section-4.1)
156+
/// as much as needed to define supported authorization schemes.
157+
///
158+
/// - note: `Basic` is the only supported scheme at the moment.
159+
/// - parameter authenticateFieldValue: A value of `WWW-Authenticate` field
160+
/// - returns: array of supported challenges found.
161+
/// # Reference
162+
/// - [RFC 7235 - Hypertext Transfer Protocol (HTTP/1.1): Authentication](https://tools.ietf.org/html/rfc7235)
163+
/// - [RFC 7617 - The 'Basic' HTTP Authentication Scheme](https://tools.ietf.org/html/rfc7617)
164+
static func challenges(from authenticateFieldValue: String) -> [_HTTPURLProtocol._HTTPMessage._Challenge] {
165+
var challenges = [_HTTPURLProtocol._HTTPMessage._Challenge]()
166+
167+
// Typical WWW-Authenticate header is something like
168+
// WWWW-Authenticate: Digest realm="test", domain="/HTTP/Digest", nonce="e3d002b9b2080453fdacea2d89f2d102"
169+
//
170+
// https://tools.ietf.org/html/rfc7235#section-4.1
171+
// WWW-Authenticate = 1#challenge
172+
//
173+
// https://tools.ietf.org/html/rfc7235#section-2.1
174+
// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
175+
// auth-scheme = token
176+
// auth-param = token BWS "=" BWS ( token / quoted-string )
177+
// token68 = 1*( ALPHA / DIGIT /
178+
// "-" / "." / "_" / "~" / "+" / "/" ) *"="
179+
//
180+
// https://tools.ietf.org/html/rfc7230#section-3.2.3
181+
// OWS = *( SP / HTAB ) ; optional whitespace
182+
// BWS = OWS ; "bad" whitespace
183+
//
184+
// https://tools.ietf.org/html/rfc7230#section-3.2.6
185+
// token = 1*tchar
186+
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
187+
// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
188+
// / DIGIT / ALPHA
189+
// ; any VCHAR, except delimiters
190+
// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
191+
// qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text
192+
// obs-text = %x80-FF
193+
// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
194+
//
195+
// https://tools.ietf.org/html/rfc5234#appendix-B.1
196+
// ALPHA = %x41-5A / %x61-7A ; A-Z / a-z
197+
// SP = %x20
198+
// HTAB = %x09 ; horizontal tab
199+
// VCHAR = %x21-7E ; visible (printing) characters
200+
// DQUOTE = %x22
201+
// DIGIT = %x30-39 ; 0-9
202+
203+
var authenticateView = authenticateFieldValue.unicodeScalars[...]
204+
// Do an "eager" search of supported auth schemes. Same as it implemented in CURL.
205+
// This means we will look after every comma on every step, no matter what was
206+
// (or wasn't) parsed on previous step.
207+
//
208+
// WWW-Authenticate field could contain some sort of ambiguity, because, in general,
209+
// it is a comma-separated list of comma-separated lists. As mentioned
210+
// in https://tools.ietf.org/html/rfc7235#section-4.1, user agents are advised to
211+
// take special care of parsing all challenges completely.
212+
while !authenticateView.isEmpty {
213+
guard let authSchemeRange = authenticateView.rangeOfTokenPrefix else {
214+
break
215+
}
216+
let authScheme = String(authenticateView[authSchemeRange])
217+
if authScheme.caseInsensitiveCompare(AuthSchemeBasic) == .orderedSame {
218+
let authDataView = authenticateView[authSchemeRange.upperBound...]
219+
let authParameters = _HTTPURLProtocol._HTTPMessage._Challenge._AuthParameter.parameters(from: authDataView)
220+
let challenge = _HTTPURLProtocol._HTTPMessage._Challenge(authScheme: authScheme, authParameters: authParameters)
221+
// "realm" is the only mandatory parameter for Basic auth scheme. Otherwise consider parsed data invalid.
222+
if challenge.parameter(withName: "realm") != nil {
223+
challenges.append(challenge)
224+
}
225+
}
226+
// read up to the next comma
227+
guard let commaIndex = authenticateView.firstIndex(of: _Delimiters.Comma) else {
228+
break
229+
}
230+
// skip comma
231+
authenticateView = authenticateView[authenticateView.index(after: commaIndex)...]
232+
// consume spaces
233+
authenticateView = authenticateView.trimSPPrefix
234+
}
235+
return challenges
236+
}
237+
}
238+
private extension _HTTPURLProtocol._HTTPMessage._Challenge._AuthParameter {
239+
/// Reads authorization challenge parameters from provided Unicode Scalar view
240+
static func parameters(from parametersView: String.UnicodeScalarView.SubSequence) -> [_HTTPURLProtocol._HTTPMessage._Challenge._AuthParameter] {
241+
var parametersView = parametersView
242+
var parameters = [_HTTPURLProtocol._HTTPMessage._Challenge._AuthParameter]()
243+
while true {
244+
parametersView = parametersView.trimSPPrefix
245+
guard let parameter = parameter(from: &parametersView) else {
246+
break
247+
}
248+
parameters.append(parameter)
249+
// trim spaces and expect comma
250+
parametersView = parametersView.trimSPPrefix
251+
guard parametersView.first == _Delimiters.Comma else {
252+
break
253+
}
254+
// drop comma
255+
parametersView = parametersView.dropFirst()
256+
}
257+
return parameters
258+
}
259+
/// Reads a single challenge parameter from provided Unicode Scalar view
260+
private static func parameter(from parametersView: inout String.UnicodeScalarView.SubSequence) -> _HTTPURLProtocol._HTTPMessage._Challenge._AuthParameter? {
261+
// Read parameter name. Return nil if name is not readable.
262+
guard let parameterName = parameterName(from: &parametersView) else {
263+
return nil
264+
}
265+
// Trim BWS, expect '='
266+
parametersView = parametersView.trimSPHTPrefix ?? parametersView
267+
guard parametersView.first == _Delimiters.Equals else {
268+
return nil
269+
}
270+
// Drop '='
271+
parametersView = parametersView.dropFirst()
272+
// Read parameter value. Return nil if parameter is not readable.
273+
guard let parameterValue = parameterValue(from: &parametersView) else {
274+
return nil
275+
}
276+
return _HTTPURLProtocol._HTTPMessage._Challenge._AuthParameter(name: parameterName, value: parameterValue)
277+
}
278+
/// Reads a challenge parameter name from provided Unicode Scalar view
279+
private static func parameterName(from nameView: inout String.UnicodeScalarView.SubSequence) -> String? {
280+
guard let nameRange = nameView.rangeOfTokenPrefix else {
281+
return nil
282+
}
283+
284+
let name = String(nameView[nameRange])
285+
nameView = nameView[nameRange.upperBound...]
286+
return name
287+
}
288+
/// Reads a challenge parameter value from provided Unicode Scalar view
289+
private static func parameterValue(from valueView: inout String.UnicodeScalarView.SubSequence) -> String? {
290+
// Trim BWS
291+
valueView = valueView.trimSPHTPrefix ?? valueView
292+
if valueView.first == _Delimiters.DoubleQuote {
293+
// quoted-string
294+
if let valueRange = valueView.rangeOfQuotedStringPrefix {
295+
let value = valueView[valueRange].dequotedString()
296+
valueView = valueView[valueRange.upperBound...]
297+
return value
298+
}
299+
}
300+
else {
301+
// token
302+
if let valueRange = valueView.rangeOfTokenPrefix {
303+
let value = String(valueView[valueRange])
304+
valueView = valueView[valueRange.upperBound...]
305+
return value
306+
}
307+
}
308+
return nil
309+
}
310+
}
115311
private extension _HTTPURLProtocol._HTTPMessage._StartLine {
116312
init?(line: String) {
117313
guard let r = line.splitRequestLine() else { return nil }
@@ -236,6 +432,7 @@ private extension Collection {
236432
private extension String.UnicodeScalarView.SubSequence {
237433
/// The range of *Token* characters as specified by RFC 2616.
238434
var rangeOfTokenPrefix: Range<Index>? {
435+
guard !isEmpty else { return nil }
239436
var end = startIndex
240437
while self[end].isValidMessageToken {
241438
end = self.index(after: end)
@@ -269,6 +466,101 @@ private extension String.UnicodeScalarView.SubSequence {
269466
}
270467
return nil
271468
}
469+
var trimSPPrefix: SubSequence {
470+
var idx = startIndex
471+
while idx < endIndex {
472+
if self[idx] == _Delimiters.Space {
473+
idx = self.index(after: idx)
474+
} else {
475+
return self[idx..<endIndex]
476+
}
477+
}
478+
return self
479+
}
480+
/// Returns range of **quoted-string** starting from first index of sequence.
481+
///
482+
/// - returns: range of **quoted-string** or `nil` if value can not be parsed.
483+
var rangeOfQuotedStringPrefix: Range<Index>? {
484+
// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
485+
// qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text
486+
// obs-text = %x80-FF
487+
// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
488+
guard !isEmpty else {
489+
return nil
490+
}
491+
var idx = startIndex
492+
// Expect and consume dquote
493+
guard self[idx] == _Delimiters.DoubleQuote else {
494+
return nil
495+
}
496+
idx = self.index(after: idx)
497+
var isQuotedPair = false
498+
while idx < endIndex {
499+
let currentScalar = self[idx]
500+
if currentScalar == _Delimiters.Backslash && !isQuotedPair {
501+
isQuotedPair = true
502+
} else if isQuotedPair {
503+
guard currentScalar.isQuotedPairEscapee else {
504+
return nil
505+
}
506+
isQuotedPair = false
507+
} else if currentScalar == _Delimiters.DoubleQuote {
508+
break
509+
} else {
510+
guard currentScalar.isQdtext else {
511+
return nil
512+
}
513+
}
514+
idx = self.index(after: idx)
515+
}
516+
// Expect stop on dquote
517+
guard idx < endIndex, self[idx] == _Delimiters.DoubleQuote else {
518+
return nil
519+
}
520+
return startIndex..<self.index(after: idx)
521+
}
522+
/// Returns dequoted string if receiver contains **quoted-string**
523+
///
524+
/// - returns: dequoted string or `nil` if receiver does not contain valid quoted string
525+
func dequotedString() -> String? {
526+
guard !isEmpty else {
527+
return nil
528+
}
529+
var resultView = String.UnicodeScalarView()
530+
resultView.reserveCapacity(self.count)
531+
var idx = startIndex
532+
// Expect and consume dquote
533+
guard self[idx] == _Delimiters.DoubleQuote else {
534+
return nil
535+
}
536+
idx = self.index(after: idx)
537+
var isQuotedPair = false
538+
while idx < endIndex {
539+
let currentScalar = self[idx]
540+
if currentScalar == _Delimiters.Backslash && !isQuotedPair {
541+
isQuotedPair = true
542+
} else if isQuotedPair {
543+
guard currentScalar.isQuotedPairEscapee else {
544+
return nil
545+
}
546+
isQuotedPair = false
547+
resultView.append(currentScalar)
548+
} else if currentScalar == _Delimiters.DoubleQuote {
549+
break
550+
} else {
551+
guard currentScalar.isQdtext else {
552+
return nil
553+
}
554+
resultView.append(currentScalar)
555+
}
556+
idx = self.index(after: idx)
557+
}
558+
// Expect stop on dquote
559+
guard idx < endIndex, self[idx] == _Delimiters.DoubleQuote else {
560+
return nil
561+
}
562+
return String(resultView)
563+
}
272564
}
273565
private extension UnicodeScalar {
274566
/// Is this a valid **token** as defined by RFC 2616 ?
@@ -278,4 +570,32 @@ private extension UnicodeScalar {
278570
guard UnicodeScalar(32) <= self && self <= UnicodeScalar(126) else { return false }
279571
return !_Delimiters.Separators.characterIsMember(UInt16(self.value))
280572
}
573+
/// Is this a valid **qdtext** character
574+
///
575+
/// - SeeAlso: https://tools.ietf.org/html/rfc7230#section-3.2.6
576+
var isQdtext: Bool {
577+
// qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text
578+
// obs-text = %x80-FF
579+
let value = self.value
580+
return self == _Delimiters.HorizontalTab
581+
|| self == _Delimiters.Space
582+
|| value == 0x21
583+
|| 0x23 <= value && value <= 0x5B
584+
|| 0x5D <= value && value <= 0x7E
585+
|| 0x80 <= value && value <= 0xFF
586+
587+
}
588+
/// Is this a valid second octet of **quoted-pair**
589+
///
590+
/// - SeeAlso: https://tools.ietf.org/html/rfc7230#section-3.2.6
591+
/// - SeeAlso: https://tools.ietf.org/html/rfc5234#appendix-B.1
592+
var isQuotedPairEscapee: Bool {
593+
// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
594+
// obs-text = %x80-FF
595+
let value = self.value
596+
return self == _Delimiters.HorizontalTab
597+
|| self == _Delimiters.Space
598+
|| 0x21 <= value && value <= 0x7E
599+
|| 0x80 <= value && value <= 0xFF
600+
}
281601
}

0 commit comments

Comments
 (0)