Dissect the PKCE Authorization Code Grant Flow on iOS https://www.kodeco.com/33091327-dissect-the-pkce-authorization-code-grant-flow-on-ios
✅This diagram represents the OAuth 2.0 Authorization code grant flow that mobile applications implement:
[1] The user starts the login flow by tapping the MyGoogleInfo Login button.
[2] Consequently, the app asks the authorization server to identify the user and ask their consent to access the data. The request includes a client_id so that the server can identify the app requesting the access.
[3] So, the authorization server redirects the user to its login screen (e.g. Google) and asks the user’s consent to give the app access to the API.
[4] The user logs in and approves the request.
[5] If the user approves the access, the authorization server returns a grant code to the client.
[6] The client requests a token to the authorization server, passing its client_id and the received grant code.
[7] In response, the authorization server emits a token after verifying the client_id and the grant code.
[8] Finally, the client accesses the data to the resource server, authenticating its requests with the token.
Although the authorization code grant flow is the way to go for mobile apps, it’s subject to client impersonation attacks. A malicious app can impersonate a legitimate client and receive a valid authentication token to access the user data.
For the flow diagram above, to receive a token the attacker should know these two parameters:
The app’s client_id. The code received in the callback URL from the authorization token. Under certain circumstances, a malicious app can recover both. The app’s client ID is usually hardcoded, for example, and an attacker could find it by reverse-engineering the app. Or, by registering the malicious app as a legitimate invoker of the callback URL, the attacker can also sniff the callback URL.
Once the attacker knows the client ID and the grant code, they can request a token to the token endpoint. From that point forward, they use the access token to retrieve data illegally.
[1] This is where the login flow begins.
[2] On each login request, the client generates a random code (code_verifier) and derives a code_challenge from it.
[3] When starting the flow, the client includes the code_challenge in the request to the authorization server. On receiving the authorization request, the authorization server saves this code for later verification.
[7] The client sends the code_verifier when requesting an access token.
[8] Therefore, the authorization server verifies that code_verifier matches code_challenge. If these two codes match, the server knows the client is legit and emits the token.
PKCECodeGenerator
import Foundation
import CryptoKit
enum PKCECodeGenerator {
/// Generate a random code as specified in
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
static func generateCodeVerifier() -> String {
// TODO: Generate code_verifier
// 1
var buffer = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
// 2
return Data(buffer).base64URLEncodedString()
}
/// Generate a code challenge from a code verifier as specified in
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
static func generateCodeChallenge(codeVerifier: String) -> String? {
// TODO: Generate code_challenge
guard let data = codeVerifier.data(using: .utf8) else { return nil }
let dataHash = SHA256.hash(data: data)
return Data(dataHash).base64URLEncodedString()
}
}
private extension Data {
func base64URLEncodedString() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
.trimmingCharacters(in: .whitespaces)
}
}
Generating HTTP Requests In addition, the standard specifies two different endpoints on the Authorization server for the two authorization phases.
Open PKCERequestBuilder.swift and note the properties for each of these endpoints at the top:
Authorization endpoint at /authorize is in charge of emitting the authorization code grant. Token endpoint at /token-generation, to emit and refresh tokens.
import Foundation
struct PKCERequestBuilder {
private let authorizationEndpointURL: String
private let tokenEndpointURL: String
private let clientId: String
private let redirectURI: String
// MARK: Authorization
/// Generates a URL with the required parameters for the authorization endpoint
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.3
func createAuthorizationRequestURL(codeChallenge: String) -> URL? {
guard var urlComponents = URLComponents(string: authorizationEndpointURL) else { return nil }
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "code_challenge", value: codeChallenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
URLQueryItem(name: "access_type", value: "offline"),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: "openid+profile+https://www.googleapis.com/auth/userinfo.profile")
]
return urlComponents.url
}
// MARK: Token
/// Generates a `URLRequest` for the token exchange
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
func createTokenExchangeURLRequest(code: String, codeVerifier: String) -> URLRequest? {
var urlRequest = createURLRequestForTokenEndpoint()
urlRequest?.httpBody = createTokenExchangeRequestData(code: code, codeVerifier: codeVerifier)
return urlRequest
}
func createRefreshTokenURLRequest(refreshToken: String) -> URLRequest? {
var urlRequest = createURLRequestForTokenEndpoint()
urlRequest?.httpBody = createRefreshTokenRequestData(refreshToken: refreshToken)
return urlRequest
}
private func createURLRequestForTokenEndpoint() -> URLRequest? {
guard let tokenEndpointURL = URL(string: tokenEndpointURL) else { return nil }
var urlRequest = URLRequest(url: tokenEndpointURL)
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
return urlRequest
}
private func createTokenExchangeRequestData(code: String, codeVerifier: String) -> Data? {
var urlComponents = URLComponents()
urlComponents.queryItems = [
URLQueryItem(name: "grant_type", value: "authorization_code"),
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "code", value: code),
URLQueryItem(name: "code_verifier", value: codeVerifier),
URLQueryItem(name: "redirect_uri", value: redirectURI)
]
return urlComponents.query?.data(using: .utf8)
}
private func createRefreshTokenRequestData(refreshToken: String) -> Data? {
var urlComponents = URLComponents()
urlComponents.queryItems = [
URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "refresh_token", value: refreshToken)
]
return urlComponents.query?.data(using: .utf8)
}
}
extension PKCERequestBuilder {
// TODO: Replace clientID with ID from Google
static let myGoogleInfo = PKCERequestBuilder(
authorizationEndpointURL: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpointURL: "https://oauth2.googleapis.com/token",
clientId: "REPLACE_WITH_CLIENTID_FROM_GOOGLE_APP",
// swiftlint:disable:next force_unwrapping
redirectURI: "\(Bundle.main.bundleIdentifier!):/oauth2callback"
)
}
🫖 Note: According to the RFC, the client should communicate with these two endpoints with two different HTTP request types:
Using a GET with all the required parameters passed as URL parameters, for the authorization endpoint. Sending a POST with the parameters passed in the request’s body, encoded as URL parameters, for the token endpoint.
-
Finally, click CREATE. You should have an OAuth client definition for iOS as in the picture below:
-
Replace REPLACE_WITH_CLIENTID_FROM_GOOGLE_APP in the definition below with the Client ID from your Google app in PKCERequestBuilder.
func startAuthentication() {
print("[Debug] Start the authentication flow")
status = .authenticating
// 1
let codeVerifier = PKCECodeGenerator.generateCodeVerifier()
guard
let codeChallenge = PKCECodeGenerator.generateCodeChallenge(
codeVerifier: codeVerifier
),
// 2
let authenticationURL = requestBuilder.createAuthorizationRequestURL(
codeChallenge: codeChallenge
)
else {
print("[Error] Can't build authentication URL!")
status = .error(error: .internalError)
return
}
print("[Debug] Authentication with: \(authenticationURL.absoluteString)")
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
print("[Error] Bundle Identifier is nil!")
status = .error(error: .internalError)
return
}
// 3
let session = ASWebAuthenticationSession(
url: authenticationURL,
callbackURLScheme: bundleIdentifier
) { callbackURL, error in
// 4
self.handleAuthenticationResponse(
callbackURL: callbackURL,
error: error,
codeVerifier: codeVerifier
)
}
// 5
session.presentationContextProvider = self
// 6
session.start()
}
do {
// 1
let (data, response) = try await URLSession.shared.data(for: tokenURLRequest)
// 2
guard let response = response as? HTTPURLResponse else {
print("[Error] HTTP response parsing failed!")
status = .error(error: .tokenExchangeFailed)
return
}
guard response.isOk else {
let body = String(data: data, encoding: .utf8) ?? "EMPTY"
print("[Error] Get token failed with status: \(response.statusCode), body: \(body)")
status = .error(error: .tokenExchangeFailed)
return
}
print("[Debug] Get token response: \(String(data: data, encoding: .utf8) ?? "EMPTY")")
// 3
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let token = try decoder.decode(GoogleToken.self, from: data)
// TODO: Store the token in the Keychain
// 4
status = .authenticated(token: token)
} catch {
print("[Error] Get token failed with: \(error.localizedDescription)")
status = .error(error: .tokenExchangeFailed)
}