Skip to content
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

First go at integrating with Auth0 and identity.porsche.com #161

Merged
merged 10 commits into from Jun 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 13 additions & 4 deletions Package.resolved
Expand Up @@ -15,17 +15,26 @@
"repositoryURL": "https://github.com/envoy/Embassy.git",
"state": {
"branch": null,
"revision": "72e53b2b76023556febdfe0cf5bc8d64018f9c18",
"version": "4.1.4"
"revision": "8469f2c1b334a7c1c3566e2cb2f97826c7cca898",
"version": "4.1.6"
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser",
"state": {
"branch": null,
"revision": "fddd1c00396eed152c45a46bea9f47b98e59301d",
"version": "1.2.0"
"revision": "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a",
"version": "1.2.2"
}
},
{
"package": "SwiftSoup",
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
"state": {
"branch": null,
"revision": "0e96a20ffd37a515c5c963952d4335c89bed50a6",
"version": "2.6.0"
}
},
{
Expand Down
9 changes: 5 additions & 4 deletions Package.swift
Expand Up @@ -15,10 +15,11 @@ let package = Package(
targets: ["PorscheConnect"]),
],
dependencies: [
.package(url: "https://github.com/envoy/Embassy.git", from: "4.1.4"),
.package(url: "https://github.com/envoy/Embassy.git", from: "4.1.6"),
.package(url: "https://github.com/envoy/Ambassador.git", from: "4.0.5"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"),
.package(url: "https://github.com/mochidev/XCTAsync.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.2"),
.package(url: "https://github.com/mochidev/XCTAsync.git", .upToNextMajor(from: "1.0.1")),
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"),
],
targets: [
.executableTarget(
Expand All @@ -32,7 +33,7 @@ let package = Package(
]),
.target(
name: "PorscheConnect",
dependencies: []),
dependencies: ["SwiftSoup"]),
.testTarget(
name: "PorscheConnectTests",
dependencies: [
Expand Down
14 changes: 9 additions & 5 deletions README.md
Expand Up @@ -19,7 +19,7 @@ Absolutely not. These endpoints are a result of reverse engineering Porsche's we

## CI/CD Status

The library has a comprehensive suite of unit tests that run on GitHub Actions. Currently the test suite is run on a Intel based macOS v12.4.x running XCode 14.2.
The library has a comprehensive suite of unit tests that run on GitHub Actions. Currently the test suite is run on a Intel based macOS v13..x running XCode 14.3.

You can see the current build status of the `main` branch here:

Expand All @@ -36,15 +36,19 @@ Porsche Connect requires Swift 5.5 or higher. It uses the new async/await concur

Currently the library supports the following platforms:

* **macOS** (Version 11.0+)
* **iOS** (Version 14+)
* **tvOS** (Version 14+)
* **watchOS** (Version 6+)
* **macOS** (Version 12.0+)
* **iOS** (Version 15+)
* **tvOS** (Version 15+)
* **watchOS** (Version 8+)

### Swift Package Index

This library is availble on the Swift Package Index at [https://swiftpackageindex.com/driven-app/porsche-connect](https://swiftpackageindex.com/driven-app/porsche-connect).

### Auth

Since calendar week 12, 2023, Porsche has moved their identity provider (iDP) in production to [Auth0](https://auth0.com). Release v0.1.37 and higher of this library uses this new Auth0 service in a transparent manner to external clients. Use of the old iDP has been retired.


# Usage

Expand Down
210 changes: 113 additions & 97 deletions Sources/PorscheConnect/APIs/PorscheConnect+Auth.swift
@@ -1,123 +1,138 @@
import Foundation
import SwiftSoup

extension PorscheConnect {

public func auth(application: OAuthApplication) async throws -> OAuthToken {
let token: OAuthToken = try await networkClient.interceptCookiesOnWatchOS {
let loginToRetrieveCookiesResponse = try await loginToRetrieveCookies()
guard loginToRetrieveCookiesResponse != nil else { throw PorscheConnectError.NoResult }

let apiAuthCodeResult = try await getApiAuthCode(application: application)
guard let codeVerifier = apiAuthCodeResult.codeVerifier,
let code = apiAuthCodeResult.code
else { throw PorscheConnectError.NoResult }

let apiTokenResult = try await getApiToken(
application: application, codeVerifier: codeVerifier, code: code)
guard let porscheAuth = apiTokenResult.data,
apiTokenResult.response != nil
else { throw PorscheConnectError.NoResult }

return OAuthToken(authResponse: porscheAuth)
}
let initialStateResponse = try await getInitialStateFromAuthService()
let sendAuthenticationDetailsResponse = try await sendAuthenticationDetails(state: initialStateResponse.state)
let _ = try await followCallback(formNameValuePairs: sendAuthenticationDetailsResponse.formNameValuePairs)
let resumeAuthResponse = try await resumeAuth()
let accessTokenResponse = try await getAccessToken(code: resumeAuthResponse.code)

let token = OAuthToken(authResponse: accessTokenResponse.authResponse)
try await authStorage.storeAuthentication(token: token, for: application.clientId)

return token
}

private func loginToRetrieveCookies() async throws -> HTTPURLResponse? {
let loginBody = buildLoginBody(username: username, password: password)
let result = try await networkClient.post(
String.self, url: networkRoutes.loginAuthURL,
body: buildPostFormBodyFrom(dictionary: loginBody), contentType: .form,
parseResponseBody: false)
if let statusCode = HttpStatusCode(rawValue: result.response.statusCode),
statusCode == .OK
{
AuthLogger.info("Login to retrieve cookies successful")

// MARK: - Private functions

private func getInitialStateFromAuthService() async throws -> (state: String, response: HTTPURLResponse?) {
let result = try await networkClient.get(String.self, url: networkRoutes.loginAuth0URL, parseResponseBody: false, shouldFollowRedirects: false)

if let statusCode = HttpStatusCode(rawValue: result.response.statusCode), statusCode == .Found {
AuthLogger.info("Initial state from auth service successful.")
}


guard let headerValue = result.response.value(forHTTPHeaderField: "Location"),
let urlComponents = URLComponents(string: headerValue),
let state = urlComponents.queryItems?.first(where: { $0.name == "state"})?.value
else {
AuthLogger.error("Could not find or process Location header from auth response.")
throw PorscheConnectError.AuthFailure
}

return (state, result.response)
}

private func sendAuthenticationDetails(state: String) async throws -> (formNameValuePairs: [String : String], response: HTTPURLResponse?) {
let loginBody = buildLoginBody(username: username, password: password, state: state)
let result = try await networkClient.post(String.self, url: networkRoutes.usernamePasswordLoginAuth0URL, body: buildPostFormBodyFrom(dictionary: loginBody), contentType: .form)

if let statusCode = HttpStatusCode(rawValue: result.response.statusCode), statusCode == .OK {
AuthLogger.info("Authentication details sent successfully.")
}

guard let html = result.data else {
AuthLogger.error("No HTML form data returned.")
throw PorscheConnectError.AuthFailure
}

var hiddenFormNameValuePairs = [String:String]()

let document = try SwiftSoup.parseBodyFragment(html)
let elements: Elements = try document.select("input[type='hidden']")
for element in elements {
hiddenFormNameValuePairs[try element.attr("name")] = try element.attr("value")
}

return (hiddenFormNameValuePairs, result.response)
}

private func followCallback(formNameValuePairs: [String:String]) async throws -> HTTPURLResponse? {
let result = try await networkClient.post(String.self, url: networkRoutes.callbackAuth0URL, body: buildPostFormBodyFrom(dictionary: formNameValuePairs), contentType: .form, parseResponseBody: false, shouldFollowRedirects: false)

if let statusCode = HttpStatusCode(rawValue: result.response.statusCode), statusCode == .Found {
AuthLogger.info("Authentication details sent successfully.")
}

if !environment.testEnvironment {
AuthLogger.info("About to sleep for \(kSleepDurationInSecs) seconds to give Porsche Auth0 service chance to process previous request.")
try await Task.sleep(nanoseconds: UInt64(kSleepDurationInSecs * Double(NSEC_PER_SEC)))
AuthLogger.info("Finished sleeping.")
}

return result.response
}

private func getApiAuthCode(application: OAuthApplication) async throws -> (
code: String?, codeVerifier: String?, response: HTTPURLResponse?
) {
let codeVerifier = codeChallenger.generateCodeVerifier()! //TODO: handle null
AuthLogger.debug("Code Verifier: \(codeVerifier)")

let apiAuthParams = buildApiAuthParams(
clientId: application.clientId, redirectURL: application.redirectURL,
codeVerifier: codeVerifier)
let result = try await networkClient.get(
String.self, url: networkRoutes.apiAuthURL, params: apiAuthParams, parseResponseBody: false)
if let url = result.response.value(forHTTPHeaderField: "cdn-original-uri"),
let code = URLComponents(string: url)?.queryItems?.first(where: { $0.name == "code" })?.value
{
AuthLogger.info("Api Auth call for code successful")
return (code, codeVerifier, result.response)
} else {

private func resumeAuth() async throws -> (code: String, response: HTTPURLResponse?) {
let url = environment.testEnvironment ? networkRoutes.resumeAuth0URL : networkRoutes.loginAuth0URL
let result = try await networkClient.get(String.self, url: url, parseResponseBody: false, shouldFollowRedirects: false)

if let statusCode = HttpStatusCode(rawValue: result.response.statusCode), statusCode == .Found {
AuthLogger.info("Resume auth service successful.")
}

guard let headerValue = result.response.value(forHTTPHeaderField: "Location"),
let urlComponents = URLComponents(string: headerValue),
let code = urlComponents.queryItems?.first(where: { $0.name == "code"})?.value
else {
AuthLogger.error("Could not find or process Location header from auth response.")
throw PorscheConnectError.AuthFailure
}

return (code, result.response)
}

private func getApiToken(application: OAuthApplication, codeVerifier: String, code: String)
async throws -> (data: AuthResponse?, response: HTTPURLResponse?)
{
let apiTokenBody = buildApiTokenBody(
clientId: application.clientId, redirectURL: application.redirectURL, code: code,
codeVerifier: codeVerifier)
let result = try await networkClient.post(
AuthResponse.self, url: networkRoutes.apiTokenURL,
body: buildPostFormBodyFrom(dictionary: apiTokenBody), contentType: .form)
if let statusCode = HttpStatusCode(rawValue: result.response.statusCode),
statusCode == .OK
{
AuthLogger.info("Api Auth call for token successful")

private func getAccessToken(code: String) async throws -> (authResponse: AuthResponse, response: HTTPURLResponse?) {
let result = try await networkClient.post(AuthResponse.self, url: networkRoutes.accessTokenAuth0URL, body: buildPostFormBodyFrom(dictionary: buildAccessTokenBody(code: code)), contentType: .form)

if let statusCode = HttpStatusCode(rawValue: result.response.statusCode), statusCode == .Found {
AuthLogger.info("Retrieving access token successful.")
}

return result

guard let authResponse = result.data else {
AuthLogger.error("Could not map response to AuthResponse.")
throw PorscheConnectError.AuthFailure
}

return (authResponse, result.response)
}

private func buildLoginBody(username: String, password: String) -> [String: String] {
private func buildLoginBody(username: String, password: String, state: String) -> [String : String] {
return [
"sec": "high",
"username": username,
"password": password,
"keeploggedin": "false",
"sec": kBlankString,
"resume": kBlankString,
"thirdPartyId": kBlankString,
"state": kBlankString,
]
}

private func buildApiAuthParams(clientId: String, redirectURL: URL, codeVerifier: String)
-> [String: String]
{
let codeChallenge = codeChallenger.codeChallenge(for: codeVerifier)! //TODO: Handle null
AuthLogger.debug("Code Challenge: \(codeChallenge)")

return [
"client_id": clientId,
"redirect_uri": redirectURL.absoluteString,
"code_challenge": codeChallenge,
"scope": "openid",
"response_type": "code",
"access_type": "offline",
"prompt": "none",
"code_challenge_method": "S256",
"redirect_uri": "https://my.porsche.com/",
"ui_locales": "de-DE",
"audience": "https://api.porsche.com",
"client_id": "UYsK00My6bCqJdbQhTQ0PbWmcSdIAMig",
"connection": "Username-Password-Authentication",
"state": state,
"tenant": "porsche-production",
"response_type": "code"
]
}

private func buildApiTokenBody(
clientId: String, redirectURL: URL, code: String, codeVerifier: String
) -> [String: String] {

private func buildAccessTokenBody(code: String) -> [String : String] {
return [
"client_id": clientId,
"redirect_uri": redirectURL.absoluteString,
"code": code,
"code_verifier": codeVerifier,
"prompt": "none",
"client_id": OAuthApplication.api.clientId,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": OAuthApplication.api.redirectURL.description
]
}
}
Expand All @@ -132,5 +147,6 @@ struct AuthResponse: Decodable {
let accessToken: String
let idToken: String
let tokenType: String
let scope: String
let expiresIn: Double
}
Expand Up @@ -4,7 +4,7 @@ extension PorscheConnect {
public func capabilities(vin: String) async throws -> (
capabilities: Capabilities?, response: HTTPURLResponse
) {
let headers = try await performAuthFor(application: .carControl)
let headers = try await performAuthFor(application: .api)

let result = try await networkClient.get(
Capabilities.self, url: networkRoutes.vehicleCapabilitiesURL(vin: vin),
Expand Down
4 changes: 2 additions & 2 deletions Sources/PorscheConnect/APIs/PorscheConnect+Emobility.swift
Expand Up @@ -5,7 +5,7 @@ extension PorscheConnect {
public func emobility(vin: String, capabilities: Capabilities) async throws -> (
emobility: Emobility?, response: HTTPURLResponse
) {
let headers = try await performAuthFor(application: .carControl)
let headers = try await performAuthFor(application: .api)

let result = try await networkClient.get(
Emobility.self,
Expand Down Expand Up @@ -150,7 +150,7 @@ public struct Emobility: Codable {
public let profileName: String
public let profileActive: Bool
public let chargingOptions: ChargingOptions
public let position: Position
public let position: Position?

// MARK: -

Expand Down
2 changes: 1 addition & 1 deletion Sources/PorscheConnect/APIs/PorscheConnect+Position.swift
Expand Up @@ -5,7 +5,7 @@ extension PorscheConnect {
public func position(vin: String) async throws -> (
position: Position?, response: HTTPURLResponse
) {
let headers = try await performAuthFor(application: .carControl)
let headers = try await performAuthFor(application: .api)

let result = try await networkClient.get(
Position.self, url: networkRoutes.vehiclePositionURL(vin: vin), headers: headers,
Expand Down
2 changes: 1 addition & 1 deletion Sources/PorscheConnect/APIs/PorscheConnect+Summary.swift
Expand Up @@ -4,7 +4,7 @@ extension PorscheConnect {
public func summary(vin: String) async throws -> (
summary: Summary?, response: HTTPURLResponse
) {
let headers = try await performAuthFor(application: .carControl)
let headers = try await performAuthFor(application: .api)

let result = try await networkClient.get(
Summary.self, url: networkRoutes.vehicleSummaryURL(vin: vin), headers: headers,
Expand Down
Expand Up @@ -6,7 +6,7 @@ extension PorscheConnect {
) async throws -> (
remoteCommandAccepted: RemoteCommandAccepted?, response: HTTPURLResponse
) {
let headers = try await performAuthFor(application: .carControl)
let headers = try await performAuthFor(application: .api)
let url = networkRoutes.vehicleToggleDirectChargingURL(
vin: vin, capabilities: capabilities, enable: enable)

Expand Down
Expand Up @@ -6,7 +6,7 @@ extension PorscheConnect {
) async throws -> (
remoteCommandAccepted: RemoteCommandAccepted?, response: HTTPURLResponse
) {
let headers = try await performAuthFor(application: .carControl)
let headers = try await performAuthFor(application: .api)
let url = networkRoutes.vehicleToggleDirectClimatisationURL(
vin: vin, enable: enable)

Expand Down