Skip to content

Commit 36b154d

Browse files
larrybrunetossus-lib
authored andcommitted
Add PKCE support (p2#324)
* Add optional PKCE support * PKCE documentation * PKCE unit tests * Updated contributors * Closes p2#290
1 parent e4deb6c commit 36b154d

File tree

8 files changed

+129
-2
lines changed

8 files changed

+129
-2
lines changed

CONTRIBUTORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Contributors
33

44
Contributors to the codebase, in reverse chronological order:
55

6+
- Larry Brunet, @larrybrunet
67
- Dave Carlson, @drdavec
78
- Sam Oakley, @blork
89
- David Jennes, @davidjennes

OAuth2.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@
404404
EEDB8625193FAAE500C4EEA1 /* Products */,
405405
);
406406
sourceTree = "<group>";
407+
usesTabs = 1;
407408
};
408409
EEDB8625193FAAE500C4EEA1 /* Products */ = {
409410
isa = PBXGroup;

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,12 @@ dynreg.register(client: oauth2) { params, error in
452452
}
453453
```
454454

455+
PKCE
456+
----
457+
458+
PKCE support is controlled by the `useProofKeyForCodeExchange` property, and the "use_pkce" setting.
459+
It is disabled by default. When enabled, a new code verifier string is generated for every authorization request.
460+
455461

456462
Keychain
457463
--------

Sources/Base/OAuth2Base.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
//
2020

2121
import Foundation
22+
import CommonCrypto
2223

2324

2425
/**
@@ -451,6 +452,7 @@ open class OAuth2Base: OAuth2Securable {
451452
*/
452453
open func assureRefreshTokenParamsAreValid(_ params: OAuth2JSON) throws {
453454
}
455+
454456
}
455457

456458

@@ -462,6 +464,10 @@ open class OAuth2ContextStore {
462464
/// Currently used redirect_url.
463465
open var redirectURL: String?
464466

467+
/// Current code verifier used for PKCE
468+
public internal(set) var codeVerifier: String?
469+
public let codeChallengeMethod = "S256"
470+
465471
/// The current state.
466472
internal var _state = ""
467473

@@ -497,5 +503,37 @@ open class OAuth2ContextStore {
497503
func resetState() {
498504
_state = ""
499505
}
506+
507+
// MARK: - PKCE
508+
509+
/**
510+
Generates a new code verifier string
511+
*/
512+
func generateCodeVerifier() {
513+
var buffer = [UInt8](repeating: 0, count: 32)
514+
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
515+
codeVerifier = Data(bytes: buffer).base64EncodedString()
516+
.replacingOccurrences(of: "+", with: "-")
517+
.replacingOccurrences(of: "/", with: "_")
518+
.replacingOccurrences(of: "=", with: "")
519+
.trimmingCharacters(in: .whitespaces)
520+
}
521+
522+
523+
func codeChallenge() -> String? {
524+
guard let verifier = codeVerifier, let data = verifier.data(using: .utf8) else { return nil }
525+
var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
526+
data.withUnsafeBytes {
527+
_ = CC_SHA256($0, CC_LONG(data.count), &buffer)
528+
}
529+
let hash = Data(bytes: buffer)
530+
let challenge = hash.base64EncodedString()
531+
.replacingOccurrences(of: "+", with: "-")
532+
.replacingOccurrences(of: "/", with: "_")
533+
.replacingOccurrences(of: "=", with: "")
534+
.trimmingCharacters(in: .whitespaces)
535+
return challenge
536+
}
537+
500538
}
501539

Sources/Base/OAuth2ClientConfig.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ open class OAuth2ClientConfig {
8787
/// url.
8888
open var safariCancelWorkaround = false
8989

90+
/// Use Proof Key for Code Exchange (PKCE)
91+
///
92+
/// See https://tools.ietf.org/html/rfc7636
93+
///
94+
open var useProofKeyForCodeExchange = false
95+
9096
/**
9197
Initializer to initialize properties from a settings dictionary.
9298
*/
@@ -139,6 +145,11 @@ open class OAuth2ClientConfig {
139145
if let assume = settings["token_assume_unexpired"] as? Bool {
140146
accessTokenAssumeUnexpired = assume
141147
}
148+
149+
if let usePKCE = settings["use_pkce"] as? Bool {
150+
useProofKeyForCodeExchange = usePKCE
151+
}
152+
142153
}
143154

144155

Sources/Flows/OAuth2.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,11 @@ open class OAuth2: OAuth2Base {
285285
if clientConfig.safariCancelWorkaround {
286286
req.params["swa"] = "\(Date.timeIntervalSinceReferenceDate)" // Safari issue workaround
287287
}
288+
if clientConfig.useProofKeyForCodeExchange {
289+
context.generateCodeVerifier()
290+
req.params["code_challenge"] = context.codeChallenge()
291+
req.params["code_challenge_method"] = context.codeChallengeMethod
292+
}
288293
req.add(params: params)
289294

290295
return req

Sources/Flows/OAuth2CodeGrant.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ open class OAuth2CodeGrant: OAuth2 {
6767
req.params["grant_type"] = type(of: self).grantType
6868
req.params["redirect_uri"] = redirect
6969
req.params["client_id"] = clientId
70-
70+
if clientConfig.useProofKeyForCodeExchange {
71+
req.params["code_verifier"] = context.codeVerifier
72+
}
7173
return req
7274
}
7375

Tests/FlowTests/OAuth2CodeGrantTests.swift

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,26 @@ class OAuth2CodeGrantTests: XCTestCase {
9797
XCTAssertTrue(8 == (query["state"]!).count, "Expecting an auto-generated UUID for `state`")
9898
}
9999

100+
func testAuthorizeURIWithPKCE() {
101+
let oauth = OAuth2CodeGrant(settings: [
102+
"client_id": "abc",
103+
"authorize_uri": "https://auth.ful.io",
104+
"token_uri": "https://token.ful.io",
105+
"keychain": false,
106+
"use_pkce" : true,
107+
])
108+
XCTAssertNotNil(oauth.authURL, "Must init `authorize_uri`")
109+
110+
let comp = URLComponents(url: try! oauth.authorizeURL(withRedirect: "oauth2://callback", scope: nil, params: nil), resolvingAgainstBaseURL: true)!
111+
XCTAssertEqual(comp.host!, "auth.ful.io", "Correct host")
112+
let query = OAuth2CodeGrant.params(fromQuery: comp.percentEncodedQuery!)
113+
XCTAssertEqual(query["client_id"]!, "abc", "Expecting correct `client_id`")
114+
XCTAssertNotNil(query["code_challenge"], "Must have `code_challenge`")
115+
XCTAssertEqual(query["code_challenge_method"]!, "S256", "Expecting correct `code_challenge_method`")
116+
XCTAssertEqual(query["redirect_uri"]!, "oauth2://callback", "Expecting correct `redirect_uri`")
117+
XCTAssertTrue(8 == (query["state"]!).count, "Expecting an auto-generated UUID for `state`")
118+
}
119+
100120
func testRedirectURI() {
101121
let oauth = OAuth2CodeGrant(settings: baseSettings)
102122
oauth.redirect = "oauth2://callback"
@@ -253,7 +273,50 @@ class OAuth2CodeGrantTests: XCTestCase {
253273
XCTAssertEqual(query2["redirect_uri"]!, "oauth2://callback", "Expecting correct `redirect_uri`")
254274
XCTAssertNil(query2["state"], "`state` must be empty")
255275
}
256-
276+
277+
func testTokenRequestWithPKCE() {
278+
let oauth = OAuth2CodeGrant(settings: [
279+
"client_id": "abc",
280+
"authorize_uri": "https://auth.ful.io",
281+
"token_uri": "https://token.ful.io",
282+
"keychain": false,
283+
"use_pkce" : true,
284+
])
285+
oauth.redirect = "oauth2://callback"
286+
287+
// no redirect in context - fail
288+
do {
289+
_ = try oauth.accessTokenRequest(with: "pp")
290+
XCTAssertTrue(false, "Should not be here any more")
291+
}
292+
catch OAuth2Error.noRedirectURL {
293+
XCTAssertTrue(true, "Must be here")
294+
}
295+
catch {
296+
XCTAssertTrue(false, "Should not be here")
297+
}
298+
299+
// with redirect in context - success
300+
oauth.context.redirectURL = "oauth2://callback"
301+
302+
// initialize code verifier in context
303+
oauth.context.generateCodeVerifier()
304+
305+
let req = try! oauth.accessTokenRequest(with: "pp").asURLRequest(for: oauth)
306+
let comp = URLComponents(url: req.url!, resolvingAgainstBaseURL: true)!
307+
XCTAssertEqual(comp.host!, "token.ful.io", "Correct host")
308+
309+
let body = String(data: req.httpBody!, encoding: String.Encoding.utf8)
310+
let query = OAuth2CodeGrant.params(fromQuery: body!)
311+
XCTAssertEqual(query["client_id"]!, "abc", "Expecting correct `client_id`")
312+
XCTAssertNil(query["client_secret"], "Must not have `client_secret`")
313+
XCTAssertEqual(query["code"]!, "pp", "Expecting correct `code`")
314+
XCTAssertEqual(query["grant_type"]!, "authorization_code", "Expecting correct `grant_type`")
315+
XCTAssertEqual(query["redirect_uri"]!, "oauth2://callback", "Expecting correct `redirect_uri`")
316+
XCTAssertNil(query["state"], "`state` must be empty")
317+
XCTAssertNotNil(query["code_verifier"], "Must have `code_verifier`")
318+
}
319+
257320
func testCustomAuthParameters() {
258321
let oauth = OAuth2CodeGrant(settings: baseSettings)
259322
oauth.redirect = "oauth2://callback"

0 commit comments

Comments
 (0)