Skip to content

YamamotoDesu/PKCEAuthorization

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 

Repository files navigation

PKCEAuthorization

Dissect the PKCE Authorization Code Grant Flow on iOS https://www.kodeco.com/33091327-dissect-the-pkce-authorization-code-grant-flow-on-ios

Authorization Code Grant Flow

✅This diagram represents the OAuth 2.0 Authorization code grant flow that mobile applications implement:

image

[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.

💀Attacking the Authorization Code Grant Flow

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.


✅The following diagram depicts how PKCE strengthens the Authorization Code Grant flow in practice:

image

[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.

🌝Preparing Server Side (Google Cloud Platform)

Enabling the Required API

  • Enable the Google People API to allow the app to query the user information image

  • Then, search for Google People API and click ENABLE. image

Generating the Authorization Credentials

  • Click CREATE CREDENTIALS, then choose OAuth Client ID image

  • Fill in the required fields as shown in the figure below. image

  • Finally, click CREATE. You should have an OAuth client definition for iOS as in the picture below: image

  • Replace REPLACE_WITH_CLIENTID_FROM_GOOGLE_APP in the definition below with the Client ID from your Google app in PKCERequestBuilder. image

Authenticating the User

  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()

  }

You’ll see an alert saying MyGoogleInfo wants to use google.com to sign in

ă‚čクăƒȘăƒŒăƒłă‚·ăƒ§ăƒƒăƒˆ 2023-03-12 14 47 01

Tap Continue and you’ll see the Google login screen

ă‚čクăƒȘăƒŒăƒłă‚·ăƒ§ăƒƒăƒˆ 2023-03-12 14 47 38

Getting the Access Token

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)
}

image

About

Dissect the PKCE Authorization Code Grant Flow on iOS

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages