-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
MBL-1157: Create PKCE code for code verifier and code challenge #1921
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import CryptoKit | ||
import Foundation | ||
import Security | ||
|
||
enum PKCEError: Error { | ||
case UnexpectedRuntimeError | ||
} | ||
|
||
public extension Data { | ||
func base64URLEncodedStringWithNoPadding() -> String { | ||
var encodedString = self.base64EncodedString() | ||
|
||
// Convert base64 to base64url | ||
encodedString = encodedString.replacingOccurrences(of: "+", with: "-") | ||
encodedString = encodedString.replacingOccurrences(of: "/", with: "_") | ||
// Strip padding | ||
encodedString = encodedString.replacingOccurrences(of: "=", with: "") | ||
|
||
return encodedString | ||
} | ||
|
||
mutating func fillWithRandomSecureBytes() throws { | ||
do { | ||
let numBytes = self.count | ||
try self.withUnsafeMutableBytes { rawPointer in | ||
let pointer = rawPointer.bindMemory(to: UInt8.self) | ||
guard let address = pointer.baseAddress else { | ||
throw PKCEError.UnexpectedRuntimeError | ||
} | ||
|
||
let result = SecRandomCopyBytes(kSecRandomDefault, numBytes, address) | ||
|
||
if result != errSecSuccess { | ||
throw PKCEError.UnexpectedRuntimeError | ||
} | ||
} | ||
} catch { | ||
throw error | ||
} | ||
} | ||
|
||
func sha256Hash() throws -> Data { | ||
let hash = SHA256.hash(data: self) | ||
var hashData: Data? | ||
|
||
hash.withUnsafeBytes { pointer in | ||
let dataPointer = pointer.bindMemory(to: UInt8.self) | ||
hashData = Data(buffer: dataPointer) | ||
} | ||
|
||
guard let unwrappedData = hashData else { | ||
throw PKCEError.UnexpectedRuntimeError | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alternatively, I could make these methods return a nullable |
||
} | ||
|
||
return unwrappedData | ||
} | ||
} | ||
|
||
/// PKCE stands for Proof Key for Code Exchange. | ||
/// The code verifier, and its associated challenge, are used to ensure that an oAuth request is valid. | ||
/// See this documentation for more details: https://www.oauth.com/oauth2-servers/mobile-and-native-apps/authorization/ | ||
public struct PKCE { | ||
/// Creates a random alphanumeric string of the specified length | ||
static func createCodeVerifier(byteLength length: Int) throws -> String { | ||
do { | ||
var buffer = Data(count: length) | ||
try buffer.fillWithRandomSecureBytes() | ||
return buffer.base64URLEncodedStringWithNoPadding() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is how the RFC for OAuth suggests doing it - you create a shorter, cryptographically random buffer, and use base64 URL encoding to turn it into the valid character set. |
||
} catch _ { | ||
throw PKCEError.UnexpectedRuntimeError | ||
} | ||
} | ||
|
||
/// Creates a base-64 encoded SHA256 hash of the given string. | ||
static func createCodeChallenge(fromVerifier verifier: String) throws -> String { | ||
guard let stringData = verifier.data(using: .utf8) else { | ||
throw PKCEError.UnexpectedRuntimeError | ||
} | ||
|
||
do { | ||
let hash = try stringData.sha256Hash() | ||
return hash.base64URLEncodedStringWithNoPadding() | ||
} catch { | ||
throw PKCEError.UnexpectedRuntimeError | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
@testable import KsApi | ||
import XCTest | ||
|
||
final class PKCETest: XCTestCase { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some rough tests - we'll coordinate more examples later, to make sure iOS + Android have the same behavior. |
||
func testData_RandomSecureBytes_overwritesOriginalData() { | ||
let buffer1 = Data(repeating: 0, count: 32) | ||
var buffer2 = Data(repeating: 0, count: 32) | ||
var buffer3 = Data(repeating: 0, count: 32) | ||
|
||
XCTAssertEqual(buffer1, buffer2) | ||
XCTAssertEqual(buffer2, buffer3) | ||
|
||
try! buffer2.fillWithRandomSecureBytes() | ||
try! buffer3.fillWithRandomSecureBytes() | ||
|
||
XCTAssertNotEqual( | ||
buffer1, | ||
buffer2, | ||
"Buffer filled with random data should not be equal to buffer filled with zeroes" | ||
) | ||
XCTAssertNotEqual( | ||
buffer1, | ||
buffer3, | ||
"Buffer filled with random data should not be equal to buffer filled with zeroes" | ||
) | ||
XCTAssertNotEqual(buffer2, buffer3, "Two randomly filled buffers should not be equal") | ||
} | ||
|
||
func testData_SHA256Hash_isCorrectValue() { | ||
let testString = "Hello, world. I am a string." | ||
let stringData = testString.data(using: .utf8) | ||
|
||
let hash = try! stringData!.sha256Hash() | ||
|
||
let expectedHashString = "c1c6864039f380248d30a73525c8351427c7fb468d58b7b1005f3e3727a042a1" | ||
let actualHashString = hash.map { String(format: "%02x", $0) }.joined() | ||
|
||
XCTAssertEqual(expectedHashString, actualHashString) | ||
} | ||
|
||
func testData_Base64URLEncodedString_isCorrectValue() { | ||
let testString = "Hello, world. I am a string." | ||
|
||
let expectedEncodedString = "SGVsbG8sIHdvcmxkLiBJIGFtIGEgc3RyaW5nLg" | ||
let actualEncodedString = testString.data(using: .utf8)!.base64URLEncodedStringWithNoPadding() | ||
|
||
XCTAssertEqual(expectedEncodedString, actualEncodedString) | ||
} | ||
|
||
func testPKCECodeChallenge_isValid() { | ||
// TODO: Add more examples. | ||
let res = try! PKCE.createCodeChallenge(fromVerifier: "foo") | ||
XCTAssertEqual(res, "LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564") | ||
} | ||
|
||
func testPKCECreateCodeVerifier_matchesRequirements() { | ||
let verifier = try! PKCE.createCodeVerifier(byteLength: 32) | ||
XCTAssertEqual(verifier.count, 43) | ||
|
||
let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" | ||
let lowercase = "abcdefghijklmnopqrstuvwxyz" | ||
let numbers = "0123456789" | ||
let specials = "-._~" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Out of curiosity, how would you get There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question - the RFC mentioned that these were valid, but you're right, they're not part of base64url 🤷♀️ |
||
|
||
let validCharacters = CharacterSet(charactersIn: uppercase + lowercase + numbers + specials) | ||
|
||
for character in verifier { | ||
let unichar = character.unicodeScalars.first! | ||
XCTAssertTrue(validCharacters.contains(unichar), "\(unichar) not in valid character set") | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These utilities are translated from the examples in OpenID: https://github.com/openid/AppAuth-iOS/blob/aea7b8acd8b3fc261b8a42c998de33d76851f30b/Source/AppAuthCore/OIDTokenUtilities.m#L31-L55