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

Add NetP authorize API support #523

Merged
merged 12 commits into from
Dec 25, 2023
Merged
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ let package = Package(
.testTarget(
name: "NetworkProtectionTests",
dependencies: [
"NetworkProtection"
"NetworkProtection",
"NetworkProtectionTestUtils"
],
resources: [
.copy("Resources/servers-original-endpoint.json"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public enum NetworkProtectionError: LocalizedError {
case failedToEncodeRedeemRequest
case invalidInviteCode
case failedToRedeemInviteCode(Error?)
case failedToRetrieveAuthToken(AuthenticationFailureResponse)
case failedToParseRedeemResponse(Error)
case invalidAuthToken
case serverListInconsistency
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public protocol NetworkProtectionCodeRedeeming {

/// Redeems an invite code with the Network Protection backend and stores the resulting auth token
func redeem(_ code: String) async throws

/// Exchanges an access token for an auth token, and stores the resulting auth token
func exchange(accessToken: String) async throws

}

/// Coordinates calls to the backend and oAuth token storage
Expand All @@ -43,7 +47,7 @@ public final class NetworkProtectionCodeRedemptionCoordinator: NetworkProtection
}

public func redeem(_ code: String) async throws {
let result = await networkClient.redeem(inviteCode: code)
let result = await networkClient.authenticate(withMethod: .inviteCode(code))
switch result {
case .success(let token):
try tokenStore.store(token)
Expand All @@ -55,4 +59,19 @@ public final class NetworkProtectionCodeRedemptionCoordinator: NetworkProtection
throw error
}
}

public func exchange(accessToken code: String) async throws {
let result = await networkClient.authenticate(withMethod: .subscription(code))
switch result {
case .success(let token):
try tokenStore.store(token)
// enable version checker on next run
versionStore.lastVersionRun = AppVersion.shared.versionNumber

case .failure(let error):
errorEvents.fire(error.networkProtectionError)
throw error
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,21 @@ import Common

public protocol NetworkProtectionTokenStore {

/// Store an oAuth token.
/// Store an auth token.
///
func store(_ token: String) throws

/// Obtain the current oAuth token.
/// Obtain the current auth token.
///
func fetchToken() throws -> String?

/// Obtain the stored oAuth token.
/// Obtain the stored auth token.
///
func deleteToken() throws
}

/// Store an oAuth token for NetworkProtection on behalf of the user. This key is then used to authenticate requests for registration and server fetches from the Network Protection backend servers.
/// Writing a new oAuth token will replace the old one.
/// Store an auth token for NetworkProtection on behalf of the user. This key is then used to authenticate requests for registration and server fetches from the Network Protection backend servers.
/// Writing a new auth token will replace the old one.
public final class NetworkProtectionKeychainTokenStore: NetworkProtectionTokenStore {
private let keychainStore: NetworkProtectionKeychainStore
private let errorEvents: EventMapping<NetworkProtectionError>?
Expand Down
71 changes: 61 additions & 10 deletions Sources/NetworkProtection/Networking/NetworkProtectionClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@

import Foundation

public enum NetworkProtectionAuthenticationMethod {
case inviteCode(String)
case subscription(String)
}

public protocol NetworkProtectionClient {
func redeem(inviteCode: String) async -> Result<String, NetworkProtectionClientError>
func authenticate(withMethod method: NetworkProtectionAuthenticationMethod) async -> Result<String, NetworkProtectionClientError>
func getServers(authToken: String) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError>
func register(authToken: String,
publicKey: PublicKey,
Expand All @@ -35,6 +40,7 @@ public enum NetworkProtectionClientError: Error, NetworkProtectionErrorConvertib
case failedToEncodeRedeemRequest
case invalidInviteCode
case failedToRedeemInviteCode(Error?)
case failedToRetrieveAuthToken(AuthenticationFailureResponse)
case failedToParseRedeemResponse(Error)
case invalidAuthToken

Expand All @@ -48,6 +54,7 @@ public enum NetworkProtectionClientError: Error, NetworkProtectionErrorConvertib
case .failedToEncodeRedeemRequest: return .failedToEncodeRedeemRequest
case .invalidInviteCode: return .invalidInviteCode
case .failedToRedeemInviteCode(let error): return .failedToRedeemInviteCode(error)
case .failedToRetrieveAuthToken(let response): return .failedToRetrieveAuthToken(response)
case .failedToParseRedeemResponse(let error): return .failedToParseRedeemResponse(error)
case .invalidAuthToken: return .invalidAuthToken
}
Expand All @@ -64,19 +71,28 @@ struct RegisterKeyRequestBody: Encodable {
}
}

struct RedeemRequestBody: Encodable {
struct RedeemInviteCodeRequestBody: Encodable {
let code: String
}

struct RedeemResponse: Decodable {
struct ExchangeAccessTokenRequestBody: Encodable {
let token: String
}

struct AuthenticationSuccessResponse: Decodable {
let token: String
}

public struct AuthenticationFailureResponse: Decodable {
public let message: String
}

public final class NetworkProtectionBackendClient: NetworkProtectionClient {

enum Constants {
static let productionEndpoint = URL(string: "https://controller.netp.duckduckgo.com")!
static let stagingEndpoint = URL(string: "https://staging.netp.duckduckgo.com")!
static let subscriptionEndpoint = URL(string: "https://staging1.netp.duckduckgo.com")!
}

private enum DecoderError: Error {
Expand All @@ -95,6 +111,10 @@ public final class NetworkProtectionBackendClient: NetworkProtectionClient {
Constants.productionEndpoint.appending("/redeem")
}

var authorizeURL: URL {
Constants.subscriptionEndpoint.appending("/authorize")
}

private let decoder: JSONDecoder = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
Expand Down Expand Up @@ -185,16 +205,38 @@ public final class NetworkProtectionBackendClient: NetworkProtectionClient {
}
}

public func redeem(inviteCode: String) async -> Result<String, NetworkProtectionClientError> {
let requestBody = RedeemRequestBody(code: inviteCode)
public func authenticate(withMethod method: NetworkProtectionAuthenticationMethod) async -> Result<String, NetworkProtectionClientError> {
switch method {
case .inviteCode(let code):
return await redeem(inviteCode: code)
case .subscription(let accessToken):
return await exchange(accessToken: accessToken)
}
}

private func redeem(inviteCode: String) async -> Result<String, NetworkProtectionClientError> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this would be a bit simpler if we just exposed these functions instead of having the enum. I feel like it would be a bit less code. Not a blocker though.

let requestBody = RedeemInviteCodeRequestBody(code: inviteCode)
return await retrieveAuthToken(requestBody: requestBody, endpoint: redeemURL)
}

private func exchange(accessToken: String) async -> Result<String, NetworkProtectionClientError> {
let requestBody = ExchangeAccessTokenRequestBody(token: accessToken)
return await retrieveAuthToken(requestBody: requestBody, endpoint: authorizeURL)
}

private func retrieveAuthToken<RequestBody: Encodable>(
requestBody: RequestBody,
endpoint: URL
) async -> Result<String, NetworkProtectionClientError> {
let requestBodyData: Data

do {
requestBodyData = try JSONEncoder().encode(requestBody)
} catch {
return .failure(.failedToEncodeRedeemRequest)
}

var request = URLRequest(url: redeemURL)
var request = URLRequest(url: endpoint)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = requestBodyData
Expand All @@ -207,16 +249,25 @@ public final class NetworkProtectionBackendClient: NetworkProtectionClient {
return .failure(.failedToRedeemInviteCode(nil))
}
switch response.statusCode {
case 200: responseData = data
case 400: return .failure(.invalidInviteCode)
default: return .failure(.failedToRedeemInviteCode(nil))
case 200:
responseData = data
case 400:
return .failure(.invalidInviteCode)
default:
do {
// Try to redeem the subscription backend error response first:
let decodedRedemptionResponse = try decoder.decode(AuthenticationFailureResponse.self, from: data)
return .failure(.failedToRetrieveAuthToken(decodedRedemptionResponse))
} catch {
return .failure(.failedToRedeemInviteCode(nil))
}
}
} catch {
return .failure(NetworkProtectionClientError.failedToRedeemInviteCode(error))
}

do {
let decodedRedemptionResponse = try decoder.decode(RedeemResponse.self, from: responseData)
let decodedRedemptionResponse = try decoder.decode(AuthenticationSuccessResponse.self, from: responseData)
return .success(decodedRedemptionResponse.token)
} catch {
return .failure(NetworkProtectionClientError.failedToParseRedeemResponse(error))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,30 @@ import NetworkProtection

// swiftlint:disable line_length
public final class MockNetworkProtectionClient: NetworkProtectionClient {

public var spyRedeemInviteCode: String?
public var spyRedeemAccessToken: String?
public var stubRedeem: Result<String, NetworkProtection.NetworkProtectionClientError> = .success("")

public func redeem(inviteCode: String) async -> Result<String, NetworkProtection.NetworkProtectionClientError> {
spyRedeemInviteCode = inviteCode
return stubRedeem
public init(stubRedeem: Result<String, NetworkProtectionClientError> = .success(""),
stubGetServers: Result<[NetworkProtectionServer], NetworkProtectionClientError> = .success([]),
stubRegister: Result<[NetworkProtectionServer], NetworkProtectionClientError> = .success([])) {
self.stubRedeem = stubRedeem
self.stubGetServers = stubGetServers
self.stubRegister = stubRegister
}

public func authenticate(
withMethod method: NetworkProtection.NetworkProtectionAuthenticationMethod
) async -> Result<String, NetworkProtection.NetworkProtectionClientError> {
switch method {
case .inviteCode(let inviteCode):
spyRedeemInviteCode = inviteCode
return stubRedeem
case .subscription(let accessToken):
spyRedeemAccessToken = accessToken
return stubRedeem
}
}

public var spyGetServersAuthToken: String?
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ final class NetworkProtectionClientTests: XCTestCase {
MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!,
.success(successData))

let result = await client.redeem(inviteCode: "DH76F8S")
let result = await client.authenticate(withMethod: .inviteCode("DH76F8S"))

XCTAssertEqual(try? result.get(), token)
}
Expand All @@ -94,7 +94,7 @@ final class NetworkProtectionClientTests: XCTestCase {
MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 400)!,
.success(emptyData))

let result = await client.redeem(inviteCode: "DH76F8S")
let result = await client.authenticate(withMethod: .inviteCode("DH76F8S"))

guard case .failure(let error) = result, case .invalidInviteCode = error else {
XCTFail("Expected an invalidInviteCode error to be thrown")
Expand All @@ -110,7 +110,7 @@ final class NetworkProtectionClientTests: XCTestCase {
MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: code)!,
.success(emptyData))

let result = await client.redeem(inviteCode: "DH76F8S")
let result = await client.authenticate(withMethod: .inviteCode("DH76F8S"))

guard case .failure(let error) = result, case .failedToRedeemInviteCode = error else {
XCTFail("Expected a failedToRedeemInviteCode error to be thrown")
Expand All @@ -125,7 +125,7 @@ final class NetworkProtectionClientTests: XCTestCase {
MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!,
.success(undecodableData))

let result = await client.redeem(inviteCode: "DH76F8S")
let result = await client.authenticate(withMethod: .inviteCode("DH76F8S"))

guard case .failure(let error) = result, case .failedToParseRedeemResponse = error else {
XCTFail("Expected a failedToRedeemInviteCode error to be thrown")
Expand Down
Loading