CVE-2020-15679 (published 2022-12-22T20:15Z)

An OAuth session fixation vulnerability existed in the VPN login flow, where an attacker could craft a custom login URL, convince a VPN user to login via that URL, and obtain authenticated access as that user. This issue is limited to cases where attacker and victim are sharing the same source IP and could allow the ability to view session states and disconnect VPN sessions. This vulnerability affects Mozilla VPN iOS 1.0.7 < (929), Mozilla VPN Windows < 1.2.2, and Mozilla VPN Android 1.1.0 < (1360).

CVSS v3 base score: 7.6, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:H)

Weakness information:
cwe_id: CWE-384
cwe_name: Session Fixation
description: Authenticating a user, or otherwise establishing a new user session, without invalidating any existing session identifier gives an attacker the opportunity to steal authenticated sessions.
extended_description: 
            Such a scenario is commonly observed when:
            
               A web application authenticates a user without first invalidating the existing session, thereby continuing to use the session already associated with the user.
               An attacker is able to force a known session identifier on a user so that, once the user authenticates, the attacker has access to the authenticated session.
               The application or container uses predictable session identifiers.
	    
	    In the generic exploit of session fixation vulnerabilities, an attacker creates a new session on a web application and records the associated session identifier. The attacker then causes the victim to associate, and possibly authenticate, against the server using that session identifier, giving the attacker access to the user's account through the active session.
         
         

Fix information:
repo_url: https://github.com/mozilla-mobile/guardian-vpn-ios
commit_details: repo_url: https://github.com/mozilla-mobile/guardian-vpn-ios
author: Celeste
author_date: 2020-09-24 16:52:48+08:00
msg: PGAND-410 Resolve oauth session fixation on iOS (#272)

* update login flow with PKCE

* fix MockGuardianAPI
repository: repo_url: https://github.com/mozilla-mobile/guardian-vpn-ios
repo_name: mozilla-mobile/guardian-vpn-ios
description: Mozilla VPN for iOS
repo_language: Swift
owner: mozilla-mobile
file_changes: filename: project.pbxproj
change_type: ModificationType.MODIFY
diff: @@ -244,6 +244,8 @@
 		D54700E82491D90C00FA40A2 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54700E72491D90C00FA40A2 /* NotificationViewController.swift */; };
 		D54700EB2491D90C00FA40A2 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D54700E92491D90C00FA40A2 /* MainInterface.storyboard */; };
 		D54700EF2491D90C00FA40A2 /* NotificationContentExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D54700E12491D90C00FA40A2 /* NotificationContentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+		D54D31DD2519F33500D6ED57 /* PKCECodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54D31DC2519F33500D6ED57 /* PKCECodeGenerator.swift */; };
+		D54D31DE2519F33500D6ED57 /* PKCECodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54D31DC2519F33500D6ED57 /* PKCECodeGenerator.swift */; };
 		D5D349B3249A73FB00DD9F50 /* error_noSignal.png in Resources */ = {isa = PBXBuildFile; fileRef = D5D349B1249A73FA00DD9F50 /* error_noSignal.png */; };
 		D5D349B4249A73FB00DD9F50 /* error_unstable.png in Resources */ = {isa = PBXBuildFile; fileRef = D5D349B2249A73FA00DD9F50 /* error_unstable.png */; };
 		D5D349B6249B703F00DD9F50 /* AppExtensionUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D349B5249B703F00DD9F50 /* AppExtensionUserDefaults.swift */; };
@@ -593,6 +595,7 @@
 		D54700E72491D90C00FA40A2 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; };
 		D54700EA2491D90C00FA40A2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
 		D54700EC2491D90C00FA40A2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		D54D31DC2519F33500D6ED57 /* PKCECodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCECodeGenerator.swift; sourceTree = "<group>"; };
 		D5D349B1249A73FA00DD9F50 /* error_noSignal.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = error_noSignal.png; sourceTree = "<group>"; };
 		D5D349B2249A73FA00DD9F50 /* error_unstable.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = error_unstable.png; sourceTree = "<group>"; };
 		D5D349B5249B703F00DD9F50 /* AppExtensionUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExtensionUserDefaults.swift; sourceTree = "<group>"; };
@@ -1134,6 +1137,7 @@
 				F4056031239E9F31000A93CC /* KeychainStored.swift */,
 				F43DFC0A234D0B5C003EA64A /* TunnelConfigurationBuilder.swift */,
 				F4108050242CDAB300973421 /* NetworkingUtilities.swift */,
+				D54D31DC2519F33500D6ED57 /* PKCECodeGenerator.swift */,
 			);
 			path = Utilities;
 			sourceTree = "<group>";
@@ -2256,6 +2260,7 @@
 				F4E908D32371CAAD00EB9FE6 /* NavigationCoordinating.swift in Sources */,
 				F4E909092371CAAD00EB9FE6 /* AccountInformationCell.swift in Sources */,
 				F42DE7C82395A64A000B79C3 /* CarouselViewController.swift in Sources */,
+				D54D31DD2519F33500D6ED57 /* PKCECodeGenerator.swift in Sources */,
 				F45D1D1023873B8A0084B5DE /* CarouselPageViewController.swift in Sources */,
 				F4A7FA24237C5C960034AF6C /* Account.swift in Sources */,
 				F4E908D52371CAAD00EB9FE6 /* TunnelManaging.swift in Sources */,
@@ -2404,6 +2409,7 @@
 				D541182F24C8358500628DF6 /* NavigationCoordinating.swift in Sources */,
 				D541183024C8358500628DF6 /* AccountInformationCell.swift in Sources */,
 				D541183124C8358500628DF6 /* CarouselViewController.swift in Sources */,
+				D54D31DE2519F33500D6ED57 /* PKCECodeGenerator.swift in Sources */,
 				D541183224C8358500628DF6 /* CarouselPageViewController.swift in Sources */,
 				D541183324C8358500628DF6 /* Account.swift in Sources */,
 				D541183424C8358500628DF6 /* TunnelManaging.swift in Sources */,

programming_language: Scala
filename: AppDelegate.swift
change_type: ModificationType.MODIFY
diff: @@ -65,6 +65,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         UIDevice.current.userInterfaceIdiom == .pad ? .all : [.portrait, .portraitUpsideDown]
     }
 
+    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
+        if let sourceApplication = options[.sourceApplication] as? String,
+            sourceApplication == "com.apple.SafariViewService" {
+           NotificationCenter.default.post(name: .callbackURLNotification, object: nil,
+                                           userInfo: ["callbackURL": url])
+           return true
+        }
+        return false
+    }
+
     func applicationDidEnterBackground(_ application: UIApplication) {
         dependencyManager?.connectionHealthMonitor.stop()
         dependencyManager?.heartbeatMonitor.stop()

programming_language: Swift
filename: GuardianAPI.swift
change_type: ModificationType.MODIFY
diff: @@ -20,15 +20,6 @@ class GuardianAPI: NetworkRequesting {
     }
 
     // MARK: -
-    func initiateUserLogin(completion: @escaping (Result<LoginCheckpointModel, GuardianAPIError>) -> Void) {
-        let urlRequest = GuardianURLRequest.urlRequest(request: .login, type: .POST)
-        networkLayer.fire(urlRequest: urlRequest) { result in
-            DispatchQueue.main.async {
-                completion(result.decode(to: LoginCheckpointModel.self))
-            }
-        }
-    }
-
     func accountInfo(token: String, completion: @escaping (Result<User, GuardianAPIError>) -> Void) {
         let urlRequest = GuardianURLRequest.urlRequest(request: .account, type: .GET, httpHeaderParams: headers(with: token))
         networkLayer.fire(urlRequest: urlRequest) { result in
@@ -38,8 +29,14 @@ class GuardianAPI: NetworkRequesting {
         }
     }
 
-    func verify(urlString: String, completion: @escaping (Result<VerifyResponse, GuardianAPIError>) -> Void) {
-        let urlRequest = GuardianURLRequest.urlRequest(with: urlString, type: .GET)
+    func verify(code: String, codeVerifier: String, completion: @escaping (Result<VerifyResponse, GuardianAPIError>) -> Void) {
+        let body: [String: Any] = ["code": code, "code_verifier": codeVerifier]
+        guard let data = try? JSONSerialization.data(withJSONObject: body) else {
+            completion(.failure(.couldNotEncodeData))
+            return
+        }
+
+        let urlRequest = GuardianURLRequest.urlRequest(request: .verify, type: .POST, httpHeaderParams: headers(), body: data)
         networkLayer.fire(urlRequest: urlRequest) { result in
             DispatchQueue.main.async {
                 completion(result.decode(to: VerifyResponse.self))
@@ -106,9 +103,12 @@ class GuardianAPI: NetworkRequesting {
     }
 
     // MARK: - Utils
-    private func headers(with token: String) -> [String: String] {
-        return ["Authorization": "Bearer \(token)",
-            "Content-Type": "application/json",
-            "User-Agent": userAgentInfo]
+    private func headers(with token: String = "") -> [String: String] {
+        var headers = ["Content-Type": "application/json",
+                       "User-Agent": userAgentInfo]
+        if !token.isEmpty {
+            headers["Authorization"] = "Bearer \(token)"
+        }
+        return headers
     }
 }

programming_language: Swift
filename: GuardianURLRequest.swift
change_type: ModificationType.MODIFY
diff: @@ -31,18 +31,13 @@ struct GuardianURLRequest {
         return urlRequest(with: urlString, type: type, queryParameters: queryParameters, httpHeaderParams: httpHeaderParams, body: body)
     }
 
-    static func urlRequest(with urlString: String,
-                           type: HttpMethod,
-                           queryParameters: [String: String]? = nil,
-                           httpHeaderParams: [String: String]? = nil,
-                           body: Data? = nil) -> URLRequest {
-        var urlComponent = URLComponents(string: urlString)!
-        if let queryParameters = queryParameters {
-            let queryItems = queryParameters.map { URLQueryItem(name: $0.key, value: $0.value) }
-            urlComponent.queryItems = queryItems
-        }
-
-        var urlRequest = URLRequest(url: urlComponent.url!)
+    static private func urlRequest(with urlString: String,
+                                   type: HttpMethod,
+                                   queryParameters: [String: String]? = nil,
+                                   httpHeaderParams: [String: String]? = nil,
+                                   body: Data? = nil) -> URLRequest {
+        let url = generateURL(urlString: urlString, queryParameters: queryParameters)
+        var urlRequest = URLRequest(url: url!)
         if let httpHeaderParams = httpHeaderParams {
             httpHeaderParams.forEach {
                 urlRequest.setValue($0.value, forHTTPHeaderField: $0.key)
@@ -57,4 +52,20 @@ struct GuardianURLRequest {
 
         return urlRequest
     }
+
+    static private func generateURL(urlString: String, queryParameters: [String: String]?) -> URL? {
+        var urlComponent = URLComponents(string: urlString)!
+        if let queryParameters = queryParameters {
+            let queryItems = queryParameters.map { URLQueryItem(name: $0.key, value: $0.value) }
+            urlComponent.queryItems = queryItems
+        }
+
+        return urlComponent.url
+    }
+
+    static func pkceLoginURL(codeChallenge: String) -> URL {
+        let urlString = "\(GuardianURLRequest.baseURL)\(GuardianURLRequestPath.login.endpoint)"
+        let queryParameters = ["code_challenge": codeChallenge, "code_challenge_method": "S256"]
+        return generateURL(urlString: urlString, queryParameters: queryParameters)!
+    }
 }

programming_language: Swift
filename: GuardianURLRequestPath.swift
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,7 @@
 
 enum GuardianURLRequestPath {
     case login
-    case verify(String)
+    case verify
     case retrieveServers
     case account
     case addDevice
@@ -20,11 +20,12 @@ enum GuardianURLRequestPath {
 
     var endpoint: String {
         let prefix = "api/v1/vpn/"
+        let v2Prefix = "api/v2/vpn/"
         switch self {
         case .login:
-            return prefix + "login"
-        case .verify(let token):
-            return prefix + "login/verify/" + token
+            return v2Prefix + "login/ios"
+        case .verify:
+            return v2Prefix + "login/verify"
         case .retrieveServers:
             return prefix + "servers"
         case .account:

programming_language: Swift
filename: Bridging-Header.h
change_type: ModificationType.MODIFY
diff: @@ -18,5 +18,6 @@
 #include "ringlogger.h"
 #include "key.h"
 #import "SimplePing.h"
+#import "CommonCrypto/CommonCrypto.h"
 
 #endif /* Guardian_Bridging_Header_h */

programming_language: Objective-C
filename: Info.plist
change_type: ModificationType.MODIFY
diff: @@ -70,6 +70,19 @@
 	<string>APPL</string>
 	<key>CFBundleShortVersionString</key>
 	<string>$(MARKETING_VERSION)</string>
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleTypeRole</key>
+			<string>Editor</string>
+			<key>CFBundleURLName</key>
+			<string>org.mozilla.ios.FirefoxVPN</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>mozilla-vpn</string>
+			</array>
+		</dict>
+	</array>
 	<key>CFBundleVersion</key>
 	<string>$(CURRENT_PROJECT_VERSION)</string>
 	<key>ITSAppUsesNonExemptEncryption</key>

programming_language: HTML
filename: NetworkRequesting.swift
change_type: ModificationType.MODIFY
diff: @@ -12,9 +12,8 @@
 import Foundation
 
 protocol NetworkRequesting {
-    func initiateUserLogin(completion: @escaping (Result<LoginCheckpointModel, GuardianAPIError>) -> Void)
     func accountInfo(token: String, completion: @escaping (Result<User, GuardianAPIError>) -> Void)
-    func verify(urlString: String, completion: @escaping (Result<VerifyResponse, GuardianAPIError>) -> Void)
+    func verify(code: String, codeVerifier: String, completion: @escaping (Result<VerifyResponse, GuardianAPIError>) -> Void)
     func availableServers(with token: String, completion: @escaping (Result<[VPNCountry], GuardianAPIError>) -> Void)
     func addDevice(with token: String, body: [String: Any], completion: @escaping (Result<Device, GuardianAPIError>) -> Void)
     func removeDevice(with token: String, deviceKey: String, completion: @escaping (Result<Void, GuardianAPIError>) -> Void)

programming_language: Swift
filename: PKCECodeGenerator.swift
change_type: ModificationType.ADD
diff: @@ -0,0 +1,42 @@
+//
+//  PKCECodeGenerator
+//  FirefoxPrivateNetworkVPN
+//
+//  This Source Code Form is subject to the terms of the Mozilla Public
+//  License, v. 2.0. If a copy of the MPL was not distributed with this
+//  file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+//  Copyright © 2020 Mozilla Corporation.
+//
+
+import Foundation
+
+struct PKCECodeGenerator {
+    static var generateCode: (codeChallenge: String, codeVerifier: String) {
+        let codeVerifier = generateCodeVerifier()
+        let codeChallenge = base64UrlEncode(sha256(string: codeVerifier))
+        return (codeChallenge, codeVerifier)
+    }
+
+    private static func generateCodeVerifier() -> String {
+        var buffer = [UInt8](repeating: 0, count: 32)
+        _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
+        return base64UrlEncode(Data(buffer))
+    }
+
+    private static func base64UrlEncode(_ data: Data?) -> String {
+        guard let data = data else { return "" }
+        return data.base64EncodedString()
+                   .replacingOccurrences(of: "+", with: "-")
+                   .replacingOccurrences(of: "/", with: "_")
+    }
+
+    private static func sha256(string: String) -> Data? {
+        guard let data = string.data(using: .utf8) else { return nil }
+        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
+        data.withUnsafeBytes {
+            _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
+        }
+        return Data(hash)
+    }
+}

programming_language: Swift
filename: LoginViewController.swift
change_type: ModificationType.MODIFY
diff: @@ -12,88 +12,81 @@
 import UIKit
 import SafariServices
 
+extension Notification.Name {
+    static let callbackURLNotification = Notification.Name("callbackURL")
+}
+
 class LoginViewController: UIViewController, Navigating {
     static var navigableItem: NavigableItem = .login
 
     private let guardianAPI = DependencyManager.shared.guardianAPI
     private var safariViewController: SFSafariViewController?
-    private var verificationURL: URL?
-    private var verifyTimer: Timer?
-    private var isVerifying = false
+    private let accountManager = DependencyManager.shared.accountManager
+    private let PKCECode: (String, String) = PKCECodeGenerator.generateCode
 
     init() {
         super.init(nibName: nil, bundle: nil)
-        guardianAPI.initiateUserLogin { [weak self] result in
-            switch result {
-            case .success(let checkpointModel):
-                guard let loginURL = checkpointModel.loginUrl else { return }
-                self?.verificationURL = checkpointModel.verificationUrl
-                let safariViewController = SFSafariViewController(url: loginURL)
-                DispatchQueue.main.async {
-                    self?.addChild(safariViewController)
-                    self?.view.addSubview(safariViewController.view)
-                    safariViewController.view.frame = self?.view.bounds ?? CGRect.zero
-                    safariViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
-                    safariViewController.didMove(toParent: self)
-                }
-                safariViewController.delegate = self
-                self?.safariViewController = safariViewController
-            case .failure(let error):
-                let loginError = error.getLoginError()
-                let context: NavigableContext = loginError == .maxDevicesReached ? .maxDevicesReached : .error(loginError)
-                self?.navigate(to: .landing, context: context)
-            }
-        }
-    }
-
-    deinit {
-        verifyTimer?.invalidate()
     }
 
     required init?(coder: NSCoder) {
         fatalError("init(coder:) has not been implemented")
     }
 
-    @objc private func verify() {
-        guard let verificationURL = verificationURL else { return }
-        if isVerifying { return }
-        isVerifying = true
+    override func viewDidLoad() {
+        super.viewDidLoad()
 
-        guardianAPI.verify(urlString: verificationURL.absoluteString) { [weak self] result in
-            guard let self = self else { return }
+        let safariViewController = SFSafariViewController(url: GuardianURLRequest.pkceLoginURL(codeChallenge: PKCECode.0))
+        addChild(safariViewController)
+        view.addSubview(safariViewController.view)
+        safariViewController.view.frame = self.view.bounds
+        safariViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+        safariViewController.didMove(toParent: self)
+        safariViewController.delegate = self
+        self.safariViewController = safariViewController
+
+        NotificationCenter.default.addObserver(self, selector: #selector(handleCallback), name: .callbackURLNotification, object: nil)
+    }
 
+    @objc private func handleCallback(notification: Notification) {
+        guard let url = notification.userInfo?["callbackURL"] as? URL,
+            let queryItems = URLComponents(string: url.absoluteString)?.queryItems,
+            let code = queryItems.first(where: { $0.name == "code" })?.value else {
+            navigate(to: .landing)
+            return
+        }
+        verify(code: code)
+    }
+
+    private func verify(code: String) {
+        guardianAPI.verify(code: code, codeVerifier: PKCECode.1) { [weak self] result in
+            guard let self = self else { return }
             switch result {
             case .success(let verification):
-                DependencyManager.shared.accountManager.login(with: verification) { loginResult in
-                    self.isVerifying = false
-                    self.verifyTimer?.invalidate()
-                    switch loginResult {
-                    case .success:
-                        self.navigate(to: .home)
-                    case .failure(let error):
-                        Logger.global?.log(message: "Authentication Error: \(error)")
-                        let context: NavigableContext = error == .maxDevicesReached ? .maxDevicesReached : .error(error)
-                        self.navigate(to: .landing, context: context)
-                    }
-                }
-            case .failure:
-                self.isVerifying = false
-                return
+                self.login(verification: verification)
+            case .failure(let error):
+                self.navigate(to: .landing, context: .error(error))
             }
         }
     }
-}
 
-// MARK: - SFSafariViewControllerDelegate
-extension LoginViewController: SFSafariViewControllerDelegate {
-    func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {
-        if didLoadSuccessfully && verifyTimer == nil {
-            verifyTimer = Timer.scheduledTimer(timeInterval: 3, target: self, selector: #selector(verify), userInfo: nil, repeats: true)
+    private func login(verification: VerifyResponse) {
+        accountManager.login(with: verification) { [weak self] loginResult in
+            guard let self = self else { return }
+            switch loginResult {
+            case .success:
+                self.navigate(to: .home)
+            case .failure(let error):
+                Logger.global?.log(message: "Authentication Error: \(error)")
+                let context: NavigableContext = error == .maxDevicesReached ? .maxDevicesReached : .error(error)
+                self.navigate(to: .landing, context: context)
+            }
         }
     }
+}
 
+// MARK: - SFSafariViewControllerDelegate
+extension LoginViewController: SFSafariViewControllerDelegate {
     func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
-        self.verifyTimer?.invalidate()
         navigate(to: .landing)
     }
 }

programming_language: Swift
filename: MockGuardianAPI.swift
change_type: ModificationType.MODIFY
diff: @@ -17,14 +17,6 @@ class MockGuardianAPI: NetworkRequesting {
 
     var apiCallObservable = PublishSubject<Void>()
 
-    func initiateUserLogin(completion: @escaping (Result<LoginCheckpointModel, GuardianAPIError>) -> Void) {
-        apiCallObservable.onNext(())
-        guard let jsonUserURL = Bundle.main.url(forResource: "loginCheckpointModel", withExtension: "json"),
-            let loginCheckpointModel = try? JSONDecoder().decode(LoginCheckpointModel.self, from: Data(contentsOf: jsonUserURL))
-            else { return }
-        completion(Result.success(loginCheckpointModel))
-    }
-
     func accountInfo(token: String, completion: @escaping (Result<User, GuardianAPIError>) -> Void) {
         apiCallObservable.onNext(())
         guard let jsonUserURL = Bundle.main.url(forResource: "user", withExtension: "json"),
@@ -33,7 +25,7 @@ class MockGuardianAPI: NetworkRequesting {
         completion(Result.success(user))
     }
 
-    func verify(urlString: String, completion: @escaping (Result<VerifyResponse, GuardianAPIError>) -> Void) {
+    func verify(code: String, codeVerifier: String, completion: @escaping (Result<VerifyResponse, GuardianAPIError>) -> Void) {
         apiCallObservable.onNext(())
         guard let jsonUserURL = Bundle.main.url(forResource: "verifyResponse", withExtension: "json"),
             let verifyResponse = try? JSONDecoder().decode(VerifyResponse.self, from: Data(contentsOf: jsonUserURL))

programming_language: Swift
repo_url: https://github.com/mozilla-services/guardian-vpn-windows
commit_details: repo_url: https://github.com/mozilla-services/guardian-vpn-windows
author: flippedcoder
author_date: 2020-09-17 19:40:27-05:00
msg: New non-polling authentication flow.

- Fixes intermittent hanging during auth
- Implements PKCE
repository: repo_url: https://github.com/mozilla-services/guardian-vpn-windows
repo_name: mozilla-services/guardian-vpn-windows-deprecated
description: Mozilla VPN for Windows
repo_language: C#
owner: mozilla-services
file_changes: filename: MozillaVPN.wxs
change_type: ModificationType.MODIFY
diff: @@ -86,6 +86,18 @@
 				<File Source="../ui/bin/$(var.Platform)/Release/Resources/Graphics/icon_70x70.png" />
 				<File Source="../ui/bin/$(var.Platform)/Release/Resources/Graphics/icon_150x150.png" />
 
+        <!-- Custom URL protocol for handling the PKCE auth flow -->
+        <RegistryKey Root="HKCR" Key="mozilla-vpn">
+          <RegistryValue Type="string" Name="URL Protocol" Value="mozilla-vpn"/>
+          <RegistryValue Type="string" Value="URL:mozilla-vpn Protocol"/>
+          <RegistryKey Key="DefaultIcon">
+            <RegistryValue Type="string" Value="MozillaVPN.exe" />
+          </RegistryKey>
+          <RegistryKey Key="shell\open\command">
+            <RegistryValue Type="string" Value="&quot;C:\Program Files\Mozilla\Mozilla VPN\MozillaVPN.exe&quot; &quot;%1&quot;" />
+          </RegistryKey>
+        </RegistryKey>
+
 				<!-- Broker Service -->
 				<ServiceInstall
 					Type="ownProcess"
@@ -112,7 +124,7 @@
 			</Component>
 		</ComponentGroup>
 
-		<!--
+    <!--
 			Merge modules
 		-->
 		<DirectoryRef Id="MozillaVPNFolder">

programming_language: Shell
filename: Firefox Private Network.csproj
change_type: ModificationType.MODIFY
diff: @@ -225,6 +225,12 @@
       <EmbedInteropTypes>False</EmbedInteropTypes>
       <Private>True</Private>
     </Reference>
+    <Reference Include="Microsoft.IdentityModel.Logging, Version=6.7.1.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
+      <HintPath>..\packages\Microsoft.IdentityModel.Logging.6.7.1\lib\net461\Microsoft.IdentityModel.Logging.dll</HintPath>
+    </Reference>
+    <Reference Include="Microsoft.IdentityModel.Tokens, Version=6.7.1.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
+      <HintPath>..\packages\Microsoft.IdentityModel.Tokens.6.7.1\lib\net461\Microsoft.IdentityModel.Tokens.dll</HintPath>
+    </Reference>
     <Reference Include="Microsoft.WindowsAPICodePack, Version=1.1.0.0, Culture=neutral, processorArchitecture=MSIL">
       <HintPath>..\packages\Microsoft.WindowsAPICodePack-Core.1.1.0.2\lib\Microsoft.WindowsAPICodePack.dll</HintPath>
     </Reference>

programming_language: Objective-C
filename: Account.cs
change_type: ModificationType.MODIFY
diff: @@ -50,6 +50,11 @@ namespace FirefoxPrivateNetwork.FxA
             }
         }
 
+        /// <summary>
+        /// Gets or sets the API version for making non-PKCE auth requests and handling responses.
+        /// </summary>
+        public static string OriginalApiVersion { get; set; } = "/api/v1";
+
         /// <summary>
         /// Gets or sets the config structure associated with this account.
         /// </summary>
@@ -73,7 +78,7 @@ namespace FirefoxPrivateNetwork.FxA
         public bool ProcessLogin(string fxaJson)
         {
             // Initialize a new login session configuration
-            Manager.Account.Config = new FxA.Config(fxaJson);
+            Manager.Account.Config = new Config(fxaJson);
 
             // Sets a value that a user has just logged in
             Manager.MainWindowViewModel.NewUserSignIn = true;
@@ -87,14 +92,14 @@ namespace FirefoxPrivateNetwork.FxA
             Manager.Account.Config.WriteFxAUserToFile(ProductConstants.FxAUserFile);
 
             // Set the account login state to logged in
-            Manager.Account.LoginState = FxA.LoginState.LoggedIn;
+            Manager.Account.LoginState = LoginState.LoggedIn;
 
             // Initialize cache for avatar image
             Manager.Account.Avatar.InitializeCache(avatarUrl: Config.FxALogin.User.Avatar);
 
             // Added a new account device through the FxA API, using the newly generated keypair
-            var devices = new FxA.Devices();
-            var deviceName = string.Format("{0} ({1} {2})", System.Environment.MachineName, System.Environment.OSVersion.Platform, System.Environment.OSVersion.Version);
+            var devices = new Devices();
+            var deviceName = string.Format("{0} ({1} {2})", Environment.MachineName, Environment.OSVersion.Platform, Environment.OSVersion.Version);
             var deviceAddResponse = devices.AddDevice(deviceName, keys.Public);
 
             // Upon successful addition of a new device, save the device interface to the WireGuard configuration file and IP addresses to settings file
@@ -159,7 +164,7 @@ namespace FirefoxPrivateNetwork.FxA
         /// <returns>FxA user JSON object.</returns>
         public JSONStructures.User GetAccountDetails()
         {
-            var api = new ApiRequest(Manager.Account.Config.FxALogin.Token, "/vpn/account", RestSharp.Method.GET);
+            var api = new ApiRequest(Manager.Account.Config.FxALogin.Token, $"{ProductConstants.BaseUrl}{OriginalApiVersion}/vpn/account", RestSharp.Method.GET);
 
             // Execute the request
             var response = api.SendRequest();

programming_language: C#
filename: Devices.cs
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.IO;
 using System.Threading.Tasks;
 using Newtonsoft.Json;
 
@@ -22,7 +23,7 @@ namespace FirefoxPrivateNetwork.FxA
         /// <returns>Returns the user's device if successful, otherwise null.</returns>
         public JSONStructures.Device AddDevice(string deviceName, string publicKey)
         {
-            var api = new ApiRequest(Manager.Account.Config.FxALogin.Token, "/vpn/device", RestSharp.Method.POST);
+            var api = new ApiRequest(Manager.Account.Config.FxALogin.Token, $"{ProductConstants.BaseUrl}{Account.OriginalApiVersion}/vpn/device", RestSharp.Method.POST);
             api.AddPostBody(new Dictionary<string, string> { { "name", deviceName }, { "pubkey", publicKey } });
 
             // Execute the request
@@ -71,7 +72,7 @@ namespace FirefoxPrivateNetwork.FxA
 
             // Url encoding the device public key
             var sanitizedDevicePublicKey = System.Web.HttpUtility.UrlEncode(publicKey);
-            var api = new ApiRequest(Manager.Account.Config.FxALogin.Token, "/vpn/device/" + sanitizedDevicePublicKey, RestSharp.Method.DELETE);
+            var api = new ApiRequest(Manager.Account.Config.FxALogin.Token, $"{ProductConstants.BaseUrl}{Account.OriginalApiVersion}/vpn/device/" + sanitizedDevicePublicKey, RestSharp.Method.DELETE);
 
             // Execute the request`
             var response = api.SendRequest();

programming_language: C#
filename: Login.cs
change_type: ModificationType.MODIFY
diff: @@ -3,11 +3,19 @@
 // </copyright>
 
 using System;
+using System.Collections.Generic;
 using System.Diagnostics;
+using System.IO;
 using System.Linq;
+using System.Security.Cryptography;
+using System.Security.Permissions;
+using System.Text;
 using System.Threading;
+using System.Web;
 using System.Windows;
+using Microsoft.IdentityModel.Tokens;
 using Newtonsoft.Json;
+using RestSharp.Extensions;
 
 namespace FirefoxPrivateNetwork.FxA
 {
@@ -16,6 +24,9 @@ namespace FirefoxPrivateNetwork.FxA
     /// </summary>
     public class Login
     {
+        private static string codeVerifierPath = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
+        private static string codeVerifierFile = "moz_gen_cvf.txt";
+
         /// <summary>
         /// Initializes a new instance of the <see cref="Login"/> class.
         /// </summary>
@@ -36,68 +47,35 @@ namespace FirefoxPrivateNetwork.FxA
         private event LoginResultHandler LoginResultEvent;
 
         /// <summary>
-        /// Gets the unique login urls for the user's signin attempt.
+        /// Gets or sets the API version for making PKCE auth requests and handling responses.
         /// </summary>
-        /// <returns>A <see cref="JSONStructures.FxALoginURLs"/> object.</returns>
-        public JSONStructures.FxALoginURLs GetLoginURLs()
-        {
-            var api = new ApiRequest(string.Empty, "/vpn/login", RestSharp.Method.POST);
-            JSONStructures.FxALoginURLs loginURLs;
-
-            // Execute the request
-            var response = api.SendRequest();
-
-            if (response == null || response.StatusCode != System.Net.HttpStatusCode.OK)
-            {
-                ErrorHandling.ErrorHandler.Handle(new ErrorHandling.UserFacingMessage("toast-login-url-retrieval-error"), ErrorHandling.UserFacingErrorType.None, ErrorHandling.LogLevel.Error);
-                return null;
-            }
-
-            try
-            {
-                loginURLs = JsonConvert.DeserializeObject<JSONStructures.FxALoginURLs>(response.Content);
-                return loginURLs;
-            }
-            catch (Exception e)
-            {
-                ErrorHandling.ErrorHandler.Handle(e, ErrorHandling.LogLevel.Error);
-            }
-
-            return null;
-        }
+        public static string ApiVersion { get; set; } = "/api/v2";
 
         /// <summary>
-        /// Queries the verification URL to check if the user has logged in.
+        /// Gets the unique login urls for the user's signin attempt.
         /// </summary>
-        /// <param name="tokenURL">Login verification URL.</param>
-        /// <returns>Returns the response content if successful, otherwise returns null.</returns>
-        public string QueryRawLoginState(string tokenURL)
+        /// <returns>A <see cref="JSONStructures.FxALoginURLs"/> object.</returns>
+        public JSONStructures.FxALoginURLs GetLoginURL()
         {
-            var api = new ApiRequest(string.Empty, tokenURL, RestSharp.Method.GET);
+            // Generate code_verifier
+            string codeVerifier = GetUniqueToken(44);
 
-            // Execute the request
-            var response = api.SendRequest();
+            // Save the code_verifier for use in the verify request
+            File.WriteAllText(Path.Combine(codeVerifierPath, codeVerifierFile), codeVerifier);
 
-            if (response == null || response.StatusCode != System.Net.HttpStatusCode.OK)
-            {
-                ErrorHandling.ErrorHandler.Handle("User has not logged in yet", ErrorHandling.LogLevel.Debug);
-                return null;
-            }
+            // Generate the code_challenge by getting the SHA256 hash and encoding it to send in a request
+            // Switched the code_challenge_method to plain since there were intermittent issues with the SHA256 method returning the user data each time.
+            string codeChallenge = codeVerifier;
 
-            return response.Content;
-        }
+            // Make new instance of object that hold the URLs needed for a user to login
+            JSONStructures.FxALoginURLs loginURLs = new JSONStructures.FxALoginURLs();
 
-        /// <summary>
-        /// Deserializes the FxA login response from JSON.
-        /// </summary>
-        /// <param name="jsonContents">FxA login response.</param>
-        /// <returns>A <see cref="JSONStructures.FxALogin"/> object.</returns>
-        public JSONStructures.FxALogin ParseLoginState(string jsonContents)
-        {
             try
             {
-                var loginData = JsonConvert.DeserializeObject<JSONStructures.FxALogin>(jsonContents);
-                return loginData;
+                // The codeChallenge is there to handle the PKCE request to the server
+                loginURLs.LoginUrl = $"{ProductConstants.BaseUrl}{ApiVersion}/vpn/login/windows?code_challenge={codeChallenge}&code_challenge_method=plain";
+
+                return loginURLs;
             }
             catch (Exception e)
             {
@@ -128,28 +106,35 @@ namespace FirefoxPrivateNetwork.FxA
         /// <summary>
         /// Initiate the login attempt.
         /// </summary>
-        /// <param name="cancelToken">Token used to cancel the login process.</param>
         /// <returns>Whether the login process is started succefully.</returns>
-        public bool StartLogin(CancellationToken cancelToken)
+        public bool StartLogin()
         {
             try
             {
-                var loginURLs = GetLoginURLs();
-                if (loginURLs == null)
+                // Get the login and verify URLs
+                JSONStructures.FxALoginURLs loginURL = GetLoginURL();
+
+                if (loginURL == null)
                 {
                     return false;
                 }
 
-                var pollInterval = loginURLs.PollInterval % 31; // Max 30 seconds, no more
-                Manager.Account.LoginState = FxA.LoginState.LoggingIn;
-                StartQueryLoginThread(loginURLs.VerificationUrl, loginURLs.PollInterval, loginURLs.ExpiresOn, cancelToken);
+                // Create the GET request for PKCE auth
+                ApiRequest api = new ApiRequest(string.Empty, loginURL.LoginUrl, RestSharp.Method.GET);
 
-                // Launch a browser
-                OpenBrowser(loginURLs.LoginUrl);
+                // Execute the request
+                var response = api.SendRequest();
 
-                // Navigate to verification page
-                UI.MainWindow mainWindow = (UI.MainWindow)Application.Current.MainWindow;
-                mainWindow.NavigateToView(new FirefoxPrivateNetwork.UI.VerifyAccountView(loginURLs.LoginUrl), UI.MainWindow.SlideDirection.Left);
+                if (response == null || response.StatusCode != System.Net.HttpStatusCode.OK)
+                {
+                    ErrorHandling.ErrorHandler.Handle(new ErrorHandling.UserFacingMessage("toast-login-url-retrieval-error"), ErrorHandling.UserFacingErrorType.None, ErrorHandling.LogLevel.Error);
+                    return false;
+                }
+
+                Manager.Account.LoginState = LoginState.LoggingIn;
+
+                // Launch a browser
+                OpenBrowser(loginURL.LoginUrl);
             }
             catch (Exception e)
             {
@@ -162,100 +147,98 @@ namespace FirefoxPrivateNetwork.FxA
         }
 
         /// <summary>
-        /// Intiates the login verification thread.
+        /// Gets verification code from mozilla-vpn:// redirect after the user logs in through the browser.
         /// </summary>
-        /// <param name="queryUri">Login verification URL.</param>
-        /// <param name="timeoutSeconds">Timeout (secs) between verification query attempts.</param>
-        /// <param name="expiresAt">Expiration date of the verification URL.</param>
-        /// <param name="cancelToken">Token used to cancel the login thread.</param>
-        public void StartQueryLoginThread(string queryUri, int timeoutSeconds, DateTime expiresAt, CancellationToken cancelToken)
+        /// <param name="code">Verification code from the custom URL protocol redirect after logging in.</param>
+        public void VerifyUserLogin(string code)
         {
-            var loginThread = new Thread(() => QueryLoginThread(queryUri, timeoutSeconds, expiresAt, cancelToken))
+            // Make the POST request to the verify endpoint
+            ApiRequest api = new ApiRequest(string.Empty, $"{ProductConstants.BaseUrl}{ApiVersion}/vpn/login/verify", RestSharp.Method.POST);
+
+            // Get code_verifier that was generated in the login request
+            string code_verifier = File.ReadAllText(Path.Combine(codeVerifierPath, codeVerifierFile));
+
+            // Create a new dictionary to hold the code and code_verifier.
+            Dictionary<string, string> postBody = new Dictionary<string, string>();
+            postBody.Add("code", code);
+            postBody.Add("code_verifier", code_verifier);
+            api.AddPostBody(postBody);
+
+            // Execute the request
+            var response = api.SendRequest();
+
+            if (response == null || response.StatusCode != System.Net.HttpStatusCode.OK)
             {
-                IsBackground = true,
-            };
-            loginThread.Start();
+                ErrorHandling.ErrorHandler.Handle(new ErrorHandling.UserFacingMessage("toast-login-url-retrieval-error"), ErrorHandling.UserFacingErrorType.None, ErrorHandling.LogLevel.Error);
+                return;
+            }
+
+            // Check login credentials, add the session info to the settings, and add a new device
+            Manager.Account.ProcessLogin(response.Content);
         }
 
         /// <summary>
-        /// Polls a verification URL periodically to confirm if the user has logged in.
+        /// Generates a Base64 encoded string based on a SHA256 hash with the string it's given.
         /// </summary>
-        /// <param name="queryUri">Login verification URL.</param>
-        /// <param name="timeoutSeconds">Timeout (secs) between verification query attempts.</param>
-        /// <param name="expiresAt">Expiration date of the verification URL.</param>
-        /// <param name="cancelToken">Token used to cancel the login thread.</param>
-        private void QueryLoginThread(string queryUri, int timeoutSeconds, DateTime expiresAt, CancellationToken cancelToken)
+        /// <param name="text">The text that needs to be converted to a SHA256 hash.</param>
+        /// <returns>A Base64 encoded SHA256 hash.</returns>
+        private static string GetHashSha256(string text)
         {
-            while (Manager.Account.LoginState == FxA.LoginState.LoggingIn && DateTime.Compare(DateTime.UtcNow, expiresAt) < 0)
-            {
-                try
-                {
-                    var queryRawData = QueryRawLoginState(queryUri);
+            SHA256Managed hashTool = new SHA256Managed();
 
-                    if (cancelToken.IsCancellationRequested)
-                    {
-                        break;
-                    }
+            byte[] phraseAsByte = Encoding.UTF8.GetBytes(string.Concat(text));
+            byte[] encryptedBytes = hashTool.ComputeHash(phraseAsByte);
 
-                    if (queryRawData == null)
-                    {
-                        Thread.Sleep(TimeSpan.FromSeconds(timeoutSeconds));
-                        continue;
-                    }
+            hashTool.Clear();
 
-                    var queryData = ParseLoginState(queryRawData);
+            string value = Convert.ToBase64String(encryptedBytes);
 
-                    if (queryData.User == null || queryData.User.Subscriptions == null || queryData.User.Subscriptions.Vpn == null)
-                    {
-                        Manager.Account.LoginState = LoginState.LoggedOut;
-                        break;
-                    }
+            return value;
+        }
 
-                    if (!queryData.User.Subscriptions.Vpn.Active)
-                    {
-                        Manager.Account.LoginState = LoginState.LoggedOut;
-                        break;
-                    }
+        /// <summary>
+        /// Generates a random cryptographic string to use in a PKCE auth request and response.
+        /// </summary>
+        /// <param name="length">The number of characters that should be in the string.</param>
+        /// <param name="chars">The acceptable characters that can be in the string.</param>
+        /// <returns>A cryptographic string.</returns>
+        private static string GetUniqueToken(int length, string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_")
+        {
+            using (RNGCryptoServiceProvider crypto = new RNGCryptoServiceProvider())
+            {
+                byte[] data = new byte[length];
 
-                    var processLoginResult = Manager.Account.ProcessLogin(queryRawData);
-                    Manager.Account.LoginState = LoginState.LoggedIn;
-                    Manager.StartUIUpdaters();
+                // If chars.Length isn't a power of 2 then there is a bias if we simply use the modulus operator. The first characters of chars will be more probable than the last ones.
+                // buffer used if we encounter an unusable random byte. We will regenerate it in this buffer.
+                byte[] buffer = null;
 
-                    var maxDevicesReached = !processLoginResult && Manager.Account.Config.FxALogin.User.Devices.Count() >= Manager.Account.Config.FxALogin.User.MaxDevices;
+                // Maximum random number that can be used without introducing a bias
+                int maxRandom = byte.MaxValue - ((byte.MaxValue + 1) % chars.Length);
 
-                    Cache.FxAServerList.RetrieveRemoteServerList();
-                    Application.Current.Dispatcher.Invoke(() =>
+                crypto.GetBytes(data);
+
+                char[] result = new char[length];
+
+                for (int i = 0; i < length; i++)
+                {
+                    byte value = data[i];
+
+                    while (value > maxRandom)
                     {
-                        var owner = Application.Current.MainWindow;
-                        if (owner != null)
+                        if (buffer == null)
                         {
-                            if (!Manager.MustUpdate)
-                            {
-                                if (maxDevicesReached)
-                                {
-                                    ((UI.MainWindow)owner).NavigateToView(new UI.DevicesView(deviceLimitReached: true, fxaJson: queryRawData), UI.MainWindow.SlideDirection.Left);
-                                }
-                                else
-                                {
-                                    ((UI.MainWindow)owner).NavigateToView(new UI.OnboardingView5(), UI.MainWindow.SlideDirection.Left);
-                                }
-                            }
-
-                            ((UI.MainWindow)owner).Show();
-                            ((UI.MainWindow)owner).WindowState = WindowState.Normal;
-                            ((UI.MainWindow)owner).Activate();
+                            buffer = new byte[1];
                         }
-                    });
-                }
-                catch (Exception e)
-                {
-                    ErrorHandling.ErrorHandler.Handle(e, ErrorHandling.LogLevel.Debug);
+
+                        crypto.GetBytes(buffer);
+                        value = buffer[0];
+                    }
+
+                    result[i] = chars[value % chars.Length];
                 }
 
-                Thread.Sleep(TimeSpan.FromSeconds(5));
+                return new string(result);
             }
-
-            LoginResultEvent?.Invoke(this, this, Manager.Account.LoginState);
         }
     }
 }

programming_language: C#
filename: LoginSessionManager.cs
change_type: ModificationType.MODIFY
diff: @@ -32,7 +32,7 @@ namespace FirefoxPrivateNetwork.FxA
 
                 sessions.Add(login, tokenSource);
 
-                if (!login.StartLogin(tokenSource.Token))
+                if (!login.StartLogin())
                 {
                     CancelCurrentSession();
                 }

programming_language: C#
filename: ServerList.cs
change_type: ModificationType.MODIFY
diff: @@ -173,7 +173,7 @@ namespace FirefoxPrivateNetwork.FxA
         /// <returns>Success status of the remote server list retrieval.</returns>
         public bool RetrieveRemoteServerList()
         {
-            var api = new ApiRequest(Manager.Account.Config.FxALogin.Token, "/vpn/servers", Method.GET);
+            var api = new ApiRequest(Manager.Account.Config.FxALogin.Token, $"{ProductConstants.BaseUrl}{Account.OriginalApiVersion}/vpn/servers", Method.GET);
             var response = api.SendRequest();
             if (response == null)
             {

programming_language: C#
filename: FxALoginURLs.cs
change_type: ModificationType.MODIFY
diff: @@ -23,17 +23,5 @@ namespace FirefoxPrivateNetwork.JSONStructures
         /// </summary>
         [JsonProperty("verification_url")]
         public string VerificationUrl { get; set; }
-
-        /// <summary>
-        /// Gets or sets the expiration date/time for the URLs.
-        /// </summary>
-        [JsonProperty("expires_on")]
-        public DateTime ExpiresOn { get; set; }
-
-        /// <summary>
-        /// Gets or sets the recommended poll interval for the verification URL.
-        /// </summary>
-        [JsonProperty("poll_interval")]
-        public int PollInterval { get; set; }
     }
 }

programming_language: C#
filename: Main.cs
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,8 @@
 // </copyright>
 
 using System;
+using System.Collections.Generic;
+using System.Diagnostics;
 using System.Linq;
 using System.ServiceProcess;
 using System.Threading;
@@ -17,13 +19,8 @@ namespace FirefoxPrivateNetwork
     /// Entry point of the Mozilla VPN application.
     /// </summary>
     [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.StyleCop.CSharp.DocumentationRules", "SA1649:FileNameMustMatchTypeName", Justification = "Default C# behavior.")]
-    internal class Entry : System.Windows.Application
+    internal class Entry : Application
     {
-        /// <summary>
-        /// Global value that is used to indicate if there is already an instance of the application running.
-        /// </summary>
-        private static readonly Mutex RunOnceMutex = new Mutex(false, string.Concat(@"Local\", ProductConstants.GUID));
-
         /// <summary>
         /// Main entry point of the application.
         /// </summary>
@@ -32,7 +29,9 @@ namespace FirefoxPrivateNetwork
         {
             bool ranOnStartup = false;
 
-            if (args.Count() == 1)
+            List<Process> processes = Process.GetProcessesByName(Process.GetCurrentProcess().ProcessName).ToList();
+
+            if (args.Count() == 1 && !args[0].Contains("mozilla-vpn:"))
             {
                 // Run the broker child process, skip the UI
                 if (args.First().ToLower() == "broker")
@@ -59,16 +58,17 @@ namespace FirefoxPrivateNetwork
             }
 
             // Prevent multiple instances of the application
-            if (!RunOnceMutex.WaitOne(TimeSpan.Zero, true))
+            if (processes.Count > 1)
             {
-                // Already running, attempt to send a "show" command to the already running process before exiting
-                var runningWindow = User32.FindWindow(ProductConstants.TrayWindowClassName, string.Empty);
-                if (runningWindow != IntPtr.Zero)
+                processes.Sort((x, y) => DateTime.Compare(y.StartTime, x.StartTime));
+
+                if (processes.Count > 2)
                 {
-                    User32.SendMessage(runningWindow, User32.WmShow, IntPtr.Zero, string.Empty);
+                    for (int i = 1; i <= processes.Count - 2; i++)
+                    {
+                        processes[i].Kill();
+                    }
                 }
-
-                Environment.Exit(1);
             }
 
             // We dont need `If Debug_QA` here, because we already had an attribute `[Conditional("DEBUG_QA")]` for this function
@@ -81,7 +81,17 @@ namespace FirefoxPrivateNetwork
                 app.InitializeComponent();
 
                 // Initialize interfaces
-                Manager.Initialize();
+                // Run this if the app is opened via the custom URL protocol with a code
+                if (args.Count() == 1 && args[0].Contains("mozilla-vpn:"))
+                {
+                    // Get the code string that's returned from the server to be used in the verification request
+                    Manager.Initialize(args.First());
+                    VerifyUser(args.First());
+                }
+                else
+                {
+                    Manager.Initialize();
+                }
 
                 // Has the app just been launched at Windows startup?
                 Manager.MainWindowViewModel.RanOnStartup = ranOnStartup;
@@ -136,5 +146,32 @@ namespace FirefoxPrivateNetwork
                 Environment.Exit(1);
             }
         }
+
+        /// <summary>
+        /// Starts the WireGuard tunnel service.
+        /// </summary>
+        /// <param name="args">
+        /// Argument 0: "tunnel" keyword
+        /// Argument 1: Path to the configuration file.
+        /// </param>
+        private static void VerifyUser(string args)
+        {
+            try
+            {
+                string code = args.Substring(args.IndexOf("code=") + 5);
+
+                if (code != null && code.Length >= 44)
+                {
+                    FxA.Login verifyUser = new FxA.Login();
+
+                    verifyUser.VerifyUserLogin(code);
+                }
+            }
+            catch (Exception e)
+            {
+                ErrorHandling.ErrorHandler.Handle(e, ErrorHandling.LogLevel.Error);
+                Environment.Exit(1);
+            }
+        }
     }
 }

programming_language: C#
filename: Manager.cs
change_type: ModificationType.MODIFY
diff: @@ -111,7 +111,8 @@ namespace FirefoxPrivateNetwork
         /// <summary>
         /// Initialize all elements of the Manager class.
         /// </summary>
-        public static void Initialize()
+        /// <param name="code">Optional verification code.</param>
+        public static void Initialize(string code = "")
         {
             InitializeSettings();
             InitializeTranslationService();
@@ -119,7 +120,7 @@ namespace FirefoxPrivateNetwork
             InitializeServerListCache();
             InitializeTunnel();
             InitializeAccount();
-            InitializeViewModels();
+            InitializeViewModels(code);
             InitializeWlanWatcher();
             InitializeCaptivePortalDetector();
             InitializeIpInfo();
@@ -167,9 +168,10 @@ namespace FirefoxPrivateNetwork
         /// <summary>
         /// Initialize the main window view model.
         /// </summary>
-        public static void InitializeViewModels()
+        /// <param name="code">Optional verification code.</param>
+        public static void InitializeViewModels(string code = "")
         {
-            MainWindowViewModel = new ViewModels.MainWindowViewModel();
+            MainWindowViewModel = new ViewModels.MainWindowViewModel(code);
         }
 
         /// <summary>
@@ -233,6 +235,8 @@ namespace FirefoxPrivateNetwork
             ConnectionStatusUpdater.StopThread();
             ServerListUpdater.StopThread();
             AccountInfoUpdater.StopTask();
+            VersionUpdater.StopTask();
+            ToastManager.StopThread();
         }
 
         /// <summary>

programming_language: C#
filename: ProductConstants.cs
change_type: ModificationType.MODIFY
diff: @@ -313,10 +313,10 @@ namespace FirefoxPrivateNetwork
             }
 
             // FxA API link
-            FxAUrl = string.Concat(BaseUrl, "/api/v1");
+            FxAUrl = string.Concat(BaseUrl, "/api/v2");
 
             // Login link
-            FxALoginUrl = string.Concat(FxAUrl, "/vpn/login");
+            FxALoginUrl = string.Concat(FxAUrl, "/vpn/login/windows");
 
             // Feedback form link
             FeedbackFormUrl = string.Concat(BaseUrl, "/r/vpn/client/feedback", FxAUtmTags);

programming_language: C#
filename: HeroSubtext.xaml
change_type: ModificationType.MODIFY
diff: @@ -122,7 +122,7 @@
                             </Setter>
                         </DataTrigger>
 
-                        <!-- Connection unsable -->
+                        <!-- Connection unstable -->
                         <DataTrigger Binding="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:HeroSubText}}, Path=(extension:CardExtensions.VpnStability)}" Value="1">
                             <Setter Property="Text">
                                 <Setter.Value>

programming_language: Markdown
filename: en-us.ftl
change_type: ModificationType.MODIFY
diff: @@ -128,7 +128,7 @@ devices-page-description = Devices with {application-name} installed using your
 devices-limit-reached-title = Remove a device
 devices-limit-reached-content = You've reached your limit. To install the VPN on this device, you'll need to remove one.
 devices-remove-popup-title = Remove device?
-devices-remove-popup-content = Please confirm you would like to remove {$deviceName}.
+devices-remove-popup-content = Please confirm you would like to remove {$deviceName}. You will need to open the VPN client on the removed device to clear your network settings and access the internet again.
 devices-remove = Remove device
 
 ## Popup

programming_language: PowerShell
filename: SettingsView.xaml
change_type: ModificationType.MODIFY
diff: @@ -82,7 +82,7 @@
                 <!-- Launch VPN on startup setting -->
                 <Grid HorizontalAlignment="Left" VerticalAlignment="Top" Grid.Row="1" Grid.Column="0">
                     <StackPanel Margin="0,0,0,8">
-                        <CheckBox Margin="18" Content="{Binding Path=[settings-auto-launch]}" Click="RunOnStartup_Click">
+                        <CheckBox Margin="18" Content="{Binding Path=[settings-auto-launch]}" Click="RunOnStartup_Click" Visibility="Hidden">
                             <CheckBox.Style>
                                 <Style BasedOn="{StaticResource Checkbox}" TargetType="{x:Type CheckBox}">
                                     <Setter Property="IsChecked" Value="False" />

programming_language: PowerShell
filename: MainWindowViewModel.cs
change_type: ModificationType.MODIFY
diff: @@ -75,7 +75,8 @@ namespace FirefoxPrivateNetwork.ViewModels
         /// <summary>
         /// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
         /// </summary>
-        public MainWindowViewModel()
+        /// <param name="code">Optional verification code.</param>
+        public MainWindowViewModel(string code)
         {
             // Initialize connectionStatus in the view model
             connectionStatus = new Models.ConnectionStatus
@@ -99,7 +100,11 @@ namespace FirefoxPrivateNetwork.ViewModels
             }
 
             // Determine initial page to display in the ViewFrame
-            if (Manager.Account.LoginState == FxA.LoginState.LoggedIn && currentDevice != null)
+            if (code.Length >= 44)
+            {
+                InitialViewFrameSourceType = typeof(UI.OnboardingView5);
+            }
+            else if (Manager.Account.LoginState == FxA.LoginState.LoggedIn && currentDevice != null)
             {
                 InitialViewFrameSourceType = typeof(UI.MainView);
             }

programming_language: C#
filename: IService.cs
change_type: ModificationType.MODIFY
diff: @@ -41,15 +41,6 @@ namespace FirefoxPrivateNetwork.WCF
         [WebInvoke(Method = "POST", ResponseFormat = WebMessageFormat.Json, RequestFormat = WebMessageFormat.Json)]
         Response Disconnect();
 
-        /// <summary>
-        /// Login stub.
-        /// </summary>
-        /// <param name="req">Login request object.</param>
-        /// <returns>WCF response.</returns>
-        [OperationContract]
-        [WebInvoke(Method = "POST", ResponseFormat = WebMessageFormat.Json, RequestFormat = WebMessageFormat.Json)]
-        Response Login(LoginRequest req);
-
         /// <summary>
         /// Logout stub.
         /// </summary>

programming_language: C#
filename: Service.cs
change_type: ModificationType.MODIFY
diff: @@ -118,27 +118,6 @@ namespace FirefoxPrivateNetwork.WCF
             }
         }
 
-        /// <summary>
-        /// Initiate login.
-        /// </summary>
-        /// <param name="req">LoginRequest WCF command object.</param>
-        /// <returns>WCF response.</returns>
-        public Response Login(LoginRequest req)
-        {
-            try
-            {
-                var loginInstance = new FxA.Login();
-                var pollInterval = req.PollInterval % 31; // Max 30 seconds, no more
-                Manager.Account.LoginState = FxA.LoginState.LoggingIn;
-                loginInstance.StartQueryLoginThread(req.VerificationUrl, req.PollInterval, req.ExpiresOn, CancellationToken.None);
-                return new Response(200, "Success");
-            }
-            catch (Exception ex)
-            {
-                return new Response(500, ex.Message);
-            }
-        }
-
         /// <summary>
         /// Initiate logout.
         /// </summary>

programming_language: C#
filename: app.manifest
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,8 @@
   <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
     <security>
       <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
-        <requestedExecutionLevel level="asInvoker" uiAccess="false" />
+        <requestedExecutionLevel level="highestAvailable" uiAccess="false" />
+        <!--<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />-->
       </requestedPrivileges>
     </security>
   </trustInfo>

programming_language: Markdown
filename: packages.config
change_type: ModificationType.MODIFY
diff: @@ -5,6 +5,8 @@
   <package id="Fluent.Net" version="1.0.31" targetFramework="net472" />
   <package id="Fody" version="6.0.0" targetFramework="net46" developmentDependency="true" />
   <package id="ini-parser" version="2.5.2" targetFramework="net472" />
+  <package id="Microsoft.IdentityModel.Logging" version="6.7.1" targetFramework="net472" />
+  <package id="Microsoft.IdentityModel.Tokens" version="6.7.1" targetFramework="net472" />
   <package id="Microsoft.NETCore.Platforms" version="2.2.2" targetFramework="net472" />
   <package id="Microsoft.WindowsAPICodePack-Core" version="1.1.0.2" targetFramework="net472" />
   <package id="Newtonsoft.Json" version="12.0.2" targetFramework="net472" />

programming_language: PowerShell
repo_url: https://github.com/mozilla-mobile/guardian-vpn-android
commit_details: repo_url: https://github.com/mozilla-mobile/guardian-vpn-android
author: Severin Rudie
author_date: 2020-09-20 20:05:59-07:00
msg: New non-polling authentication flow.

- Fixes intermittent hanging during auth
- Implements PKCE
repository: repo_url: https://github.com/mozilla-mobile/guardian-vpn-android
repo_name: mozilla-mobile/guardian-vpn-android
description: DEPRECATED - Mozilla VPN for Android
repo_language: Kotlin
owner: mozilla-mobile
file_changes: filename: build.gradle
change_type: ModificationType.MODIFY
diff: @@ -64,6 +64,12 @@ android {
     viewBinding {
         enabled = true
     }
+
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
 }
 
 configurations {
@@ -102,13 +108,18 @@ dependencies {
     ktlint 'com.pinterest:ktlint:0.35.0'
 
     testImplementation 'junit:junit:4.12'
-    testImplementation 'org.robolectric:robolectric:3.4.2'
+    testImplementation 'org.robolectric:robolectric:4.4'
+    testImplementation 'androidx.test:core:1.3.0'
+    testImplementation 'androidx.test.ext:junit:1.1.1'
+
     testImplementation 'androidx.arch.core:core-testing:2.1.0'
     testImplementation 'com.google.truth:truth:1.0.1'
     testImplementation 'io.mockk:mockk:1.9'
     testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.7'
     androidTestImplementation 'androidx.test:runner:1.2.0'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
+    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
+    androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
     androidTestImplementation 'androidx.test.ext:junit:1.1.1'
 }
 

programming_language: CoffeeScript
filename: PkceRegressionTest.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+import android.content.Intent
+import android.net.Uri
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.FlakyTest
+import androidx.test.filters.LargeTest
+import androidx.test.rule.ActivityTestRule
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Assert.assertNotNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.firefox.vpn.splash.SplashActivity
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class PkceRegressionTest {
+
+    @Rule
+    @JvmField
+    val splashActivityTestRule = ActivityTestRule(SplashActivity::class.java)
+
+    @Rule
+    @JvmField
+    val intentReceiverActivityTestRule = ActivityTestRule(IntentReceiverActivity::class.java)
+
+    @Test
+    @FlakyTest
+    /**
+     * Flaky for two reasons.  1) this needs to touch the network, 2) Espresso tests are just flaky.
+     */
+    fun pkce_regression_test() {
+        // Simulate response from user logging in at:
+        // "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net/api/v2/vpn/login/android?code_challenge=fx-O4_N_sfGrXxLgDkByfVNgZUPCI1s5PqWp8k1fG8M=&code_challenge_method=S256"
+        val authCode = "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d"
+        val intentUri = Uri.parse(
+            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                    "/vpn/client/login/success?" +
+                    "code=$authCode" +
+                    "#Intent;category=android.intent.category.BROWSABLE;" +
+                    "launchFlags=0x14000000;" +
+                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                    "i.org.chromium.chrome.browser.referrer_id=18;" +
+                    "S.com.android.browser.application_id=com.android.chrome;end"
+        )
+        val intent = Intent("android.intent.action.VIEW", intentUri)
+
+        val receivedCode = CompletableDeferred<AuthCode>()
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(receivedCode)
+
+        // IntentReceiverActivity launched with the above auth code
+        intentReceiverActivityTestRule.launchActivity(intent)
+
+        // assert an auth code was received
+        runBlocking {
+            withTimeout(5_000) {
+                assertNotNull(receivedCode.await())
+            }
+        }
+
+        runBlocking { delay(1_000) }
+
+        // assert onboarding screen still shown, login did not proceed
+        onView(withId(R.id.auth_btn)).check(matches(isDisplayed()))
+    }
+}

programming_language: Scala
filename: AndroidManifest.xml
change_type: ModificationType.MODIFY
diff: @@ -50,6 +50,20 @@
             android:screenOrientation="portrait"
             tools:ignore="LockedOrientationActivity" />
 
+        <activity android:name="org.mozilla.firefox.vpn.IntentReceiverActivity"
+            android:screenOrientation="portrait"
+            tools:ignore="LockedOrientationActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+                <data
+                    android:scheme="mozilla-vpn"
+                    android:host="login"
+                    android:path="/success" />
+            </intent-filter>
+        </activity>
+
         <service
             android:name="org.mozilla.firefox.vpn.main.vpn.GuardianVpnService"
             android:permission="android.permission.BIND_VPN_SERVICE">

programming_language: Ruby
filename: GuardianApp.kt
change_type: ModificationType.MODIFY
diff: @@ -8,7 +8,7 @@ import org.mozilla.firefox.vpn.report.ReportUtil
 import org.mozilla.firefox.vpn.service.MockGuardianService
 import org.mozilla.firefox.vpn.util.NotificationUtil
 
-class GuardianApp : Application() {
+open class GuardianApp : Application() {
 
     val coreComponent: CoreComponent by lazy {
         CoreComponentImpl(this)

programming_language: Scala
filename: GuardianComponent.kt
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,6 @@ import org.mozilla.firefox.vpn.servers.domain.SelectedServerProvider
 import org.mozilla.firefox.vpn.service.GuardianService
 import org.mozilla.firefox.vpn.service.newInstance
 import org.mozilla.firefox.vpn.update.UpdateManager
-import org.mozilla.firefox.vpn.user.data.ReferralManager
 import org.mozilla.firefox.vpn.user.data.SessionManager
 import org.mozilla.firefox.vpn.user.data.UserRepository
 
@@ -33,12 +32,10 @@ class GuardianComponentImpl(
 
     private val sessionManager = SessionManager(prefs)
 
-    private val referralManager = ReferralManager(coreComponent.app.applicationContext, prefs)
-
     var service = GuardianService.newInstance(sessionManager)
 
     override val userRepo: UserRepository by lazy {
-        UserRepository(service, sessionManager, referralManager)
+        UserRepository(service, sessionManager)
     }
 
     override val deviceRepo: DeviceRepository by lazy {

programming_language: Scala
filename: IntentReceiverActivity.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import kotlinx.coroutines.CompletableDeferred
+import org.mozilla.firefox.vpn.ext.toCode
+import org.mozilla.firefox.vpn.user.domain.AuthToken
+
+/**
+ * Code that is included in the `verify` request that is used to retrieve an [AuthToken].
+ */
+typealias AuthCode = String
+
+/**
+ * Abstraction that handles incoming [Intent]s for use elsewhere in the app.  After
+ * processing an [Intent], this activity will finish itself.
+ */
+open class IntentReceiverActivity : AppCompatActivity() {
+
+    companion object {
+
+        /**
+         * Set a [CompletableDeferred] that will be completed with an [AuthCode] the next
+         * time one is received.  Note that this is not guaranteed to ever complete.
+         *
+         * This is static because the alternative was a similarly bad practice: weaving
+         * the reference through many layers of Android framework code.
+         */
+        fun setAuthCodeReceivedDeferred(authCodeReceived: CompletableDeferred<AuthCode>) {
+            _authCodeReceived = authCodeReceived
+        }
+        private var _authCodeReceived: CompletableDeferred<AuthCode> = CompletableDeferred()
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        intent?.data?.toCode()?.let { authCode ->
+            _authCodeReceived.complete(authCode)
+        }
+
+        finish()
+    }
+}

programming_language: Scala
filename: MockedGuardianComponent.kt
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,6 @@ import org.mozilla.firefox.vpn.servers.data.ServerRepository
 import org.mozilla.firefox.vpn.servers.domain.SelectedServerProvider
 import org.mozilla.firefox.vpn.service.MockGuardianService
 import org.mozilla.firefox.vpn.update.UpdateManager
-import org.mozilla.firefox.vpn.user.data.ReferralManager
 import org.mozilla.firefox.vpn.user.data.SessionManager
 import org.mozilla.firefox.vpn.user.data.UserRepository
 
@@ -20,12 +19,10 @@ class MockedGuardianComponent(
 
     private val sessionManager = SessionManager(prefs)
 
-    private val referralManager = ReferralManager(coreComponent.app.applicationContext, prefs)
-
     var service = MockGuardianService()
 
     override val userRepo: UserRepository by lazy {
-        UserRepository(service, sessionManager, referralManager)
+        UserRepository(service, sessionManager)
     }
 
     override val deviceRepo: DeviceRepository by lazy {

programming_language: Scala
filename: AuthCodeHelper.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.crypto
+
+import android.util.Base64
+import java.security.MessageDigest
+import java.security.SecureRandom
+import kotlin.random.Random
+import kotlin.random.asKotlinRandom
+
+/**
+ * PKCE code verifier.
+ */
+typealias CodeVerifier = String
+/**
+ * PKCE code challenge.
+ */
+typealias CodeChallenge = String
+
+private const val CHALLENGE_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP
+
+/**
+ * Contains utility functions for generating secure codes.
+ */
+object AuthCodeHelper {
+
+    /**
+     * Returns a cryptographically random key. Sent during token request as part of PKCE.
+     */
+    fun generateCodeVerifier(random: Random = SecureRandom().asKotlinRandom()): CodeVerifier {
+        val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', '.', '~')
+
+        val size = (43..128).random(random)
+
+        val sb = StringBuilder()
+        for (i in 1..size) {
+            sb.append(allowedChars.random(random))
+        }
+        return sb.toString()
+    }
+
+    /**
+     * Returns a SHA-256 encoded hash based on [verifier]. Used to retrieve a token as
+     * part of PKCE.
+     */
+    @Synchronized // MessageDigest is not thread-safe
+    fun generateCodeChallenge(verifier: CodeVerifier): CodeChallenge {
+        val bytes = verifier.toByteArray(Charsets.US_ASCII)
+        val messageDigest = MessageDigest.getInstance("SHA-256")
+        messageDigest.update(bytes, 0, bytes.size)
+        val digest = messageDigest.digest()
+
+        return Base64.encodeToString(digest, CHALLENGE_FLAGS)
+    }
+}

programming_language: Scala
filename: LiveEvent.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.ext
+
+import com.hadilq.liveevent.LiveEvent
+
+/**
+ * Adds an event on to the [LiveEvent]. This makes usage more idiomatic by letting
+ * [LiveEvent]s be called as functions.
+ *
+ * EXAMPLE
+ *
+ * With this extension:
+ * ```
+ * promptLogin(info.loginUrl)
+ * ```
+ *
+ * Without this extension:
+ * ```
+ * promptLogin.value = info.loginUrl
+ * ```
+ */
+operator fun <T> LiveEvent<T>.invoke(value: T) {
+    this.value = value
+}
+
+/**
+ * Adds an event on to the [LiveEvent]. This makes usage more idiomatic by letting
+ * [LiveEvent]s be called as functions.
+ *
+ * EXAMPLE
+ *
+ * With this extension:
+ * ```
+ * promptLogin()
+ * ```
+ *
+ * Without this extension:
+ * ```
+ * promptLogin.value = Unit
+ * ```
+ */
+operator fun LiveEvent<Unit>.invoke() {
+    this.value = Unit
+}

programming_language: Scala
filename: Uri.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.ext
+
+import android.net.Uri
+import org.mozilla.firefox.vpn.AuthCode
+
+private const val CODE_QUERY_PARAM = "code"
+
+private val ALLOWED_CODE_CHARS = (('0'..'9') + ('a'..'f')).toSet()
+
+fun Uri.toCode(): AuthCode? {
+    val code = getQueryParameter(CODE_QUERY_PARAM)
+
+    return if (
+        code == null ||
+        code.length != 80 ||
+        code.any { char -> !ALLOWED_CODE_CHARS.contains(char) }
+    ) {
+        null
+    } else {
+        code
+    }
+}

programming_language: Scala
filename: SignOutUseCase.kt
change_type: ModificationType.MODIFY
diff: @@ -27,7 +27,7 @@ class SignOutUseCase(
         CoroutineScope(coroutineContext + NonCancellable).launch {
             deviceRepository.getDevice()?.let {
                 deviceRepository.unregisterDevice(it.device.pubKey)
-                userRepository.removeUserInfo()
+                userRepository.invalidateSession()
             }
         }
     }

programming_language: Scala
filename: OnboardingActivity.kt
change_type: ModificationType.MODIFY
diff: @@ -3,7 +3,6 @@ package org.mozilla.firefox.vpn.onboarding
 import android.content.Context
 import android.content.Intent
 import android.os.Bundle
-import android.view.View
 import android.widget.Toast
 import androidx.appcompat.app.AppCompatActivity
 import androidx.lifecycle.Observer
@@ -40,11 +39,11 @@ class OnboardingActivity : AppCompatActivity() {
 
         viewModel.isLoggedOut = isLoggedOut()
 
-        viewModel.toast.observe(this, Observer {
+        viewModel.bus.toast.observe(this, Observer {
             Toast.makeText(this, it.resolve(this), Toast.LENGTH_SHORT).show()
         })
 
-        viewModel.showLoggedOutMessage.observe(this, Observer {
+        viewModel.bus.showLoggedOutMessage.observe(this, Observer {
             val message = it.resolve(this) ?: return@Observer
             val sb = GuardianSnackbar.make(
                 binding.container,
@@ -57,33 +56,23 @@ class OnboardingActivity : AppCompatActivity() {
             sb.show()
         })
 
-        viewModel.promptLogin.observe(this, Observer {
+        viewModel.bus.promptLogin.observe(this, Observer {
             customTab.launchUrl(it)
         })
 
-        viewModel.launchMainPage.observerUntilOnDestroy(this, Observer {
+        viewModel.bus.launchMainPage.observerUntilOnDestroy(this, Observer {
             if (redirectHandled) {
                 handlePreviousFailedRedirection()
             } else {
-                redirectHandled = true
                 redirectToMainPage()
             }
         })
 
-        viewModel.uiModel.observe(this, Observer {
-            binding.loadingView.visibility = if (it.isLoading) {
-                View.VISIBLE
-            } else {
-                View.GONE
-            }
+        viewModel.bus.closeTabsToOnboarding.observerUntilOnDestroy(this, Observer {
+            closeCustomTabsToOnboarding()
         })
     }
 
-    override fun onResume() {
-        super.onResume()
-        viewModel.resumeLoginFlow()
-    }
-
     override fun onNewIntent(intent: Intent?) {
         super.onNewIntent(intent)
         if (redirectHandled) {
@@ -106,6 +95,16 @@ class OnboardingActivity : AppCompatActivity() {
         // To close custom tab first, current approach launches OnboardingActivity again with
         // FLAG_ACTIVITY_CLEAR_TOP to clear the custom tab, and since onNewIntent() will be called
         // in this case, we launch main activity there
+        redirectHandled = true
+
+        startActivity(getStartIntent(this@OnboardingActivity).apply {
+            addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+        })
+    }
+
+    private fun closeCustomTabsToOnboarding() {
+        // See #redirectToMainPage for explanation
+        redirectHandled = false
 
         startActivity(getStartIntent(this@OnboardingActivity).apply {
             addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)

programming_language: Scala
filename: OnboardingComponent.kt
change_type: ModificationType.MODIFY
diff: @@ -1,14 +1,14 @@
 package org.mozilla.firefox.vpn.onboarding
 
+import com.hadilq.liveevent.LiveEvent
 import org.mozilla.firefox.vpn.CoreComponent
 import org.mozilla.firefox.vpn.GuardianComponent
 import org.mozilla.firefox.vpn.device.domain.AddDeviceUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.ClearPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.GetPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.SetPendingLoginInfoUseCase
 import org.mozilla.firefox.vpn.user.domain.CreateUserUseCase
-import org.mozilla.firefox.vpn.user.domain.GetLoginInfoUseCase
-import org.mozilla.firefox.vpn.user.domain.VerifyLoginUseCase
+import org.mozilla.firefox.vpn.user.domain.GetAuthCodeUseCase
+import org.mozilla.firefox.vpn.user.domain.GetTokenUseCase
+import org.mozilla.firefox.vpn.user.domain.SaveAuthTokenUseCase
+import org.mozilla.firefox.vpn.util.StringResource
 
 interface OnboardingComponent {
     val viewModel: OnboardingViewModel
@@ -20,13 +20,27 @@ class OnboardingComponentImpl(
 ) : OnboardingComponent, GuardianComponent by guardianComponent, CoreComponent by coreComponent {
 
     override val viewModel: OnboardingViewModel
-        get() = OnboardingViewModel(
-            loginInfoUseCase = GetLoginInfoUseCase(userRepo),
-            verifyLoginUseCase = VerifyLoginUseCase(userRepo),
-            createUserUseCase = CreateUserUseCase(userRepo, userStateResolver),
-            addDeviceUseCase = AddDeviceUseCase(deviceRepo, userRepo),
-            setPendingLoginInfoUseCase = SetPendingLoginInfoUseCase(prefs),
-            getPendingLoginInfoUseCase = GetPendingLoginInfoUseCase(prefs),
-            clearPendingLoginInfoUseCase = ClearPendingLoginInfoUseCase(prefs)
-        )
+        get() {
+            val bus = Bus()
+            return OnboardingViewModel(
+                bus = bus,
+                createUserUseCase = CreateUserUseCase(userRepo, userStateResolver),
+                addDeviceUseCase = AddDeviceUseCase(deviceRepo, userRepo),
+                getTokenUseCase = GetTokenUseCase(userRepo, GetAuthCodeUseCase(bus)),
+                saveAuthTokenUseCase = SaveAuthTokenUseCase(userRepo)
+            )
+        }
+}
+
+/**
+ * Used to pass data to the presentation layer.
+ *
+ * Not technically a bus, but CollectionOfChannels is a bad name.
+ */
+class Bus {
+    val toast = LiveEvent<StringResource>()
+    val showLoggedOutMessage = LiveEvent<StringResource>()
+    val launchMainPage = LiveEvent<Unit>()
+    val closeTabsToOnboarding = LiveEvent<Unit>()
+    val promptLogin = LiveEvent<String>()
 }

programming_language: Scala
filename: OnboardingViewModel.kt
change_type: ModificationType.MODIFY
diff: @@ -1,152 +1,74 @@
 package org.mozilla.firefox.vpn.onboarding
 
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
-import com.hadilq.liveevent.LiveEvent
-import java.util.concurrent.atomic.AtomicBoolean
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withContext
 import org.mozilla.firefox.vpn.R
 import org.mozilla.firefox.vpn.device.domain.AddDeviceUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.ClearPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.GetPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.SetPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.service.LoginResult
 import org.mozilla.firefox.vpn.user.domain.CreateUserUseCase
-import org.mozilla.firefox.vpn.user.domain.GetLoginInfoUseCase
-import org.mozilla.firefox.vpn.user.domain.VerifyLoginUseCase
+import org.mozilla.firefox.vpn.user.domain.GetTokenUseCase
+import org.mozilla.firefox.vpn.user.domain.SaveAuthTokenUseCase
 import org.mozilla.firefox.vpn.util.GLog
 import org.mozilla.firefox.vpn.util.Result
 import org.mozilla.firefox.vpn.util.StringResource
-import org.mozilla.firefox.vpn.util.addCompletionHandler
 import org.mozilla.firefox.vpn.util.onError
-import org.mozilla.firefox.vpn.util.onSuccess
 
 class OnboardingViewModel(
-    private val loginInfoUseCase: GetLoginInfoUseCase,
-    private val verifyLoginUseCase: VerifyLoginUseCase,
+    val bus: Bus,
     private val createUserUseCase: CreateUserUseCase,
     private val addDeviceUseCase: AddDeviceUseCase,
-    private val setPendingLoginInfoUseCase: SetPendingLoginInfoUseCase,
-    private val getPendingLoginInfoUseCase: GetPendingLoginInfoUseCase,
-    private val clearPendingLoginInfoUseCase: ClearPendingLoginInfoUseCase
+    private val getTokenUseCase: GetTokenUseCase,
+    private val saveAuthTokenUseCase: SaveAuthTokenUseCase
 ) : ViewModel() {
 
-    val toast = LiveEvent<StringResource>()
-    val showLoggedOutMessage = LiveEvent<StringResource>()
-    val launchMainPage = LiveEvent<Unit>()
-    val promptLogin = LiveEvent<String>()
-
-    private val _uiModel = MutableLiveData<UiModel>()
-    val uiModel: LiveData<UiModel> = _uiModel
-
-    // See #153 & #204 before modifying the login flow
-    private val loginSuccess = AtomicBoolean(false)
-
-    private var verificationJob: Job? = null
-
     var isLoggedOut: Boolean = false
         set(value) {
             if (value) {
-                showLoggedOutMessage.postValue(StringResource(R.string.onboarding_logged_out))
+                bus.showLoggedOutMessage.postValue(StringResource(R.string.onboarding_logged_out))
             }
             field = value
         }
 
-    fun startLoginFlow() {
-        viewModelScope.launch(Dispatchers.Main) { getLoginInfo().onSuccess { login(it) } }
-    }
-
-    fun resumeLoginFlow() {
-        viewModelScope.launch(Dispatchers.Main.immediate) {
-            _uiModel.value = UiModel(true)
-
-            if (loginSuccess.get()) {
-                // Already being verified by the verifyLoginAsync()
-                gotoMainPage()
-            } else {
-                getPendingLoginInfoUseCase()
-                    ?.let { verifyLoginOnce(it) }
-                    ?: run { _uiModel.value = UiModel(false) }
-            }
-        }
-    }
-
-    override fun onCleared() {
-        if (loginSuccess.get()) {
-            clearPendingLoginInfoUseCase()
-        }
-        super.onCleared()
-    }
-
-    private fun cancelLoginFlow() {
-        verificationJob?.cancel("verification cancelled")
-    }
-
-    private suspend fun getLoginInfo() = withContext(Dispatchers.IO) {
-        loginInfoUseCase()
-    }
-
-    private suspend fun login(info: LoginInfo) = withContext(Dispatchers.Main) {
-        // Save login info persistently so it survives even if the view(activity) is recycled
-        setPendingLoginInfoUseCase(info)
+    private var loginJob: Job = Job()
 
-        promptLogin.value = info.loginUrl
-        verificationJob = verifyLoginPeriodically(info).addCompletionHandler { verificationJob = null }
-    }
-
-    private suspend fun verifyLogin(info: LoginInfo, retry: Boolean = false) = withContext(Dispatchers.IO) {
-        when (val result = verifyLoginUseCase(info, retry)) {
-            is Result.Success -> onLoginSuccess(result.value)
-            is Result.Fail -> {
-                _uiModel.postValue(UiModel(false))
-                cancelLoginFlow()
-                GLog.d(TAG, "verify login failed: ${result.exception}")
+    fun startLoginFlow() {
+        // Some edge cases (e.g., rapidly clicking the "get started" button) can cause this
+        // method to be repeatedly called.  We cancel any previous attempts here, so that
+        // at most one is running at any time.
+        runBlocking { loginJob.cancelAndJoin() }
+        loginJob = viewModelScope.launch(Dispatchers.IO) {
+
+            when (val token = getTokenUseCase(scope = this)) {
+                is Result.Success -> {
+                    saveAuthTokenUseCase(token.value)
+
+                    setupNewUser()
+
+                    bus.launchMainPage.postValue(Unit)
+                }
+                is Result.Fail -> {
+                    // TODO this would be a good place to show an error toast, if we had
+                    // copy. This at least prevents them from watching an endless progress
+                    // bar on failure.
+                    bus.closeTabsToOnboarding.postValue(Unit)
+                    GLog.d(TAG, "verify login failed: ${token.exception}")
+                }
             }
         }
     }
 
-    private suspend fun verifyLoginOnce(info: LoginInfo) = withContext(Dispatchers.IO) {
-        verifyLogin(info, false)
-    }
-
-    private suspend fun verifyLoginPeriodically(info: LoginInfo) = viewModelScope.launch(Dispatchers.IO) {
-        verifyLogin(info, true)
-    }
-
-    private suspend fun onLoginSuccess(
-        loginResult: LoginResult
-    ) = withContext(Dispatchers.Main) {
-        if (loginSuccess.compareAndSet(false, true)) {
-            setupNewUser(loginResult)
-        }
-
-        gotoMainPage()
-    }
-
-    private suspend fun gotoMainPage() = withContext(Dispatchers.Main.immediate) {
-        launchMainPage.value = Unit
-    }
-
-    private suspend fun setupNewUser(
-        loginResult: LoginResult
-    ) = withContext(Dispatchers.IO) {
-        createUserUseCase(loginResult)
-
+    private suspend fun setupNewUser() = withContext(Dispatchers.IO) {
+        // Note: `createUserUseCase` and `AddDeviceUseCase` are temporally coupled
+        createUserUseCase()
         addDeviceUseCase()
             .onError { GLog.d(TAG, "add device failed: $it") }
     }
 
-    data class UiModel(
-        val isLoading: Boolean
-    )
-
     companion object {
         private const val TAG = "OnboardingViewModel"
     }

programming_language: Scala
filename: ClearPendingLoginInfoUseCase.kt
change_type: ModificationType.DELETE
diff: @@ -1,13 +0,0 @@
-package org.mozilla.firefox.vpn.onboarding.domain
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-
-class ClearPendingLoginInfoUseCase(
-    private val prefs: SharedPreferences
-) {
-
-    operator fun invoke() {
-        prefs.edit { remove(SetPendingLoginInfoUseCase.PREF_PENDING_LOGIN_INFO) }
-    }
-}

programming_language: unknown
filename: GetPendingLoginInfoUseCase.kt
change_type: ModificationType.DELETE
diff: @@ -1,21 +0,0 @@
-package org.mozilla.firefox.vpn.onboarding.domain
-
-import android.content.SharedPreferences
-import com.google.gson.Gson
-import org.mozilla.firefox.vpn.service.LoginInfo
-
-class GetPendingLoginInfoUseCase(
-    private val prefs: SharedPreferences
-) {
-
-    operator fun invoke(): LoginInfo? {
-        return prefs.getString(SetPendingLoginInfoUseCase.PREF_PENDING_LOGIN_INFO, null)
-            ?.let {
-                try {
-                    Gson().fromJson(it, LoginInfo::class.java)
-                } catch (e: Exception) {
-                    null
-                }
-            }
-    }
-}

programming_language: unknown
filename: SetPendingLoginInfoUseCase.kt
change_type: ModificationType.DELETE
diff: @@ -1,19 +0,0 @@
-package org.mozilla.firefox.vpn.onboarding.domain
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import com.google.gson.Gson
-import org.mozilla.firefox.vpn.service.LoginInfo
-
-class SetPendingLoginInfoUseCase(
-    private val prefs: SharedPreferences
-) {
-
-    operator fun invoke(info: LoginInfo) {
-        prefs.edit { putString(PREF_PENDING_LOGIN_INFO, Gson().toJson(info)) }
-    }
-
-    companion object {
-        const val PREF_PENDING_LOGIN_INFO = "pending_login_info"
-    }
-}

programming_language: unknown
filename: Report.kt
change_type: ModificationType.MODIFY
diff: @@ -11,15 +11,12 @@ import com.bosphere.filelogger.FLConfig
 import com.bosphere.filelogger.FLConst
 import java.io.File
 import org.mozilla.firefox.vpn.service.DeviceInfo
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.service.LoginResult
+import org.mozilla.firefox.vpn.service.User
 import org.mozilla.firefox.vpn.user.data.UserInfo
 import org.mozilla.firefox.vpn.user.data.isDeviceLimitReached
 import org.mozilla.firefox.vpn.user.data.isSubscribed
 import org.mozilla.firefox.vpn.util.GLog
 import org.mozilla.firefox.vpn.util.Result
-import org.mozilla.firefox.vpn.util.TimeFormat
-import org.mozilla.firefox.vpn.util.TimeUtil
 import org.mozilla.firefox.vpn.util.onError
 import org.mozilla.firefox.vpn.util.onSuccess
 
@@ -126,8 +123,7 @@ inline fun <reified T : Any> Result<T>.doReport(
 
 inline fun <reified T : Any> reportDataType(value: T): String {
     return when (value) {
-        is LoginInfo -> value.toReport()
-        is LoginResult -> value.toReport()
+        is User -> value.toReport()
         is UserInfo -> value.toReport()
         is DeviceInfo -> value.toReport()
         is List<*> -> {
@@ -146,21 +142,9 @@ fun reportError(tag: String, msg: String) {
     GLog.report(tag, "error($msg)")
 }
 
-fun LoginInfo.toReport(): String {
-    val time = System.currentTimeMillis()
-    val expire = try {
-        TimeUtil.parse(this.expiresOn, TimeFormat.Iso8601).time
-    } catch (e: Exception) {
-        0L
-    }
-    val expireIn = expire - time
-    val interval = this.pollInterval
-    return "expireIn=$expireIn, pollInterval=$interval"
-}
-
-fun LoginResult.toReport(): String {
-    val subscribed = user.subscription.isSubscribed
-    val numDevices = user.devices
+fun User.toReport(): String {
+    val subscribed = subscription.isSubscribed
+    val numDevices = devices
     return "subscribed=$subscribed, numDevices=${numDevices.size}"
 }
 

programming_language: Scala
filename: GuardianService.kt
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,6 @@
 package org.mozilla.firefox.vpn.service
 
 import android.os.Build
-import androidx.core.net.toUri
 import com.google.gson.Gson
 import com.google.gson.GsonBuilder
 import com.google.gson.JsonDeserializationContext
@@ -17,9 +16,13 @@ import okhttp3.Interceptor
 import okhttp3.OkHttpClient
 import okhttp3.ResponseBody
 import okhttp3.logging.HttpLoggingInterceptor
+import org.mozilla.firefox.vpn.AuthCode
 import org.mozilla.firefox.vpn.BuildConfig
 import org.mozilla.firefox.vpn.const.ENDPOINT
+import org.mozilla.firefox.vpn.crypto.CodeChallenge
+import org.mozilla.firefox.vpn.crypto.CodeVerifier
 import org.mozilla.firefox.vpn.user.data.SessionManager
+import org.mozilla.firefox.vpn.user.domain.AuthToken
 import org.mozilla.firefox.vpn.util.Result
 import org.mozilla.firefox.vpn.util.mapError
 import retrofit2.Response
@@ -31,15 +34,16 @@ import retrofit2.http.GET
 import retrofit2.http.Header
 import retrofit2.http.POST
 import retrofit2.http.Path
-import retrofit2.http.QueryMap
-import retrofit2.http.Url
 
 interface GuardianService {
-    @POST("api/v1/vpn/login")
-    suspend fun getLoginInfo(@QueryMap referral: Map<String, String>): Response<LoginInfo>
 
-    @GET
-    suspend fun verifyLogin(@Url verifyUrl: String): Response<LoginResult>
+    @POST("/api/v2/vpn/login/verify/")
+    suspend fun verifyLogin(@Body postData: PostData): Response<LoginResult>
+
+    data class PostData(
+        @SerializedName("code") val code: AuthCode,
+        @SerializedName("code_verifier") val codeVerifier: CodeVerifier
+    )
 
     @GET("api/v1/vpn/account")
     suspend fun getUserInfo(
@@ -67,6 +71,12 @@ interface GuardianService {
         const val HOST_CONTACT = "$HOST_GUARDIAN/r/vpn/contact"
         const val HOST_TERMS = "$HOST_GUARDIAN/r/vpn/terms"
         const val HOST_PRIVACY = "$HOST_GUARDIAN/r/vpn/privacy"
+
+        fun getLoginUrl(
+            codeChallenge: CodeChallenge,
+            challengeMethod: String = "S256"
+        ) = "$HOST_GUARDIAN/api/v2/vpn/login/android?" +
+                "code_challenge=$codeChallenge&code_challenge_method=$challengeMethod"
     }
 }
 
@@ -78,7 +88,7 @@ fun GuardianService.Companion.newInstance(sessionManager: SessionManager): Guard
                 .header("User-Agent", getUserAgent())
                 .method(original.method(), original.body())
             if (original.isHttps) {
-                request.addHeader("Authorization", "Bearer ${sessionManager.getUserInfo()?.token}")
+                request.addHeader("Authorization", "Bearer ${sessionManager.getAuthToken()}")
             }
             it.proceed(request.build())
         }
@@ -150,30 +160,13 @@ private class TimeoutInterceptor : Interceptor {
 
 private fun getUserAgent(): String {
     val os = "Android ${Build.VERSION.RELEASE}"
-    val abi = Build.SUPPORTED_ABIS.firstOrNull()?.let { it } ?: "no-support-abi"
+    val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "no-support-abi"
     return "FirefoxPrivateNetworkVPN/${BuildConfig.VERSION_NAME} ($os; $abi)"
 }
 
-data class LoginInfo(
-    @SerializedName("login_url")
-    val loginUrl: String,
-
-    @SerializedName("verification_url")
-    val verificationUrl: String,
-
-    @SerializedName("expires_on")
-    val expiresOn: String,
-
-    @SerializedName("poll_interval")
-    val pollInterval: Int
-)
-
 data class LoginResult(
-    @SerializedName("user")
-    val user: User,
-
     @SerializedName("token")
-    val token: String
+    val token: AuthToken
 )
 
 data class User(
@@ -324,18 +317,6 @@ data class Version(
     val message: String
 )
 
-class LoginQueryBuilder(private val referral: String) {
-
-    fun build(): Map<String, String> {
-        return "mozilla.com?$referral".toUri().let { uri ->
-            uri.queryParameterNames
-                .filter { it.isNotEmpty() } // parameter name shouldn't be empty
-                .map { it to (uri.getQueryParameter(it) ?: "") }
-                .toMap()
-        }
-    }
-}
-
 data class DeviceRequestBody(
     val name: String,
     val pubkey: String
@@ -423,10 +404,12 @@ object LoginTokenNotFound : UnauthorizedException()
 object LoginTokenExpired : UnauthorizedException()
 object LoginTokenUnverified : UnauthorizedException()
 
+object BrowserClosedWithoutLogin : RuntimeException()
+
 data class ExpiredException(val currentTime: String, val expireTime: String) : RuntimeException()
 class ErrorCodeException(val code: Int, val errorBody: ResponseBody?) : RuntimeException()
 
 object NetworkException : RuntimeException()
 
-open class UnknownException(private val msg: String) : RuntimeException(msg)
+open class UnknownException(msg: String) : RuntimeException(msg)
 data class UnknownErrorBody(val body: ResponseBody?) : UnknownException("${body?.string()}")

programming_language: Scala
filename: MockGuardianService.kt
change_type: ModificationType.MODIFY
diff: @@ -59,19 +59,8 @@ class MockGuardianService : GuardianService {
 
     private val countries = listOf(Country("Mock country", "US", cities))
 
-    override suspend fun getLoginInfo(loginRequestBody: Map<String, String>): Response<LoginInfo> {
-        delay()
-        return Response.success(LoginInfo(
-            loginUrl,
-            verifyUrl,
-            nextYear(),
-            2000
-        ))
-    }
-
-    override suspend fun verifyLogin(verifyUrl: String): Response<LoginResult> {
-        delay()
-        return Response.success(LoginResult(user, "mock_token"))
+    override suspend fun verifyLogin(data: GuardianService.PostData): Response<LoginResult> {
+        return Response.success(LoginResult(("mock_token")))
     }
 
     override suspend fun getUserInfo(connectTimeout: String, readTimeout: String): Response<User> {

programming_language: Scala
filename: ReferralManager.kt
change_type: ModificationType.DELETE
diff: @@ -1,72 +0,0 @@
-package org.mozilla.firefox.vpn.user.data
-
-import android.content.Context
-import android.content.SharedPreferences
-import com.android.installreferrer.api.InstallReferrerClient
-import com.android.installreferrer.api.InstallReferrerStateListener
-import com.android.installreferrer.api.ReferrerDetails
-import kotlin.coroutines.resume
-import kotlinx.coroutines.suspendCancellableCoroutine
-import org.mozilla.firefox.vpn.util.GLog
-
-class ReferralManager(context: Context, private val prefs: SharedPreferences) {
-
-    companion object {
-        private const val TAG = "ReferralManager"
-        private const val SIR_PREF_REFERRAL = "sir_pref_referral"
-        private const val DEFAULT_REFERRAL = ""
-    }
-
-    private var referrerClient: InstallReferrerClient =
-        InstallReferrerClient.newBuilder(context).build()
-
-    suspend fun getUserReferral() = suspendCancellableCoroutine<String> { cont ->
-        val cacheReferral = prefs.getString(SIR_PREF_REFERRAL, null)
-        if (cacheReferral != null) {
-            GLog.d(TAG, "referral already submitted")
-            if (cont.isActive) {
-                cont.resume(cacheReferral)
-                return@suspendCancellableCoroutine
-            }
-        }
-        referrerClient.startConnection(object : InstallReferrerStateListener {
-
-            override fun onInstallReferrerSetupFinished(responseCode: Int) {
-                when (responseCode) {
-                    InstallReferrerClient.InstallReferrerResponse.OK -> {
-                        val response: ReferrerDetails = referrerClient.installReferrer
-                        val referrerUrl: String = response.installReferrer
-                        prefs.edit().putString(SIR_PREF_REFERRAL, referrerUrl).apply()
-                        if (cont.isActive) {
-                            GLog.d(TAG, "onInstallReferrerSetupFinished")
-                            cont.resume(referrerUrl)
-                            return
-                        }
-                    }
-                    InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED -> {
-                        // API not available on the current Play Store app or there's no Play Store
-                        GLog.w(TAG, "FEATURE_NOT_SUPPORTED")
-                    }
-                    InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE -> {
-                        // Connection couldn't be established.
-                        GLog.w(TAG, "SERVICE_UNAVAILABLE")
-                    }
-                }
-                if (cont.isActive) {
-                    cont.resume(DEFAULT_REFERRAL)
-                    return
-                }
-            }
-
-            override fun onInstallReferrerServiceDisconnected() {
-                // Try to restart the connection on the next request to
-                // Google Play by calling the startConnection() method.
-                if (cont.isActive) {
-                    GLog.w(TAG, "onInstallReferrerServiceDisconnected")
-                    cont.resume(DEFAULT_REFERRAL)
-                    return
-                }
-            }
-        })
-    }
-}

programming_language: unknown
filename: SessionManager.kt
change_type: ModificationType.MODIFY
diff: @@ -2,10 +2,11 @@ package org.mozilla.firefox.vpn.user.data
 
 import android.content.SharedPreferences
 import com.google.gson.Gson
+import org.mozilla.firefox.vpn.user.domain.AuthToken
 
 class SessionManager(private val prefs: SharedPreferences) {
 
-    fun createUserInfo(user: UserInfo) {
+    fun saveUserInfo(user: UserInfo) {
         val json = Gson().toJson(user)
         prefs.edit()
             .putString(PREF_USER_INFO, json)
@@ -18,11 +19,23 @@ class SessionManager(private val prefs: SharedPreferences) {
         }
     }
 
-    fun removeUserInfo() {
-        prefs.edit().remove(PREF_USER_INFO).apply()
+    fun saveAuthToken(token: AuthToken) {
+        prefs.edit()
+            .putString(PREF_AUTH_TOKEN, token)
+            .apply()
+    }
+
+    fun getAuthToken(): AuthToken? = prefs.getString(PREF_AUTH_TOKEN, null)
+
+    fun invalidateSession() {
+        prefs.edit()
+            .remove(PREF_USER_INFO)
+            .remove(PREF_AUTH_TOKEN)
+            .apply()
     }
 
     companion object {
         private const val PREF_USER_INFO = "user_info"
+        private const val PREF_AUTH_TOKEN = "auth_token"
     }
 }

programming_language: Scala
filename: UserRepository.kt
change_type: ModificationType.MODIFY
diff: @@ -1,9 +1,10 @@
 package org.mozilla.firefox.vpn.user.data
 
 import java.net.UnknownHostException
+import org.mozilla.firefox.vpn.AuthCode
+import org.mozilla.firefox.vpn.crypto.CodeVerifier
+import org.mozilla.firefox.vpn.report.doReport
 import org.mozilla.firefox.vpn.service.GuardianService
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.service.LoginQueryBuilder
 import org.mozilla.firefox.vpn.service.LoginResult
 import org.mozilla.firefox.vpn.service.NetworkException
 import org.mozilla.firefox.vpn.service.Subscription
@@ -17,6 +18,7 @@ import org.mozilla.firefox.vpn.service.handleError
 import org.mozilla.firefox.vpn.service.resolveBody
 import org.mozilla.firefox.vpn.service.toErrorBody
 import org.mozilla.firefox.vpn.service.toUnauthorizedError
+import org.mozilla.firefox.vpn.user.domain.AuthToken
 import org.mozilla.firefox.vpn.util.GLog
 import org.mozilla.firefox.vpn.util.Result
 import org.mozilla.firefox.vpn.util.TimeFormat
@@ -26,71 +28,59 @@ import org.mozilla.firefox.vpn.util.mapValue
 import org.mozilla.firefox.vpn.util.onError
 import org.mozilla.firefox.vpn.util.onSuccess
 
+private const val TAG = "UserRepository"
+
 class UserRepository(
     private val guardianService: GuardianService,
-    private val sessionManager: SessionManager,
-    private val referralManager: ReferralManager
+    private val sessionManager: SessionManager
 ) {
 
     /**
-     * @return Result.Success(loginInfo) or Result.Fail(NetworkException|Otherwise)
+     *
      */
-    suspend fun getLoginInfo(): Result<LoginInfo> {
-        return try {
-            val loginQueryBuilder = LoginQueryBuilder(referralManager.getUserReferral())
-            guardianService.getLoginInfo(loginQueryBuilder.build()).resolveBody()
-        } catch (e: UnknownHostException) {
-            Result.Fail(NetworkException)
-        } catch (e: Exception) {
-            Result.Fail(UnknownException("Unknown exception=$e"))
-        }
+    suspend fun verifyLogin(
+        authCode: AuthCode,
+        codeVerifier: CodeVerifier
+    ): Result<LoginResult> = try {
+        val response =
+            guardianService.verifyLogin(GuardianService.PostData(authCode, codeVerifier))
+
+        response.resolveBody()
+            .handleError(401) {
+                it?.toErrorBody()
+                    ?.toUnauthorizedError()
+                    ?: UnknownErrorBody(it)
+            }
+    } catch (e: UnknownHostException) {
+        Result.Fail(NetworkException)
+    } catch (e: Exception) {
+        Result.Fail(UnknownException("Unknown exception=$e"))
     }
 
-    /**
-     * @return Result.Success(loginResult) or Result.Fail(UnauthorizedException|NetworkException|Otherwise)
-     */
-    suspend fun verifyLogin(info: LoginInfo): Result<LoginResult> {
-        return try {
-            val response = guardianService.verifyLogin(info.verificationUrl)
-            response.resolveBody()
-                .handleError(401) {
-                    it?.toErrorBody()
-                        ?.toUnauthorizedError()
-                        ?: UnknownErrorBody(it)
-                }
-        } catch (e: UnknownHostException) {
-            Result.Fail(NetworkException)
-        } catch (e: Exception) {
-            Result.Fail(UnknownException("Unknown exception=$e"))
-        }
-    }
+    fun saveUserInfo(user: UserInfo) = sessionManager.saveUserInfo(user)
 
-    fun createUserInfo(user: UserInfo) {
-        sessionManager.createUserInfo(user)
-    }
+    fun getUserInfo(): UserInfo? = sessionManager.getUserInfo()
 
-    fun getUserInfo(): UserInfo? {
-        return sessionManager.getUserInfo()
-    }
+    /**
+     * Remove stored user info and auth token.
+     */
+    fun invalidateSession() = sessionManager.invalidateSession()
 
-    fun removeUserInfo() {
-        sessionManager.removeUserInfo()
-    }
+    fun saveAuthToken(token: AuthToken) = sessionManager.saveAuthToken(token)
 
     /**
      * @return Result.Success(user) or Result.Fail(UnauthorizedException|NetworkException|Otherwise)
      */
     suspend fun refreshUserInfo(connectTimeout: Long = 0, readTimeout: Long = 0): Result<UserInfo> {
-        val userInfo = getUserInfo() ?: return Result.Fail(UnauthorizedException())
-
         return try {
             val response = guardianService.getUserInfo(connectTimeout, readTimeout)
             response.resolveBody()
+                .doReport(tag = TAG)
                 .mapValue {
-                    userInfo.copy(
+                    UserInfo(
                         user = it,
                         latestUpdateTime = System.currentTimeMillis()
-                    ).apply { createUserInfo(this) }
+                    ).apply { saveUserInfo(this) }
                 }
                 .handleError(401) {
                     it?.toErrorBody()
@@ -118,7 +108,6 @@ class UserRepository(
 
 data class UserInfo(
     val user: User,
-    val token: String,
     val latestUpdateTime: Long
 )
 

programming_language: Scala
filename: CreateUserUseCase.kt
change_type: ModificationType.MODIFY
diff: @@ -1,8 +1,6 @@
 package org.mozilla.firefox.vpn.user.domain
 
 import org.mozilla.firefox.vpn.UserStateResolver
-import org.mozilla.firefox.vpn.service.LoginResult
-import org.mozilla.firefox.vpn.user.data.UserInfo
 import org.mozilla.firefox.vpn.user.data.UserRepository
 
 class CreateUserUseCase(
@@ -10,14 +8,9 @@ class CreateUserUseCase(
     private val userStateResolver: UserStateResolver
 ) {
 
-    operator fun invoke(loginResult: LoginResult): UserInfo {
-        return UserInfo(
-            user = loginResult.user,
-            token = loginResult.token,
-            latestUpdateTime = System.currentTimeMillis()
-        ).apply {
-            userRepository.createUserInfo(this)
-            userStateResolver.refresh()
-        }
+    suspend operator fun invoke() {
+        // Note: `refreshUserInfo` and `refresh` are temporally coupled
+        userRepository.refreshUserInfo()
+        userStateResolver.refresh()
     }
 }

programming_language: Scala
filename: GetAuthCodeUseCase.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.user.domain
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ensureActive
+import org.mozilla.firefox.vpn.AuthCode
+import org.mozilla.firefox.vpn.IntentReceiverActivity
+import org.mozilla.firefox.vpn.crypto.CodeChallenge
+import org.mozilla.firefox.vpn.onboarding.Bus
+import org.mozilla.firefox.vpn.service.BrowserClosedWithoutLogin
+import org.mozilla.firefox.vpn.service.GuardianService
+import org.mozilla.firefox.vpn.service.NetworkException
+import org.mozilla.firefox.vpn.service.UnknownException
+import org.mozilla.firefox.vpn.util.LoginCustomTab
+import org.mozilla.firefox.vpn.util.Result
+
+class GetAuthCodeUseCase(
+    private val bus: Bus
+) {
+
+    /**
+     * Navigates the user to FxA login, then retrieves the auth code from the response.
+     *
+     * Note: this will block until the user either completes their login or closes the
+     * custom tab.  Programmatically, this is a very long time.  Maybe don't use the main
+     * thread.
+     */
+    suspend operator fun invoke(
+        codeChallenge: CodeChallenge,
+        scope: CoroutineScope
+    ): Result<AuthCode> {
+
+        val authCodeReceived = CompletableDeferred<AuthCode>().also {
+            IntentReceiverActivity.setAuthCodeReceivedDeferred(it)
+        }
+        val customTabsClosed = CompletableDeferred<Unit>().also {
+            LoginCustomTab.setCustomTabsClosedEvent(it)
+        }
+
+        val loginUrl = GuardianService.getLoginUrl(codeChallenge)
+        bus.promptLogin.postValue(loginUrl)
+
+        return try {
+            while (!authCodeReceived.isCompleted && !customTabsClosed.isCompleted) {
+                scope.ensureActive() // Yield to check for cancellation
+                /* block */
+            }
+
+            when {
+                authCodeReceived.isCompleted -> Result.Success(authCodeReceived.await())
+                customTabsClosed.isCompleted -> Result.Fail(BrowserClosedWithoutLogin)
+                else -> Result.Fail(UnknownException("Get secret failed unexpectedly"))
+            }
+        } finally {
+            authCodeReceived.cancel()
+            customTabsClosed.cancel()
+
+            Result.Fail(NetworkException)
+        }
+    }
+}

programming_language: Scala
filename: GetLoginInfoUseCase.kt
change_type: ModificationType.DELETE
diff: @@ -1,19 +0,0 @@
-package org.mozilla.firefox.vpn.user.domain
-
-import org.mozilla.firefox.vpn.report.doReport
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.user.data.UserRepository
-import org.mozilla.firefox.vpn.util.Result
-
-class GetLoginInfoUseCase(
-    private val userRepository: UserRepository
-) {
-
-    suspend operator fun invoke(): Result<LoginInfo> {
-        return userRepository.getLoginInfo().doReport(tag = TAG)
-    }
-
-    companion object {
-        private const val TAG = "GetLoginInfoUseCase"
-    }
-}

programming_language: unknown
filename: GetTokenUseCase.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.user.domain
+
+import kotlinx.coroutines.CoroutineScope
+import org.mozilla.firefox.vpn.crypto.AuthCodeHelper
+import org.mozilla.firefox.vpn.user.data.UserRepository
+import org.mozilla.firefox.vpn.util.Result
+import org.mozilla.firefox.vpn.util.flatMapValue
+import org.mozilla.firefox.vpn.util.mapValue
+
+/**
+ * Token that is attached to all requests made by an authenticated user.
+ */
+typealias AuthToken = String
+
+class GetTokenUseCase(
+    private val userRepository: UserRepository,
+    private val getAuthCodeUseCase: GetAuthCodeUseCase
+) {
+
+    /**
+     * Begin login flow and await either a token or failure.
+     *
+     * This is not guaranteed to complete if the user 1) never finishes signing in and 2) never
+     * cancels their login flow.
+     */
+    suspend operator fun invoke(scope: CoroutineScope): Result<AuthToken> {
+        val codeVerifier = AuthCodeHelper.generateCodeVerifier()
+        val codeChallenge = AuthCodeHelper.generateCodeChallenge(codeVerifier)
+
+        return getAuthCodeUseCase(codeChallenge, scope)
+            .flatMapValue { secret -> userRepository.verifyLogin(secret, codeVerifier) }
+            .mapValue { loginResult -> loginResult.token }
+    }
+}

programming_language: Scala
filename: LogoutUseCase.kt
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,7 @@ class LogoutUseCase(
 
     operator fun invoke() {
         GLog.report(TAG, "logged out")
-        userRepository.removeUserInfo()
+        userRepository.invalidateSession()
         deviceRepository.removeDevice()
     }
 

programming_language: Scala
filename: SaveAuthTokenUseCase.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.user.domain
+
+import org.mozilla.firefox.vpn.user.data.UserRepository
+
+class SaveAuthTokenUseCase(private val userRepository: UserRepository) {
+    operator fun invoke(token: AuthToken) {
+        userRepository.saveAuthToken(token)
+    }
+}

programming_language: Scala
filename: VerifyLoginUseCase.kt
change_type: ModificationType.DELETE
diff: @@ -1,42 +0,0 @@
-package org.mozilla.firefox.vpn.user.domain
-
-import kotlinx.coroutines.delay
-import org.mozilla.firefox.vpn.report.doReport
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.service.LoginResult
-import org.mozilla.firefox.vpn.service.LoginTokenExpired
-import org.mozilla.firefox.vpn.service.LoginTokenNotFound
-import org.mozilla.firefox.vpn.user.data.UserRepository
-import org.mozilla.firefox.vpn.util.Result
-
-class VerifyLoginUseCase(
-    private val userRepository: UserRepository
-) {
-
-    suspend operator fun invoke(info: LoginInfo, retry: Boolean): Result<LoginResult> {
-        var result = verifyLogin(info)
-        if (!retry) {
-            return result
-        }
-
-        while (result is Result.Fail) {
-            // Nothing we can do to invalid/expired token. User will have to close the custom tab
-            // and click login button again to retrieve new token
-            when (result.exception) {
-                is LoginTokenNotFound, is LoginTokenExpired -> return result
-            }
-
-            delay(info.pollInterval * 1000L)
-            result = verifyLogin(info)
-        }
-        return result
-    }
-
-    private suspend fun verifyLogin(info: LoginInfo) = userRepository
-        .verifyLogin(info)
-        .doReport(tag = TAG)
-
-    companion object {
-        private const val TAG = "VerifyLoginUseCase"
-    }
-}

programming_language: unknown
filename: CustomTabUtil.kt
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,9 @@ import android.app.Activity
 import android.content.ComponentName
 import android.content.Context
 import android.net.Uri
+import android.os.Bundle
 import androidx.appcompat.app.AppCompatActivity
+import androidx.browser.customtabs.CustomTabsCallback
 import androidx.browser.customtabs.CustomTabsClient
 import androidx.browser.customtabs.CustomTabsIntent
 import androidx.browser.customtabs.CustomTabsServiceConnection
@@ -12,7 +14,10 @@ import androidx.browser.customtabs.CustomTabsSession
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.DefaultLifecycleObserver
 import androidx.lifecycle.LifecycleOwner
+import java.util.ArrayDeque
+import java.util.Queue
 import kotlin.coroutines.resume
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlinx.coroutines.withContext
@@ -63,7 +68,7 @@ class LoginCustomTab(private val activity: AppCompatActivity) : DefaultLifecycle
             override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
                 GLog.d(TAG, "service connected")
                 client.warmup(0)
-                this@LoginCustomTab.session = client.newSession(null)
+                this@LoginCustomTab.session = client.newSession(customTabsCallback)
                 this@LoginCustomTab.client = client
             }
 
@@ -90,6 +95,61 @@ class LoginCustomTab(private val activity: AppCompatActivity) : DefaultLifecycle
             "org.mozilla.focus",
             "com.android.chrome"
         )
+
+        /**
+         * Set a [CompletableDeferred] that will complete the next time custom tabs are closed.
+         *
+         * IMPORTANT: this should be set _before_ custom tabs are opened.
+         *
+         * Note that custom tab callbacks are asynchronous and _extremely_ slow.  This
+         * class attempts to handle cases where the user quickly opens and closes their
+         * tabs, but if you encounter problems around that use case this is a good
+         * place to look.
+         *
+         * This is static because the alternative was a similarly bad practice: weaving
+         * the reference through many layers of Android framework code.
+         */
+        fun setCustomTabsClosedEvent(authCodeReceived: CompletableDeferred<Unit>) {
+            queuedCloses.add(authCodeReceived)
+        }
+        private val queuedCloses: Queue<CompletableDeferred<Unit>> = ArrayDeque()
+    }
+
+    private val customTabsCallback = object : CustomTabsCallback() {
+        private var openCount = 0
+
+        override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
+            when (navigationEvent) {
+                // From testing, this seems to always be sent before TAB_HIDDEN
+                TAB_SHOWN -> { openCount++ }
+                // If the user clicks the button multiple times, we may have more completables
+                // than we do TAB_SHOWN events.  Also note that these callbacks are
+                // asynchronous and extremely slow, and from testing do not always occur in
+                // order, or even at all.  E.g., a very fast `open -> close -> open` might
+                // send `SHOWN -> SHOWN`.
+                //
+                // As a result, we are very conservative here and only close as many
+                // completables as we have seen SHOWN events.  Others will be automatically
+                // canceled when their parent job ends anyway.
+                //
+                // Be very careful changing this.  Mistakes here can cause auth to hang
+                // indefinitely, as requests are immediately canceled due to extra calls to
+                // `complete`.
+                TAB_HIDDEN -> {
+                    openCount--
+                    queuedCloses.poll()?.complete(Unit)
+
+                    var head = queuedCloses.peek()
+                    while (head != null && openCount > 0) {
+                        head = queuedCloses.poll()
+                        head?.complete(Unit)
+                        openCount--
+                    }
+                }
+                else -> { /* Do nothing */
+                }
+            }
+        }
     }
 }
 

programming_language: Scala
filename: ResourceUtil.kt
change_type: ModificationType.MODIFY
diff: @@ -8,8 +8,8 @@ import java.util.Locale
 
 class StringResource {
     var id: Int? = null
-    var str: String? = null
-    var formatArgs: Array<out String>? = null
+    private var str: String? = null
+    private var formatArgs: Array<out String>? = null
 
     constructor(str: String) {
         this.str = str

programming_language: Scala
filename: Result.kt
change_type: ModificationType.MODIFY
diff: @@ -12,6 +12,12 @@ inline fun <T : Any, R : Any> Result<T>.mapValue(function: (T) -> R): Result<R>
     }
 }
 
+inline fun <T : Any, R : Any> Result<T>.flatMapValue(function: (T) -> Result<R>): Result<R> =
+    when (this) {
+        is Result.Success -> function(value)
+        is Result.Fail -> Result.Fail(exception)
+    }
+
 inline fun <T : Any> Result<T>.mapError(function: (Exception) -> Exception): Result<T> {
     return when (this) {
         is Result.Success -> this

programming_language: Scala
filename: IntentReceiverActivityTest.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,214 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+import android.content.Intent
+import android.net.Uri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import java.lang.RuntimeException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric.buildActivity
+import org.robolectric.android.controller.ActivityController
+import org.robolectric.annotation.Config
+
+@ExperimentalCoroutinesApi
+@RunWith(AndroidJUnit4::class)
+@Config(application = TestGuardianApp::class, sdk = [WORKING_SDK])
+class IntentReceiverActivityTest {
+
+    private val code = "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d"
+    private val intentUri = Uri.parse(
+        "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                "/vpn/client/login/success?" +
+                "code=$code" +
+                "#Intent;category=android.intent.category.BROWSABLE;" +
+                "launchFlags=0x14000000;" +
+                "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                "i.org.chromium.chrome.browser.referrer_id=18;" +
+                "S.com.android.browser.application_id=com.android.chrome;end"
+    )
+    private val intent = Intent("android.intent.action.VIEW", intentUri)
+
+    private lateinit var activity: ActivityController<IntentReceiverActivity>
+    private lateinit var first: CompletableDeferred<AuthCode>
+    private lateinit var second: CompletableDeferred<AuthCode>
+
+    @Before
+    fun setup() {
+        first = CompletableDeferred()
+        second = CompletableDeferred()
+    }
+
+    @Test
+    fun `WHEN no intent is sent THEN nextLogin should not complete`() {
+        assertFalse(first.isCompleted)
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+        activity = buildActivity(IntentReceiverActivity::class.java).setup()
+
+        assertFalse(first.isCompleted)
+    }
+
+    @Test
+    fun `WHEN intent is sent THEN nextLogin should complete`() = runBlockingTest {
+        assertFalse(first.isCompleted)
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+        buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+        assertTrue(first.isCompleted)
+        assertEquals(code, first.await())
+
+        // new deferred should be set
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(second)
+        assertNotEquals(first, second)
+        assertFalse(second.isCompleted)
+    }
+
+    @Test
+    fun `GIVEN intent was sent WHEN activity is recreated several times THEN only one token should be emitted`() = runBlockingTest {
+        assertFalse(first.isCompleted)
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+        val controller =
+            buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+        assertTrue(first.isCompleted)
+        assertEquals(code, first.await())
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(second)
+        assertFalse(second.isCompleted)
+
+        controller.pause().stop().start().resume()
+
+        controller.pause().stop().start().resume()
+
+        assertFalse(second.isCompleted)
+    }
+
+    @Test
+    fun `GIVEN code is included WHEN other query params are included THEN they should be ignored in favor of code`() = runBlockingTest {
+            val intentUri = Uri.parse(
+                "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                        "/vpn/client/login/success?" +
+                        "code=$code&" +
+                        "token=myToken&" +
+                        "secret=mySecret&" +
+                        "lotteryTicketNumber=5" +
+                        "#Intent;category=android.intent.category.BROWSABLE;" +
+                        "launchFlags=0x14000000;" +
+                        "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                        "i.org.chromium.chrome.browser.referrer_id=18;" +
+                        "S.com.android.browser.application_id=com.android.chrome;end"
+            )
+            val intent = Intent("android.intent.action.VIEW", intentUri)
+
+            IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+            assertFalse(first.isCompleted)
+
+            buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+            assertTrue(first.isCompleted)
+            assertEquals(code, first.await())
+        }
+
+    @Test
+    fun `GIVEN code is not included WHEN other query params are included THEN nextAuthCodeReceived should not complete`() {
+        val intentUri = Uri.parse(
+            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                    "/vpn/client/login/success?" +
+                    "token=myToken&" +
+                    "secret=mySecret&" +
+                    "lotteryTicketNumber=5" +
+                    "#Intent;category=android.intent.category.BROWSABLE;" +
+                    "launchFlags=0x14000000;" +
+                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                    "i.org.chromium.chrome.browser.referrer_id=18;" +
+                    "S.com.android.browser.application_id=com.android.chrome;end"
+        )
+        val intent = Intent("android.intent.action.VIEW", intentUri)
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+        assertFalse(first.isCompleted)
+
+        buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+        assertFalse(first.isCompleted)
+    }
+
+    @Test
+    fun `valid codes should pass`() {
+        val failures = listOf(
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d",
+            "d6e9d39217dca1c1e09f742cd9d43705436f9b508eb460191d3743a62ad4bbedd6f64db531243b8b",
+            "94261d5d6baf934f4285ff7c89f0c855e82647a8fa5a44f6e4ce00ce41ea8ba22fb10f74c7bcf206",
+            "b1fe04adfef209ede45d5144cdbb9ab87924bded27b1a2310989da597fadf7b559886d07e9ca2394",
+            "572e46b7773c5fa358211aa5338f8ddbda8375e9047d662cb97e49803efd91883627a10a52e9ac1a",
+            "a9598960552e7fdd2d6ea9c403b762b198d318de7dc4f361c52ab2d6a2fd82b83417c3e33ce2c48f",
+            "a250663294300db88f5a065b96cb9ff257feb095173d643fb7560d39ba615cdd4d43682d1c9d5063",
+            "28aa232db5769b9510c428cb28fd563817a17d05301ff8f58b0f992de7576fcc8b1a4bf624d0d1c8"
+        ).filter { !validateAuthCode(it, shouldPass = true) }
+
+        if (failures.isNotEmpty()) {
+            println("${failures.size} valid codes failed validation: $failures")
+            throw RuntimeException()
+        }
+    }
+
+    @Test
+    fun `invalid codes should not pass`() {
+        // Codes should always be 80 characters of lowercase hexadecimal
+        val successes = listOf(
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // 79 chars
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37a8", // 81 chars
+            "d60b4de6f4a8a6e2228e82b328729d9cc1-666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes dash
+            "60b4de6f4a8a6e2228E82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3e~a3f4efa1ef3c37", // includes tilde
+            "d60b4d e6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes space
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a.5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes .
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c92/5bb7a3ea3f4efa1ef3c37", // includes /
+            "60b4de6f4a8a6e2228g82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d", // includes non-hexadecimal char (g)
+            "60b4de6f4a8a6e2228E82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d" // includes some uppercase
+        ).filter { !validateAuthCode(it, shouldPass = false) }
+
+        if (successes.isNotEmpty()) {
+            println("${successes.size} invalid codes passed validation: $successes")
+            throw RuntimeException()
+        }
+    }
+
+    private fun validateAuthCode(code: String, shouldPass: Boolean): Boolean {
+        val intentUri = Uri.parse(
+            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                    "/vpn/client/login/success?" +
+                    "code=$code&" +
+                    "#Intent;category=android.intent.category.BROWSABLE;" +
+                    "launchFlags=0x14000000;" +
+                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                    "i.org.chromium.chrome.browser.referrer_id=18;" +
+                    "S.com.android.browser.application_id=com.android.chrome;end"
+        )
+        val intent = Intent("android.intent.action.VIEW", intentUri)
+
+        val authCodeReceived = CompletableDeferred<AuthCode>()
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(authCodeReceived)
+        assertFalse(authCodeReceived.isCompleted)
+
+        buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+        return runBlocking {
+            shouldPass == (authCodeReceived.isCompleted && code == authCodeReceived.await())
+        }
+    }
+}

programming_language: Scala
filename: TestConstants.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+/**
+ * SDK level that works with Robolectric.
+ *
+ * Android Studio is (as of 9/8/20) packaged with JDK 1.8, while Android API 29+ requires
+ * Java 9 or newer. Aiming Robolectric tests at API 28 avoids this problem.
+ */
+const val WORKING_SDK = 28

programming_language: Scala
filename: TestGuardianApp.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+class TestGuardianApp : GuardianApp() {
+
+    override fun onCreate() {
+        super.onCreate()
+        // Avoid Robolectric IllegalStateException 'no theme'
+        setTheme(R.style.AppTheme)
+    }
+}

programming_language: Scala
filename: AuthCodeHelperTest.kt
change_type: ModificationType.ADD
diff: @@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.crypto
+
+import kotlin.random.Random
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.firefox.vpn.TestGuardianApp
+import org.mozilla.firefox.vpn.WORKING_SDK
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = TestGuardianApp::class, sdk = [WORKING_SDK])
+class AuthCodeHelperTest {
+
+    private lateinit var deterministicRandom: Random
+
+    @Before
+    fun setup() {
+        deterministicRandom = Random(seed = 42)
+    }
+
+    @Test
+    fun `code_challenges generated from the same code_verifier will always match`() {
+        val verifier = "i'm a code verifier!"
+
+        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        AuthCodeHelper.generateCodeChallenge("some")
+        AuthCodeHelper.generateCodeChallenge("other")
+        AuthCodeHelper.generateCodeChallenge("verifiers")
+
+        val challenge2 = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        assertEquals(challenge, challenge2)
+    }
+
+    @Test
+    fun `code_challenges generated different code_verifiers should not match`() {
+        val challenge1 = AuthCodeHelper.generateCodeChallenge("I'm the first verifier!")
+        val challenge2 = AuthCodeHelper.generateCodeChallenge("I'm totally unrelated to the other one")
+
+        assertNotEquals(challenge1, challenge2)
+    }
+
+    @Test
+    fun `deterministicRandom is deterministic`() {
+        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
+        assertEquals(
+            "qpUrMTIZVoB31IKqc.rznkjs9RiK.qdLWXktKUrqXnm-To8VSRb7iMELP4WH4vDknyPD3AuPDlHDJu8y8~DDb8G-Rdljl3r9rxsB5b",
+            verifier
+        )
+    }
+
+    @Test
+    fun `code_challenges should contain a trailing '=' for padding`() {
+        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
+        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)
+        assertEquals("=", challenge.takeLast(1))
+    }
+
+    @Test
+    fun `code_verifiers should not contain a trailing '='`() {
+        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
+        assertNotEquals("=", verifier.takeLast(1))
+    }
+
+    @Test
+    fun `code_challenges should be 44 characters long`() {
+        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
+        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)
+        assertEquals(44, challenge.length)
+    }
+
+    @Test
+    fun `code_challenge should sha256 hash its input`() {
+        // This uses hardcoded values provided by the backend team for manual testing
+        val verifier = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        assertEquals("1uxomN6H3axuWzYRcIp6ocLSmCkzScwabCmaHbcUnTg=", codeChallenge)
+    }
+
+    @Test
+    fun `code_challenge hashing should match server expectation`() {
+        val verifier = "pqpUrMTIZVoB31IKqc.rznkjs9RiK.qdLWXktKUrqXn"
+        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        assertEquals("o23Jen9tjz5kkUrEuwWiPr-XaNszrAKusCj80QxipsU=", codeChallenge)
+    }
+
+    @Test
+    fun `code_challenge hashing should handle all expected characters`() {
+        val verifier = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
+        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        assertEquals("RZ77XZltYSfl0BLxuGd8pHGJ4EoMoVDVuSWHgNq3RY8=", codeChallenge)
+    }
+}

programming_language: Scala
filename: SetSelectedServerUseCaseTest.kt
change_type: ModificationType.MODIFY
diff: @@ -27,9 +27,9 @@ class SetSelectedServerUseCaseTest {
         every { edit().putString(any(), any()).apply() } just Runs
     }
 
-    lateinit var serverRepository: ServerRepository
-    lateinit var selectedServerProvider: SelectedServerProvider
-    lateinit var setSelectedServerUseCase: SetSelectedServerUseCase
+    private lateinit var serverRepository: ServerRepository
+    private lateinit var selectedServerProvider: SelectedServerProvider
+    private lateinit var setSelectedServerUseCase: SetSelectedServerUseCase
 
     @Before
     fun setUp() {

programming_language: Scala
filename: LoginQueryBuilderTest.kt
change_type: ModificationType.DELETE
diff: @@ -1,37 +0,0 @@
-package org.mozilla.firefox.vpn.service
-
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-@RunWith(RobolectricTestRunner::class)
-class LoginQueryBuilderTest {
-
-    @Test
-    fun `only send valid referral info to backend`() {
-        var loginQueryBuilder = LoginQueryBuilder("")
-        assertTrue(loginQueryBuilder.build().isEmpty())
-
-        loginQueryBuilder = LoginQueryBuilder("aaa")
-        assertTrue(loginQueryBuilder.build().size == 1)
-
-        loginQueryBuilder = LoginQueryBuilder("aaa=")
-        assertTrue(loginQueryBuilder.build().size == 1)
-
-        loginQueryBuilder = LoginQueryBuilder("=aaa")
-        assertTrue(loginQueryBuilder.build().isEmpty())
-
-        loginQueryBuilder = LoginQueryBuilder("aaa=xxx")
-        assertTrue(loginQueryBuilder.build().size == 1)
-
-        loginQueryBuilder = LoginQueryBuilder("?aaa")
-        assertTrue(loginQueryBuilder.build().size == 1)
-
-        loginQueryBuilder = LoginQueryBuilder("aaa=xxx&bbb=zzz")
-        assertTrue(loginQueryBuilder.build().size == 2)
-
-        loginQueryBuilder = LoginQueryBuilder("aaa=xxx&=zzz")
-        assertTrue(loginQueryBuilder.build().size == 1)
-    }
-}

programming_language: unknown