Swift Firefox Account Manager
This commit is contained in:
Родитель
72bbb67ee0
Коммит
81d45d3df7
|
@ -3,3 +3,15 @@
|
|||
# Unreleased Changes
|
||||
|
||||
[Full Changelog](https://github.com/mozilla/application-services/compare/v0.48.3...master)
|
||||
|
||||
|
||||
## FxA Client
|
||||
|
||||
### What's New
|
||||
|
||||
- `FirefoxAccount` is now deprecated
|
||||
- Introducing `FxAccountManager` which provides a higher-level interface to Firefox Accounts. Among other things, this class handles (and can recover from) authentication errors, exposes device-related account methods, handles its own keychain storage and fires observer notifications for important account events.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- `FirefoxAccount.fromJSON(json: String)` has been replaced by the `FirefoxAccount(fromJsonState: String)` constructor.
|
||||
|
|
1
Cartfile
1
Cartfile
|
@ -1 +1,2 @@
|
|||
github "apple/swift-protobuf" ~> 1.0
|
||||
github "jrendel/SwiftKeychainWrapper" ~> 3.2.0
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
github "apple/swift-protobuf" "1.5.0"
|
||||
github "apple/swift-protobuf" "1.7.0"
|
||||
github "jrendel/SwiftKeychainWrapper" "3.4.0"
|
||||
|
|
|
@ -6,6 +6,7 @@ the details of which are reproduced below.
|
|||
|
||||
* [Mozilla Public License 2.0](#mozilla-public-license-20)
|
||||
* [Apache License 2.0](#apache-license-20)
|
||||
* [MIT License: SwiftKeychainWrapper](#mit-license-swiftkeychainwrapper)
|
||||
* [MIT License: aho-corasick, byteorder, memchr, termcolor](#mit-license-aho-corasick-byteorder-memchr-termcolor)
|
||||
* [MIT License: ansi_term](#mit-license-ansi_term)
|
||||
* [MIT License: atty](#mit-license-atty)
|
||||
|
@ -751,6 +752,37 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
```
|
||||
-------------
|
||||
## MIT License: SwiftKeychainWrapper
|
||||
|
||||
The following text applies to code linked from these dependencies:
|
||||
[SwiftKeychainWrapper](https://github.com/jrendel/SwiftKeychainWrapper)
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Jason Rendel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
```
|
||||
-------------
|
||||
## MIT License: aho-corasick, byteorder, memchr, termcolor
|
||||
|
|
|
@ -10,6 +10,11 @@ public enum FirefoxAccountError: LocalizedError {
|
|||
case unspecified(message: String)
|
||||
case panic(message: String)
|
||||
|
||||
// Trying to finish an authentication that was never started with begin(...)Flow.
|
||||
case noExistingAuthFlow
|
||||
// Trying to finish a different authentication flow.
|
||||
case wrongAuthFlow
|
||||
|
||||
/// Our implementation of the localizedError protocol -- (This shows up in Sentry)
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
|
@ -21,6 +26,10 @@ public enum FirefoxAccountError: LocalizedError {
|
|||
return "FirefoxAccountError.unspecified: \(message)"
|
||||
case let .panic(message):
|
||||
return "FirefoxAccountError.panic: \(message)"
|
||||
case .noExistingAuthFlow:
|
||||
return "FirefoxAccountError.noExistingAuthFlow"
|
||||
case .wrongAuthFlow:
|
||||
return "FirefoxAccountError.wrongAuthFlow"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,196 +3,26 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
open class FxAConfig {
|
||||
// FIXME: these should be lower case.
|
||||
// swiftlint:disable identifier_name
|
||||
public enum Server: String {
|
||||
case Release = "https://accounts.firefox.com"
|
||||
case Stable = "https://stable.dev.lcip.org"
|
||||
case Dev = "https://accounts.stage.mozaws.net"
|
||||
}
|
||||
|
||||
// swiftlint:enable identifier_name
|
||||
|
||||
let contentUrl: String
|
||||
let clientId: String
|
||||
let redirectUri: String
|
||||
|
||||
public init(contentUrl: String, clientId: String, redirectUri: String) {
|
||||
self.contentUrl = contentUrl
|
||||
self.clientId = clientId
|
||||
self.redirectUri = redirectUri
|
||||
}
|
||||
|
||||
public init(withServer server: Server, clientId: String, redirectUri: String) {
|
||||
contentUrl = server.rawValue
|
||||
self.clientId = clientId
|
||||
self.redirectUri = redirectUri
|
||||
}
|
||||
|
||||
public static func release(clientId: String, redirectUri: String) -> FxAConfig {
|
||||
return FxAConfig(withServer: FxAConfig.Server.Release, clientId: clientId, redirectUri: redirectUri)
|
||||
}
|
||||
|
||||
public static func stable(clientId: String, redirectUri: String) -> FxAConfig {
|
||||
return FxAConfig(withServer: FxAConfig.Server.Stable, clientId: clientId, redirectUri: redirectUri)
|
||||
}
|
||||
|
||||
public static func dev(clientId: String, redirectUri: String) -> FxAConfig {
|
||||
return FxAConfig(withServer: FxAConfig.Server.Dev, clientId: clientId, redirectUri: redirectUri)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol PersistCallback {
|
||||
func persist(json: String)
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "com.mozilla.firefox-account")
|
||||
|
||||
open class FirefoxAccount {
|
||||
private let raw: UInt64
|
||||
private var persistCallback: PersistCallback?
|
||||
|
||||
private init(raw: UInt64) {
|
||||
self.raw = raw
|
||||
}
|
||||
|
||||
/// Create a `FirefoxAccount` from scratch. This is suitable for callers using the
|
||||
/// OAuth Flow.
|
||||
/// Please note that the `FxAConfig` provided will be consumed and therefore
|
||||
/// should not be re-used.
|
||||
public convenience init(config: FxAConfig) throws {
|
||||
let pointer = try queue.sync {
|
||||
try FirefoxAccountError.unwrap { err in
|
||||
fxa_new(config.contentUrl, config.clientId, config.redirectUri, err)
|
||||
}
|
||||
}
|
||||
self.init(raw: pointer)
|
||||
}
|
||||
|
||||
/// Restore a previous instance of `FirefoxAccount` from a serialized state (obtained with `toJSON(...)`).
|
||||
open class func fromJSON(state: String) throws -> FirefoxAccount {
|
||||
return try queue.sync {
|
||||
let handle = try FirefoxAccountError.unwrap { err in fxa_from_json(state, err) }
|
||||
return FirefoxAccount(raw: handle)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if self.raw != 0 {
|
||||
queue.sync {
|
||||
try! FirefoxAccountError.unwrap { err in
|
||||
// Is `try!` the right thing to do? We should only hit an error here
|
||||
// for panics and handle misuse, both inidicate bugs in our code
|
||||
// (the first in the rust code, the 2nd in this swift wrapper).
|
||||
fxa_free(self.raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes the state of a `FirefoxAccount` instance. It can be restored
|
||||
/// later with `fromJSON(...)`. It is the responsability of the caller to
|
||||
/// persist that serialized state regularly (after operations that mutate
|
||||
/// `FirefoxAccount`) in a **secure** location.
|
||||
open func toJSON() throws -> String {
|
||||
return try queue.sync {
|
||||
try self.toJSONInternal()
|
||||
}
|
||||
}
|
||||
|
||||
private func toJSONInternal() throws -> String {
|
||||
return String(freeingFxaString: try FirefoxAccountError.unwrap { err in
|
||||
fxa_to_json(self.raw, err)
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers a persistance callback. The callback will get called every time
|
||||
/// the `FirefoxAccount` state needs to be saved. The callback must
|
||||
/// persist the passed string in a secure location (like the keychain).
|
||||
public func registerPersistCallback(_ cb: PersistCallback) {
|
||||
persistCallback = cb
|
||||
}
|
||||
|
||||
/// Unregisters a persistance callback.
|
||||
public func unregisterPersistCallback() {
|
||||
persistCallback = nil
|
||||
}
|
||||
|
||||
private func tryPersistState() {
|
||||
queue.async {
|
||||
guard let cb = self.persistCallback else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let json = try self.toJSONInternal()
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
cb.persist(json: json)
|
||||
}
|
||||
} catch {
|
||||
// Ignore the error because the prior operation might have worked,
|
||||
// but still log it.
|
||||
os_log("FirefoxAccount internal state serialization failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This class provides low-level access to `RustFxAccount` through various asynchronous wrappers.
|
||||
/// It should not be used anymore and is kept for backwards compatbility for the Lockwise iOS project.
|
||||
@available(*, deprecated, message: "Use FxaAccountManager instead")
|
||||
open class FirefoxAccount: RustFxAccount {
|
||||
/// Gets the logged-in user profile.
|
||||
/// Throws `FirefoxAccountError.Unauthorized` if we couldn't find any suitable access token
|
||||
/// to make that call. The caller should then start the OAuth Flow again with
|
||||
/// the "profile" scope.
|
||||
open func getProfile(completionHandler: @escaping (Profile?, Error?) -> Void) {
|
||||
queue.async {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
let profileBuffer = try FirefoxAccountError.unwrap { err in
|
||||
fxa_profile(self.raw, false, err)
|
||||
}
|
||||
let msg = try! MsgTypes_Profile(serializedData: Data(rustBuffer: profileBuffer))
|
||||
fxa_bytebuffer_free(profileBuffer)
|
||||
let profile = Profile(msg: msg)
|
||||
let profile = try super.getProfile()
|
||||
DispatchQueue.main.async { completionHandler(profile, nil) }
|
||||
self.tryPersistState()
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler(nil, error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open func getTokenServerEndpointURL() throws -> URL {
|
||||
return try queue.sync {
|
||||
URL(string: String(freeingFxaString: try FirefoxAccountError.unwrap { err in
|
||||
fxa_get_token_server_endpoint_url(self.raw, err)
|
||||
}))!
|
||||
}
|
||||
}
|
||||
|
||||
open func getConnectionSuccessURL() throws -> URL {
|
||||
return try queue.sync {
|
||||
URL(string: String(freeingFxaString: try FirefoxAccountError.unwrap { err in
|
||||
fxa_get_connection_success_url(self.raw, err)
|
||||
}))!
|
||||
}
|
||||
}
|
||||
|
||||
open func getManageAccountURL(entrypoint: String) throws -> URL {
|
||||
return try queue.sync {
|
||||
URL(string: String(freeingFxaString: try FirefoxAccountError.unwrap { err in
|
||||
fxa_get_manage_account_url(self.raw, entrypoint, err)
|
||||
}))!
|
||||
}
|
||||
}
|
||||
|
||||
open func getManageDevicesURL(entrypoint: String) throws -> URL {
|
||||
return try queue.sync {
|
||||
URL(string: String(freeingFxaString: try FirefoxAccountError.unwrap { err in
|
||||
fxa_get_manage_devices_url(self.raw, entrypoint, err)
|
||||
}))!
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a OAuth token by starting a new OAuth flow.
|
||||
///
|
||||
/// This function returns a URL string that the caller should open in a webview.
|
||||
|
@ -201,12 +31,9 @@ open class FirefoxAccount {
|
|||
/// the caller must intercept that redirection, extract the `code` and `state` query parameters and call
|
||||
/// `completeOAuthFlow(...)` to complete the flow.
|
||||
open func beginOAuthFlow(scopes: [String], completionHandler: @escaping (URL?, Error?) -> Void) {
|
||||
queue.async {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
let scope = scopes.joined(separator: " ")
|
||||
let url = URL(string: String(freeingFxaString: try FirefoxAccountError.unwrap { err in
|
||||
fxa_begin_oauth_flow(self.raw, scope, err)
|
||||
}))!
|
||||
let url = try super.beginOAuthFlow(scopes: scopes)
|
||||
DispatchQueue.main.async { completionHandler(url, nil) }
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler(nil, error) }
|
||||
|
@ -219,13 +46,10 @@ open class FirefoxAccount {
|
|||
/// This resulting token might not have all the `scopes` the caller have requested (e.g. the user
|
||||
/// might have denied some of them): it is the responsibility of the caller to accomodate that.
|
||||
open func completeOAuthFlow(code: String, state: String, completionHandler: @escaping (Void, Error?) -> Void) {
|
||||
queue.async {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
try FirefoxAccountError.unwrap { err in
|
||||
fxa_complete_oauth_flow(self.raw, code, state, err)
|
||||
}
|
||||
try super.completeOAuthFlow(code: code, state: state)
|
||||
DispatchQueue.main.async { completionHandler((), nil) }
|
||||
self.tryPersistState()
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler((), error) }
|
||||
}
|
||||
|
@ -238,15 +62,9 @@ open class FirefoxAccount {
|
|||
/// for this scope. The caller should then start the OAuth Flow again with
|
||||
/// the desired scope.
|
||||
open func getAccessToken(scope: String, completionHandler: @escaping (AccessTokenInfo?, Error?) -> Void) {
|
||||
queue.async {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
let infoBuffer = try FirefoxAccountError.unwrap { err in
|
||||
fxa_get_access_token(self.raw, scope, err)
|
||||
}
|
||||
self.tryPersistState()
|
||||
let msg = try! MsgTypes_AccessTokenInfo(serializedData: Data(rustBuffer: infoBuffer))
|
||||
fxa_bytebuffer_free(infoBuffer)
|
||||
let tokenInfo = AccessTokenInfo(msg: msg)
|
||||
let tokenInfo = try super.getAccessToken(scope: scope)
|
||||
DispatchQueue.main.async { completionHandler(tokenInfo, nil) }
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler(nil, error) }
|
||||
|
@ -256,14 +74,9 @@ open class FirefoxAccount {
|
|||
|
||||
/// Check whether the refreshToken is active
|
||||
open func checkAuthorizationStatus(completionHandler: @escaping (IntrospectInfo?, Error?) -> Void) {
|
||||
queue.async {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
let infoBuffer = try FirefoxAccountError.unwrap { err in
|
||||
fxa_check_authorization_status(self.raw, err)
|
||||
}
|
||||
let msg = try! MsgTypes_IntrospectInfo(serializedData: Data(rustBuffer: infoBuffer))
|
||||
fxa_bytebuffer_free(infoBuffer)
|
||||
let tokenInfo = IntrospectInfo(msg: msg)
|
||||
let tokenInfo = try super.checkAuthorizationStatus()
|
||||
DispatchQueue.main.async { completionHandler(tokenInfo, nil) }
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler(nil, error) }
|
||||
|
@ -277,11 +90,9 @@ open class FirefoxAccount {
|
|||
/// so the caller can try to call `getAccessToken` or `getProfile`
|
||||
/// again.
|
||||
open func clearAccessTokenCache(completionHandler: @escaping (Void, Error?) -> Void) {
|
||||
queue.async {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
try FirefoxAccountError.unwrap { err in
|
||||
fxa_clear_access_token_cache(self.raw, err)
|
||||
}
|
||||
try super.clearAccessTokenCache()
|
||||
DispatchQueue.main.async { completionHandler((), nil) }
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler((), error) }
|
||||
|
@ -292,79 +103,13 @@ open class FirefoxAccount {
|
|||
/// Disconnect from the account and optionaly destroy our device record.
|
||||
/// `beginOAuthFlow(...)` will need to be called to reconnect.
|
||||
open func disconnect(completionHandler: @escaping (Void, Error?) -> Void) {
|
||||
queue.async {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
try FirefoxAccountError.unwrap { err in
|
||||
fxa_disconnect(self.raw, err)
|
||||
}
|
||||
try super.disconnect()
|
||||
DispatchQueue.main.async { completionHandler((), nil) }
|
||||
self.tryPersistState()
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler((), error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ScopedKey {
|
||||
public let kty: String
|
||||
public let scope: String
|
||||
public let k: String
|
||||
public let kid: String
|
||||
|
||||
internal init(msg: MsgTypes_ScopedKey) {
|
||||
kty = msg.kty
|
||||
scope = msg.scope
|
||||
k = msg.k
|
||||
kid = msg.kid
|
||||
}
|
||||
}
|
||||
|
||||
public struct AccessTokenInfo {
|
||||
public let scope: String
|
||||
public let token: String
|
||||
public let key: ScopedKey?
|
||||
public let expiresAt: Date
|
||||
|
||||
internal init(msg: MsgTypes_AccessTokenInfo) {
|
||||
scope = msg.scope
|
||||
token = msg.token
|
||||
key = msg.hasKey ? ScopedKey(msg: msg.key) : nil
|
||||
expiresAt = Date(timeIntervalSince1970: Double(msg.expiresAt))
|
||||
}
|
||||
}
|
||||
|
||||
public struct IntrospectInfo {
|
||||
public let active: Bool
|
||||
public let tokenType: String
|
||||
public let scope: String?
|
||||
public let exp: Date
|
||||
public let iss: String?
|
||||
|
||||
internal init(msg: MsgTypes_IntrospectInfo) {
|
||||
active = msg.active
|
||||
tokenType = msg.tokenType
|
||||
scope = msg.hasScope ? msg.scope : nil
|
||||
exp = Date(timeIntervalSince1970: Double(msg.exp))
|
||||
iss = msg.hasIss ? msg.iss : nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct Avatar {
|
||||
public let url: String
|
||||
public let isDefault: Bool
|
||||
}
|
||||
|
||||
public struct Profile {
|
||||
public let uid: String
|
||||
public let email: String
|
||||
public let avatar: Avatar?
|
||||
public let displayName: String?
|
||||
|
||||
internal init(msg: MsgTypes_Profile) {
|
||||
uid = msg.uid
|
||||
email = msg.email
|
||||
avatar = msg.hasAvatar ? Avatar(url: msg.avatar, isDefault: msg.avatarDefault) : nil
|
||||
displayName = msg.hasDisplayName ? msg.displayName : nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This class inherits from `RustFxAccount` and adds:
|
||||
/// - Automatic state persistence through `PersistCallback`.
|
||||
/// - Auth error signaling through observer notifications.
|
||||
class FxAccount: RustFxAccount {
|
||||
private var persistCallback: PersistCallback?
|
||||
|
||||
/// Registers a persistance callback. The callback will get called every time
|
||||
/// the `FxAccounts` state needs to be saved. The callback must
|
||||
/// persist the passed string in a secure location (like the keychain).
|
||||
public func registerPersistCallback(_ cb: PersistCallback) {
|
||||
persistCallback = cb
|
||||
}
|
||||
|
||||
/// Unregisters a persistance callback.
|
||||
public func unregisterPersistCallback() {
|
||||
persistCallback = nil
|
||||
}
|
||||
|
||||
override func getProfile() throws -> Profile {
|
||||
defer { tryPersistState() }
|
||||
return try notifyAuthErrors {
|
||||
try super.getProfile()
|
||||
}
|
||||
}
|
||||
|
||||
override func beginOAuthFlow(scopes: [String]) throws -> URL {
|
||||
return try notifyAuthErrors {
|
||||
try super.beginOAuthFlow(scopes: scopes)
|
||||
}
|
||||
}
|
||||
|
||||
override func beginPairingFlow(pairingUrl: String, scopes: [String]) throws -> URL {
|
||||
return try notifyAuthErrors {
|
||||
try super.beginPairingFlow(pairingUrl: pairingUrl, scopes: scopes)
|
||||
}
|
||||
}
|
||||
|
||||
override func completeOAuthFlow(code: String, state: String) throws {
|
||||
defer { tryPersistState() }
|
||||
try notifyAuthErrors {
|
||||
try super.completeOAuthFlow(code: code, state: state)
|
||||
}
|
||||
}
|
||||
|
||||
override func getAccessToken(scope: String) throws -> AccessTokenInfo {
|
||||
defer { tryPersistState() }
|
||||
return try notifyAuthErrors {
|
||||
try super.getAccessToken(scope: scope)
|
||||
}
|
||||
}
|
||||
|
||||
override func disconnect() throws {
|
||||
defer { tryPersistState() }
|
||||
try super.disconnect()
|
||||
}
|
||||
|
||||
override func pollDeviceCommands() throws -> [DeviceEvent] {
|
||||
defer { tryPersistState() }
|
||||
return try notifyAuthErrors {
|
||||
try super.pollDeviceCommands()
|
||||
}
|
||||
}
|
||||
|
||||
override func fetchDevices() throws -> [Device] {
|
||||
return try notifyAuthErrors {
|
||||
try super.fetchDevices()
|
||||
}
|
||||
}
|
||||
|
||||
override func setDevicePushSubscription(endpoint: String, publicKey: String, authKey: String) throws {
|
||||
try notifyAuthErrors {
|
||||
try super.setDevicePushSubscription(endpoint: endpoint, publicKey: publicKey, authKey: authKey)
|
||||
}
|
||||
}
|
||||
|
||||
override func setDeviceDisplayName(_ name: String) throws {
|
||||
try notifyAuthErrors {
|
||||
try super.setDeviceDisplayName(name)
|
||||
}
|
||||
}
|
||||
|
||||
override func handlePushMessage(payload: String) throws -> [DeviceEvent] {
|
||||
defer { tryPersistState() }
|
||||
return try notifyAuthErrors {
|
||||
try super.handlePushMessage(payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
override func sendSingleTab(targetId: String, title: String, url: String) throws {
|
||||
return try notifyAuthErrors {
|
||||
try super.sendSingleTab(targetId: targetId, title: title, url: url)
|
||||
}
|
||||
}
|
||||
|
||||
override func initializeDevice(
|
||||
name: String,
|
||||
deviceType: DeviceType,
|
||||
supportedCapabilities: [DeviceCapability]
|
||||
) throws {
|
||||
defer { tryPersistState() }
|
||||
try notifyAuthErrors {
|
||||
try super.initializeDevice(name: name, deviceType: deviceType, supportedCapabilities: supportedCapabilities)
|
||||
}
|
||||
}
|
||||
|
||||
override func ensureCapabilities(supportedCapabilities: [DeviceCapability]) throws {
|
||||
defer { tryPersistState() }
|
||||
try notifyAuthErrors {
|
||||
try super.ensureCapabilities(supportedCapabilities: supportedCapabilities)
|
||||
}
|
||||
}
|
||||
|
||||
override func checkAuthorizationStatus() throws -> IntrospectInfo {
|
||||
return try notifyAuthErrors {
|
||||
try self.checkAuthorizationStatus()
|
||||
}
|
||||
}
|
||||
|
||||
override func clearAccessTokenCache() throws {
|
||||
try notifyAuthErrors {
|
||||
try self.clearAccessTokenCache()
|
||||
}
|
||||
}
|
||||
|
||||
private func tryPersistState() {
|
||||
DispatchQueue.global().async {
|
||||
guard let cb = self.persistCallback else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let json = try self.toJSON()
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
cb.persist(json: json)
|
||||
}
|
||||
} catch {
|
||||
// Ignore the error because the prior operation might have worked,
|
||||
// but still log it.
|
||||
FxALog.error("FxAccounts internal state serialization failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal func notifyAuthErrors<T>(_ cb: () throws -> T) rethrows -> T {
|
||||
do {
|
||||
return try cb()
|
||||
} catch let error as FirefoxAccountError {
|
||||
if case let .unauthorized(msg) = error {
|
||||
FxALog.debug("Auth error caught: \(msg)")
|
||||
notifyAuthError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
internal func notifyAuthError() {
|
||||
NotificationCenter.default.post(name: .accountAuthException, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol PersistCallback {
|
||||
func persist(json: String)
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
|
||||
open class FxAConfig {
|
||||
// FIXME: these should be lower case.
|
||||
// swiftlint:disable identifier_name
|
||||
public enum Server: String {
|
||||
case Release = "https://accounts.firefox.com"
|
||||
case Stable = "https://stable.dev.lcip.org"
|
||||
case Dev = "https://accounts.stage.mozaws.net"
|
||||
}
|
||||
|
||||
// swiftlint:enable identifier_name
|
||||
|
||||
let contentUrl: String
|
||||
let clientId: String
|
||||
let redirectUri: String
|
||||
|
||||
public init(contentUrl: String, clientId: String, redirectUri: String) {
|
||||
self.contentUrl = contentUrl
|
||||
self.clientId = clientId
|
||||
self.redirectUri = redirectUri
|
||||
}
|
||||
|
||||
public init(withServer server: Server, clientId: String, redirectUri: String) {
|
||||
contentUrl = server.rawValue
|
||||
self.clientId = clientId
|
||||
self.redirectUri = redirectUri
|
||||
}
|
||||
|
||||
public static func release(clientId: String, redirectUri: String) -> FxAConfig {
|
||||
return FxAConfig(withServer: FxAConfig.Server.Release, clientId: clientId, redirectUri: redirectUri)
|
||||
}
|
||||
|
||||
public static func stable(clientId: String, redirectUri: String) -> FxAConfig {
|
||||
return FxAConfig(withServer: FxAConfig.Server.Stable, clientId: clientId, redirectUri: redirectUri)
|
||||
}
|
||||
|
||||
public static func dev(clientId: String, redirectUri: String) -> FxAConfig {
|
||||
return FxAConfig(withServer: FxAConfig.Server.Dev, clientId: clientId, redirectUri: redirectUri)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct Device {
|
||||
public let id: String
|
||||
public let displayName: String
|
||||
public let deviceType: DeviceType
|
||||
public let isCurrentDevice: Bool
|
||||
public let lastAccessTime: UInt64?
|
||||
public let capabilities: [DeviceCapability]
|
||||
public let subscriptionExpired: Bool
|
||||
public let subscription: DevicePushSubscription?
|
||||
|
||||
internal static func fromCollectionMsg(msg: MsgTypes_Devices) -> [Device] {
|
||||
msg.devices.map { Device(msg: $0) }
|
||||
}
|
||||
|
||||
internal init(msg: MsgTypes_Device) {
|
||||
id = msg.id
|
||||
displayName = msg.displayName
|
||||
deviceType = DeviceType.fromMsg(msg: msg.type)
|
||||
isCurrentDevice = msg.isCurrentDevice
|
||||
lastAccessTime = msg.hasLastAccessTime ? msg.lastAccessTime : nil
|
||||
capabilities = msg.capabilities.map { DeviceCapability.fromMsg(msg: $0) }
|
||||
subscriptionExpired = msg.pushEndpointExpired
|
||||
subscription = msg.hasPushSubscription ?
|
||||
DevicePushSubscription(msg: msg.pushSubscription) :
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeviceType {
|
||||
case desktop
|
||||
case mobile
|
||||
case tablet
|
||||
case tv
|
||||
case vr
|
||||
case unknown
|
||||
|
||||
internal static func fromMsg(msg: MsgTypes_Device.TypeEnum) -> DeviceType {
|
||||
switch msg {
|
||||
case .desktop: return .desktop
|
||||
case .mobile: return .mobile
|
||||
case .tablet: return .tablet
|
||||
case .tv: return .tv
|
||||
case .vr: return .vr
|
||||
case .unknown: return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
internal func toMsg() -> MsgTypes_Device.TypeEnum {
|
||||
switch self {
|
||||
case .desktop: return .desktop
|
||||
case .mobile: return .mobile
|
||||
case .tablet: return .tablet
|
||||
case .tv: return .tv
|
||||
case .vr: return .vr
|
||||
case .unknown: return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeviceCapability {
|
||||
case sendTab
|
||||
|
||||
internal static func fromMsg(msg: MsgTypes_Device.Capability) -> DeviceCapability {
|
||||
switch msg {
|
||||
case .sendTab: return .sendTab
|
||||
}
|
||||
}
|
||||
|
||||
internal func toMsg() -> MsgTypes_Device.Capability {
|
||||
switch self {
|
||||
case .sendTab: return .sendTab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == DeviceCapability {
|
||||
internal func toCollectionMsg() -> MsgTypes_Capabilities {
|
||||
MsgTypes_Capabilities.with {
|
||||
$0.capability = self.map { $0.toMsg() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevicePushSubscription {
|
||||
public let endpoint: String
|
||||
public let publicKey: String
|
||||
public let authKey: String
|
||||
|
||||
public init(endpoint: String, publicKey: String, authKey: String) {
|
||||
self.endpoint = endpoint
|
||||
self.publicKey = publicKey
|
||||
self.authKey = authKey
|
||||
}
|
||||
|
||||
internal init(msg: MsgTypes_Device.PushSubscription) {
|
||||
endpoint = msg.endpoint
|
||||
publicKey = msg.publicKey
|
||||
authKey = msg.authKey
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeviceEvent {
|
||||
case tabReceived(Device?, [TabData])
|
||||
|
||||
internal static func fromCollectionMsg(msg: MsgTypes_AccountEvents) -> [DeviceEvent] {
|
||||
msg.events.map { DeviceEvent.fromMsg(msg: $0) }
|
||||
}
|
||||
|
||||
internal static func fromMsg(msg: MsgTypes_AccountEvent) -> DeviceEvent {
|
||||
switch msg.type {
|
||||
case .tabReceived: do {
|
||||
let device = msg.tabReceivedData.hasFrom ? Device(msg: msg.tabReceivedData.from) : nil
|
||||
let entries = msg.tabReceivedData.entries.map { TabData(title: $0.title, url: $0.url) }
|
||||
return .tabReceived(device, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct TabData {
|
||||
public let title: String
|
||||
public let url: String
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
static let constellationStateUpdate = Notification.Name("constellationStateUpdate")
|
||||
}
|
||||
|
||||
public struct ConstellationState {
|
||||
public let localDevice: Device?
|
||||
public let remoteDevices: [Device]
|
||||
}
|
||||
|
||||
public class DeviceConstellation {
|
||||
var constellationState: ConstellationState?
|
||||
let account: FxAccount
|
||||
|
||||
init(account: FxAccount) {
|
||||
self.account = account
|
||||
}
|
||||
|
||||
/// Get local + remote devices synchronously.
|
||||
/// Note that this state might be empty, which should handle by calling `refreshState()`
|
||||
/// A `.constellationStateUpdate` notification is fired if the device list changes at any time.
|
||||
public func state() -> ConstellationState? {
|
||||
return constellationState
|
||||
}
|
||||
|
||||
/// Refresh the list of remote devices.
|
||||
/// A `.constellationStateUpdate` notification might get fired once the new device list is fetched.
|
||||
public func refreshState() {
|
||||
DispatchQueue.global().async {
|
||||
FxALog.info("Refreshing device list...")
|
||||
do {
|
||||
let devices = try self.account.fetchDevices()
|
||||
let localDevice = devices.first { $0.isCurrentDevice }
|
||||
if localDevice?.subscriptionExpired ?? false {
|
||||
FxALog.debug("Current device needs push endpoint registration.")
|
||||
}
|
||||
let remoteDevices = devices.filter { !$0.isCurrentDevice }
|
||||
|
||||
let newState = ConstellationState(localDevice: localDevice, remoteDevices: remoteDevices)
|
||||
self.constellationState = newState
|
||||
|
||||
FxALog.debug("Refreshed device list; saw \(devices.count) device(s).")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .constellationStateUpdate,
|
||||
object: nil,
|
||||
userInfo: ["newState": newState]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
FxALog.error("Failure fetching the device list: \(error).")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the local device name.
|
||||
public func setLocalDeviceName(name: String) {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
try self.account.setDeviceDisplayName(name)
|
||||
// Update our list of devices in the background to reflect the change.
|
||||
self.refreshState()
|
||||
} catch {
|
||||
FxALog.error("Failure changing the local device name: \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll for device events we might have missed (e.g. Push notification missed, or device offline).
|
||||
/// Your app should probably call this on a regular basic (e.g. once a day).
|
||||
public func pollForEvents(completionHandler: @escaping (Result<[DeviceEvent], Error>) -> Void) {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
let events = try self.account.pollDeviceCommands()
|
||||
DispatchQueue.main.async { completionHandler(.success(events)) }
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler(.failure(error)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an event to another device such as Send Tab.
|
||||
public func sendEventToDevice(targetDeviceId: String, e: DeviceEventOutgoing) {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
switch e {
|
||||
case let .sendTab(title, url): do {
|
||||
try self.account.sendSingleTab(targetId: targetDeviceId, title: title, url: url)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
FxALog.error("Error sending event to another device: \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the local AutoPush subscription with the FxA server.
|
||||
public func setDevicePushSubscription(sub: DevicePushSubscription) {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
try self.account.setDevicePushSubscription(
|
||||
endpoint: sub.endpoint,
|
||||
publicKey: sub.publicKey,
|
||||
authKey: sub.authKey
|
||||
)
|
||||
} catch {
|
||||
FxALog.error("Failure setting push subscription: \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Once Push has decrypted a payload, send the payload to this method
|
||||
/// which will tell the app what to do with it in form of `DeviceEvents`.
|
||||
public func processRawIncomingDeviceEvent(pushPayload: String,
|
||||
completionHandler: @escaping (Result<[DeviceEvent], Error>) -> Void) {
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
let events = try self.account.handlePushMessage(payload: pushPayload)
|
||||
DispatchQueue.main.async { completionHandler(.success(events)) }
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler(.failure(error)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal func initDevice(name: String, type: DeviceType, capabilities: [DeviceCapability]) {
|
||||
// This method is called by `FxAccountManager` on its own asynchronous queue, hence
|
||||
// no wrapping in a `DispatchQueue.global().async`.
|
||||
assert(!Thread.isMainThread)
|
||||
do {
|
||||
try account.initializeDevice(name: name, deviceType: type, supportedCapabilities: capabilities)
|
||||
} catch {
|
||||
FxALog.error("Failure initializing device: \(error).")
|
||||
}
|
||||
}
|
||||
|
||||
internal func ensureCapabilities(capabilities: [DeviceCapability]) {
|
||||
// This method is called by `FxAccountManager` on its own asynchronous queue, hence
|
||||
// no wrapping in a `DispatchQueue.global().async`.
|
||||
assert(!Thread.isMainThread)
|
||||
do {
|
||||
try account.ensureCapabilities(supportedCapabilities: capabilities)
|
||||
} catch {
|
||||
FxALog.error("Failure ensuring device capabilities: \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeviceEventOutgoing {
|
||||
case sendTab(title: String, url: String)
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
class FxALog {
|
||||
private static let log = OSLog(
|
||||
subsystem: Bundle.main.bundleIdentifier!,
|
||||
category: "FxAccountManager"
|
||||
)
|
||||
|
||||
internal static func info(_ msg: String) {
|
||||
log(msg, type: .info)
|
||||
}
|
||||
|
||||
internal static func debug(_ msg: String) {
|
||||
log(msg, type: .debug)
|
||||
}
|
||||
|
||||
internal static func error(_ msg: String) {
|
||||
log(msg, type: .error)
|
||||
}
|
||||
|
||||
private static func log(_ msg: String, type: OSLogType) {
|
||||
os_log("%@", log: log, type: type, msg)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,568 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
static let accountLoggedOut = Notification.Name("accountLoggedOut")
|
||||
static let accountAuthProblems = Notification.Name("accountAuthProblems")
|
||||
static let accountAuthenticated = Notification.Name("accountAuthenticated")
|
||||
static let accountProfileUpdate = Notification.Name("accountProfileUpdate")
|
||||
}
|
||||
|
||||
// swiftlint:disable type_body_length
|
||||
open class FxaAccountManager {
|
||||
let accountStorage: KeyChainAccountStorage
|
||||
let config: FxAConfig
|
||||
let deviceConfig: DeviceConfig
|
||||
let applicationScopes: [String]
|
||||
|
||||
var acct: FxAccount?
|
||||
var account: FxAccount? {
|
||||
get { return acct }
|
||||
set {
|
||||
acct = newValue
|
||||
if let acc = acct {
|
||||
constellation = makeDeviceConstellation(account: acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var state: AccountState = AccountState.start
|
||||
var profile: Profile?
|
||||
var constellation: DeviceConstellation?
|
||||
var latestOAuthStateParam: String?
|
||||
|
||||
/// Instanciate the account manager.
|
||||
/// This class is intended to be long-lived within your app.
|
||||
/// `keychainAccessGroup` is especially important if you are
|
||||
/// using the manager in iOS App Extensions.
|
||||
public required init(
|
||||
config: FxAConfig,
|
||||
deviceConfig: DeviceConfig,
|
||||
applicationScopes: [String] = [OAuthScope.profile, OAuthScope.oldSync],
|
||||
keychainAccessGroup: String? = nil
|
||||
) {
|
||||
self.config = config
|
||||
self.deviceConfig = deviceConfig
|
||||
self.applicationScopes = applicationScopes
|
||||
accountStorage = KeyChainAccountStorage(keychainAccessGroup: keychainAccessGroup)
|
||||
setupAuthExceptionsListener()
|
||||
}
|
||||
|
||||
private lazy var statePersistenceCallback: FxAStatePersistenceCallback = {
|
||||
FxAStatePersistenceCallback(manager: self)
|
||||
}()
|
||||
|
||||
/// Starts the FxA account manager and advances the state machine.
|
||||
/// It is required to call this method before doing anything else with the manager.
|
||||
/// Note that as a result of this initialization, notifications such as `accountAuthenticated` might be
|
||||
/// fired.
|
||||
public func initialize(completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
processEvent(event: .initialize) {
|
||||
DispatchQueue.main.async { completionHandler(Result.success(())) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true the user is currently logged-in to an account, no matter if they need to reconnect or not.
|
||||
public func hasAccount() -> Bool {
|
||||
return state == .authenticatedWithProfile ||
|
||||
state == .authenticatedNoProfile ||
|
||||
state == .authenticationProblem
|
||||
}
|
||||
|
||||
/// Returns true if the account needs re-authentication.
|
||||
/// Your app should present the option to start a new OAuth flow.
|
||||
public func accountNeedsReauth() -> Bool {
|
||||
return state == .authenticationProblem
|
||||
}
|
||||
|
||||
/// Begins a new authentication flow.
|
||||
///
|
||||
/// This function returns a URL string that the caller should open in a webview.
|
||||
///
|
||||
/// Once the user has confirmed the authorization grant, they will get redirected to `redirect_url`:
|
||||
/// the caller must intercept that redirection, extract the `code` and `state` query parameters and call
|
||||
/// `finishAuthentication(...)` to complete the flow.
|
||||
public func beginAuthentication(completionHandler: @escaping (Result<URL, Error>) -> Void) {
|
||||
FxALog.info("beginAuthentication")
|
||||
DispatchQueue.global().async {
|
||||
let result = self.updatingLatestAuthState { account in
|
||||
try account.beginOAuthFlow(scopes: self.applicationScopes)
|
||||
}
|
||||
DispatchQueue.main.async { completionHandler(result) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Begins a new pairing flow.
|
||||
/// The pairing URL corresponds to the URL shown by the other pairing party,
|
||||
/// scanned by your app QR code reader.
|
||||
///
|
||||
/// This function returns a URL string that the caller should open in a webview.
|
||||
///
|
||||
/// Once the user has confirmed the authorization grant, they will get redirected to `redirect_url`:
|
||||
/// the caller must intercept that redirection, extract the `code` and `state` query parameters and call
|
||||
/// `finishAuthentication(...)` to complete the flow.
|
||||
public func beginPairingAuthentication(
|
||||
pairingUrl: String,
|
||||
completionHandler: @escaping (Result<URL, Error>) -> Void
|
||||
) {
|
||||
DispatchQueue.global().async {
|
||||
let result = self.updatingLatestAuthState { account in
|
||||
try account.beginPairingFlow(pairingUrl: pairingUrl, scopes: self.applicationScopes)
|
||||
}
|
||||
DispatchQueue.main.async { completionHandler(result) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a "begin authentication" closure, extracting the returned `state` from the returned URL
|
||||
/// and put it aside for later in `latestOAuthStateParam`.
|
||||
/// Afterwards, in `finishAuthentication` we ensure that we are
|
||||
/// finishing the correct (and same) authentication flow.
|
||||
private func updatingLatestAuthState(_ beginFlowFn: (FxAccount) throws -> URL) -> Result<URL, Error> {
|
||||
do {
|
||||
let url = try beginFlowFn(requireAccount())
|
||||
let comps = URLComponents(url: url, resolvingAgainstBaseURL: true)
|
||||
latestOAuthStateParam = comps!.queryItems!.first(where: { $0.name == "state" })!.value
|
||||
return .success(url)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish an authentication flow.
|
||||
///
|
||||
/// If it succeeds, a `.accountAuthenticated` notification will get fired.
|
||||
public func finishAuthentication(
|
||||
authData: FxaAuthData,
|
||||
completionHandler: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
if latestOAuthStateParam == nil {
|
||||
DispatchQueue.main.async { completionHandler(.failure(FirefoxAccountError.noExistingAuthFlow)) }
|
||||
} else if authData.state != latestOAuthStateParam {
|
||||
DispatchQueue.main.async { completionHandler(.failure(FirefoxAccountError.wrongAuthFlow)) }
|
||||
} else { /* state == latestAuthState */
|
||||
processEvent(event: .authenticated(authData: authData)) {
|
||||
DispatchQueue.main.async { completionHandler(.success(())) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get an OAuth access token.
|
||||
public func getAccessToken(scope: String, completionHandler: @escaping (Result<AccessTokenInfo, Error>) -> Void) {
|
||||
do {
|
||||
let tokenInfo = try requireAccount().getAccessToken(scope: scope)
|
||||
DispatchQueue.main.async { completionHandler(.success(tokenInfo)) }
|
||||
} catch {
|
||||
DispatchQueue.main.async { completionHandler(.failure(error)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the user profile in the background.
|
||||
///
|
||||
/// If it succeeds, a `.accountProfileUpdate` notification will get fired.
|
||||
public func refreshProfile() {
|
||||
processEvent(event: .fetchProfile) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the user profile synchronously. It could be empty
|
||||
/// because of network or authentication problems.
|
||||
public func accountProfile() -> Profile? {
|
||||
if state == .authenticatedWithProfile || state == .authenticationProblem {
|
||||
return profile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Get the device constellation.
|
||||
public func deviceConstellation() -> DeviceConstellation? {
|
||||
return constellation
|
||||
}
|
||||
|
||||
/// Log-out from the account.
|
||||
/// The `.accountLoggedOut` notification will also get fired.
|
||||
public func logout(completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
processEvent(event: .logout) {
|
||||
DispatchQueue.main.async { completionHandler(.success(())) }
|
||||
}
|
||||
}
|
||||
|
||||
let fxaFsmQueue = DispatchQueue(label: "com.mozilla.fxa-mgr-queue")
|
||||
|
||||
internal func processEvent(event: Event, completionHandler: @escaping () -> Void) {
|
||||
fxaFsmQueue.async {
|
||||
var toProcess: Event? = event
|
||||
while let e = toProcess {
|
||||
guard let nextState = FxaAccountManager.nextState(state: self.state, event: e) else {
|
||||
FxALog.error("Got invalid event \(e) for state \(self.state).")
|
||||
continue
|
||||
}
|
||||
FxALog.debug("Processing event \(e) for state \(self.state). Next state is \(nextState).")
|
||||
self.state = nextState
|
||||
toProcess = self.stateActions(forState: self.state, via: e)
|
||||
if let successiveEvent = toProcess {
|
||||
FxALog.debug(
|
||||
"Ran \(e) side-effects for state \(self.state), got successive event \(successiveEvent)."
|
||||
)
|
||||
}
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
// State transition matrix. Returns nil if there's no transition.
|
||||
internal static func nextState(state: AccountState, event: Event) -> AccountState? {
|
||||
switch state {
|
||||
case .start:
|
||||
switch event {
|
||||
case .initialize: return .start
|
||||
case .accountNotFound: return .notAuthenticated
|
||||
case .accountRestored: return .authenticatedNoProfile
|
||||
default: return nil
|
||||
}
|
||||
case .notAuthenticated:
|
||||
switch event {
|
||||
case .authenticated: return .authenticatedNoProfile
|
||||
default: return nil
|
||||
}
|
||||
case .authenticatedNoProfile:
|
||||
switch event {
|
||||
case .authenticationError: return .authenticationProblem
|
||||
case .fetchProfile: return .authenticatedNoProfile
|
||||
case .fetchedProfile: return .authenticatedWithProfile
|
||||
case .failedToFetchProfile: return .authenticatedNoProfile
|
||||
case .logout: return .notAuthenticated
|
||||
default: return nil
|
||||
}
|
||||
case .authenticatedWithProfile:
|
||||
switch event {
|
||||
case .authenticationError: return .authenticationProblem
|
||||
case .logout: return .notAuthenticated
|
||||
default: return nil
|
||||
}
|
||||
case .authenticationProblem:
|
||||
switch event {
|
||||
case .recoveredFromAuthenticationProblem: return .authenticatedNoProfile
|
||||
case .authenticated: return .authenticatedNoProfile
|
||||
case .logout: return .notAuthenticated
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
internal func stateActions(forState: AccountState, via: Event) -> Event? {
|
||||
switch forState {
|
||||
case .start: do {
|
||||
switch via {
|
||||
case .initialize: do {
|
||||
if let acct = tryRestoreAccount() {
|
||||
account = acct
|
||||
return Event.accountRestored
|
||||
} else {
|
||||
return Event.accountNotFound
|
||||
}
|
||||
}
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
case .notAuthenticated: do {
|
||||
switch via {
|
||||
case .logout: do {
|
||||
// Clean up internal account state and destroy the current FxA device record.
|
||||
do {
|
||||
try requireAccount().disconnect()
|
||||
FxALog.info("Disconnected FxA account")
|
||||
} catch {
|
||||
FxALog.error("Failed to fully disconnect the FxA account: \(error).")
|
||||
}
|
||||
profile = nil
|
||||
constellation = nil
|
||||
accountStorage.clear()
|
||||
// If we cannot instanciate FxA something is *really* wrong, crashing is a valid option.
|
||||
account = createAccount()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .accountLoggedOut,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
case .accountNotFound: do {
|
||||
account = createAccount()
|
||||
}
|
||||
default: break // Do nothing
|
||||
}
|
||||
}
|
||||
case .authenticatedNoProfile: do {
|
||||
switch via {
|
||||
case let .authenticated(authData): do {
|
||||
FxALog.info("Registering persistence callback")
|
||||
requireAccount().registerPersistCallback(statePersistenceCallback)
|
||||
|
||||
FxALog.debug("Completing oauth flow")
|
||||
do {
|
||||
try requireAccount().completeOAuthFlow(code: authData.code, state: authData.state)
|
||||
} catch {
|
||||
// Reasons this can fail:
|
||||
// - network errors
|
||||
// - unknown auth state
|
||||
// - authenticating via web-content; we didn't beginOAuthFlowAsync
|
||||
FxALog.error("Error completing OAuth flow: \(error)")
|
||||
}
|
||||
|
||||
FxALog.info("Initializing device")
|
||||
requireConstellation().initDevice(
|
||||
name: deviceConfig.name,
|
||||
type: deviceConfig.type,
|
||||
capabilities: deviceConfig.capabilities
|
||||
)
|
||||
|
||||
postAuthenticated(authType: authData.authType)
|
||||
|
||||
return Event.fetchProfile
|
||||
}
|
||||
case .accountRestored: do {
|
||||
FxALog.info("Registering persistence callback")
|
||||
requireAccount().registerPersistCallback(statePersistenceCallback)
|
||||
|
||||
FxALog.info("Ensuring device capabilities...")
|
||||
requireConstellation().ensureCapabilities(capabilities: deviceConfig.capabilities)
|
||||
|
||||
postAuthenticated(authType: .existingAccount)
|
||||
|
||||
return Event.fetchProfile
|
||||
}
|
||||
case .recoveredFromAuthenticationProblem: do {
|
||||
FxALog.info("Registering persistence callback")
|
||||
requireAccount().registerPersistCallback(statePersistenceCallback)
|
||||
|
||||
FxALog.info("Initializing device")
|
||||
requireConstellation().initDevice(
|
||||
name: deviceConfig.name,
|
||||
type: deviceConfig.type,
|
||||
capabilities: deviceConfig.capabilities
|
||||
)
|
||||
|
||||
postAuthenticated(authType: .recovered)
|
||||
|
||||
return Event.fetchProfile
|
||||
}
|
||||
case .fetchProfile: do {
|
||||
// Profile fetching and account authentication issues:
|
||||
// https://github.com/mozilla/application-services/issues/483
|
||||
FxALog.info("Fetching profile...")
|
||||
|
||||
do {
|
||||
profile = try requireAccount().getProfile()
|
||||
} catch {
|
||||
return Event.failedToFetchProfile
|
||||
}
|
||||
return Event.fetchedProfile
|
||||
}
|
||||
default: break // Do nothing
|
||||
}
|
||||
}
|
||||
case .authenticatedWithProfile: do {
|
||||
switch via {
|
||||
case .fetchedProfile: do {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .accountProfileUpdate,
|
||||
object: nil,
|
||||
userInfo: ["profile": self.profile!]
|
||||
)
|
||||
}
|
||||
}
|
||||
default: break // Do nothing
|
||||
}
|
||||
}
|
||||
case .authenticationProblem:
|
||||
switch via {
|
||||
case .authenticationError: do {
|
||||
// Somewhere in the system, we've just hit an authentication problem.
|
||||
// There are two main causes:
|
||||
// 1) an access token we've obtain from fxalib via 'getAccessToken' expired
|
||||
// 2) password was changed, or device was revoked
|
||||
// We can recover from (1) and test if we're in (2) by asking the fxalib.
|
||||
// If it succeeds, then we can go back to whatever
|
||||
// state we were in before. Future operations that involve access tokens should
|
||||
// succeed.
|
||||
|
||||
func onError() {
|
||||
// We are either certainly in the scenario (2), or were unable to determine
|
||||
// our connectivity state. Let's assume we need to re-authenticate.
|
||||
// This uncertainty about real state means that, hopefully rarely,
|
||||
// we will disconnect users that hit transient network errors during
|
||||
// an authorization check.
|
||||
// See https://github.com/mozilla-mobile/android-components/issues/3347
|
||||
FxALog.error("Unable to recover from an auth problem.")
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .accountAuthProblems,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let account = requireAccount()
|
||||
let info = try account.checkAuthorizationStatus()
|
||||
if !info.active {
|
||||
onError()
|
||||
return nil
|
||||
}
|
||||
try account.clearAccessTokenCache()
|
||||
// Make sure we're back on track by re-requesting the profile access token.
|
||||
_ = try account.getAccessToken(scope: OAuthScope.profile)
|
||||
return .recoveredFromAuthenticationProblem
|
||||
} catch {
|
||||
onError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
default: break // Do nothing
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
internal func createAccount() -> FxAccount {
|
||||
return try! FxAccount(config: config)
|
||||
}
|
||||
|
||||
internal func tryRestoreAccount() -> FxAccount? {
|
||||
return accountStorage.read()
|
||||
}
|
||||
|
||||
internal func makeDeviceConstellation(account: FxAccount) -> DeviceConstellation {
|
||||
return DeviceConstellation(account: account)
|
||||
}
|
||||
|
||||
internal func postAuthenticated(authType: FxaAuthType) {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .accountAuthenticated,
|
||||
object: nil,
|
||||
userInfo: ["authType": authType]
|
||||
)
|
||||
}
|
||||
requireConstellation().refreshState()
|
||||
}
|
||||
|
||||
// Handle auth exceptions caught in classes that don't hold a reference to the manager.
|
||||
internal func setupAuthExceptionsListener() {
|
||||
_ = NotificationCenter.default.addObserver(forName: .accountAuthException, object: nil, queue: nil) { _ in
|
||||
self.processEvent(event: .authenticationError) {}
|
||||
}
|
||||
}
|
||||
|
||||
internal func requireAccount() -> FxAccount {
|
||||
if let acct = account {
|
||||
return acct
|
||||
}
|
||||
preconditionFailure("initialize() must be called first.")
|
||||
}
|
||||
|
||||
internal func requireConstellation() -> DeviceConstellation {
|
||||
if let cstl = constellation {
|
||||
return cstl
|
||||
}
|
||||
preconditionFailure("account must be set (sets constellation).")
|
||||
}
|
||||
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
extension Notification.Name {
|
||||
static let accountAuthException = Notification.Name("accountAuthException")
|
||||
}
|
||||
|
||||
class FxAStatePersistenceCallback: PersistCallback {
|
||||
weak var manager: FxaAccountManager?
|
||||
|
||||
public init(manager: FxaAccountManager) {
|
||||
self.manager = manager
|
||||
}
|
||||
|
||||
func persist(json: String) {
|
||||
manager?.accountStorage.write(json)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* States of the [FxaAccountManager].
|
||||
*/
|
||||
internal enum AccountState {
|
||||
case start
|
||||
case notAuthenticated
|
||||
case authenticationProblem
|
||||
case authenticatedNoProfile
|
||||
case authenticatedWithProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for [FxaAccountManager] state machine events.
|
||||
* Events aren't a simple enum class because we might want to pass data along with some of the events.
|
||||
*/
|
||||
internal enum Event {
|
||||
case initialize
|
||||
case accountNotFound
|
||||
case accountRestored
|
||||
case authenticated(authData: FxaAuthData)
|
||||
case authenticationError /* (error: AuthException) */
|
||||
case recoveredFromAuthenticationProblem
|
||||
case fetchProfile
|
||||
case fetchedProfile
|
||||
case failedToFetchProfile
|
||||
case logout
|
||||
}
|
||||
|
||||
public enum FxaAuthType {
|
||||
case existingAccount
|
||||
case signin
|
||||
case signup
|
||||
case pairing
|
||||
case recovered
|
||||
case other(reason: String)
|
||||
|
||||
internal static func fromActionQueryParam(_ action: String) -> FxaAuthType {
|
||||
switch action {
|
||||
case "signin": return .signin
|
||||
case "signup": return .signup
|
||||
case "pairing": return .pairing
|
||||
default: return .other(reason: action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct FxaAuthData {
|
||||
public let code: String
|
||||
public let state: String
|
||||
public let authType: FxaAuthType
|
||||
|
||||
/// These constructor paramers shall be extracted from the OAuth final redirection URL query
|
||||
/// parameters.
|
||||
public init(code: String, state: String, actionQueryParam: String) {
|
||||
self.code = code
|
||||
self.state = state
|
||||
authType = FxaAuthType.fromActionQueryParam(actionQueryParam)
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceConfig {
|
||||
let name: String
|
||||
let type: DeviceType
|
||||
let capabilities: [DeviceCapability]
|
||||
|
||||
public init(name: String, type: DeviceType, capabilities: [DeviceCapability]) {
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.capabilities = capabilities
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum OAuthScope {
|
||||
// Necessary to fetch a profile.
|
||||
public static let profile: String = "profile"
|
||||
// Necessary to obtain sync keys.
|
||||
public static let oldSync: String = "https://identity.mozilla.com/apps/oldsync"
|
||||
}
|
||||
|
||||
public struct ScopedKey {
|
||||
public let kty: String
|
||||
public let scope: String
|
||||
public let k: String
|
||||
public let kid: String
|
||||
|
||||
internal init(msg: MsgTypes_ScopedKey) {
|
||||
kty = msg.kty
|
||||
scope = msg.scope
|
||||
k = msg.k
|
||||
kid = msg.kid
|
||||
}
|
||||
}
|
||||
|
||||
public struct AccessTokenInfo {
|
||||
public let scope: String
|
||||
public let token: String
|
||||
public let key: ScopedKey?
|
||||
public let expiresAt: Date
|
||||
|
||||
internal init(msg: MsgTypes_AccessTokenInfo) {
|
||||
scope = msg.scope
|
||||
token = msg.token
|
||||
key = msg.hasKey ? ScopedKey(msg: msg.key) : nil
|
||||
expiresAt = Date(timeIntervalSince1970: Double(msg.expiresAt))
|
||||
}
|
||||
|
||||
// For testing.
|
||||
internal init(
|
||||
scope: String,
|
||||
token: String,
|
||||
key: ScopedKey? = nil,
|
||||
expiresAt: Date = Date()
|
||||
) {
|
||||
self.scope = scope
|
||||
self.token = token
|
||||
self.key = key
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct IntrospectInfo {
|
||||
public let active: Bool
|
||||
public let tokenType: String
|
||||
public let scope: String?
|
||||
public let exp: Date
|
||||
public let iss: String?
|
||||
|
||||
internal init(msg: MsgTypes_IntrospectInfo) {
|
||||
active = msg.active
|
||||
tokenType = msg.tokenType
|
||||
scope = msg.hasScope ? msg.scope : nil
|
||||
exp = Date(timeIntervalSince1970: Double(msg.exp))
|
||||
iss = msg.hasIss ? msg.iss : nil
|
||||
}
|
||||
|
||||
// For testing.
|
||||
internal init(
|
||||
active: Bool,
|
||||
tokenType: String,
|
||||
scope: String? = nil,
|
||||
exp: Date = Date(),
|
||||
iss: String? = nil
|
||||
) {
|
||||
self.active = active
|
||||
self.tokenType = tokenType
|
||||
self.scope = scope
|
||||
self.exp = exp
|
||||
self.iss = iss
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct Avatar {
|
||||
public let url: String
|
||||
public let isDefault: Bool
|
||||
}
|
||||
|
||||
public struct Profile {
|
||||
public let uid: String
|
||||
public let email: String
|
||||
public let avatar: Avatar?
|
||||
public let displayName: String?
|
||||
|
||||
internal init(msg: MsgTypes_Profile) {
|
||||
uid = msg.uid
|
||||
email = msg.email
|
||||
avatar = msg.hasAvatar ? Avatar(url: msg.avatar, isDefault: msg.avatarDefault) : nil
|
||||
displayName = msg.hasDisplayName ? msg.displayName : nil
|
||||
}
|
||||
|
||||
// For testing.
|
||||
internal init(uid: String, email: String, avatar: Avatar? = nil, displayName: String? = nil) {
|
||||
self.uid = uid
|
||||
self.email = email
|
||||
self.avatar = avatar
|
||||
self.displayName = displayName
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
import SwiftKeychainWrapper
|
||||
|
||||
class KeyChainAccountStorage {
|
||||
internal var keychainWrapper: KeychainWrapper
|
||||
internal static var keychainKey: String = "accountJSON"
|
||||
|
||||
init(keychainAccessGroup: String?) {
|
||||
keychainWrapper = KeychainWrapper.sharedAppContainerKeychain(keychainAccessGroup: keychainAccessGroup)
|
||||
}
|
||||
|
||||
func read() -> FxAccount? {
|
||||
if let json = keychainWrapper.string(forKey: KeyChainAccountStorage.keychainKey) {
|
||||
do {
|
||||
return try FxAccount(fromJsonState: json)
|
||||
} catch {
|
||||
FxALog.error("FxAccount internal state de-serialization failed: \(error).")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func write(_ json: String) {
|
||||
if !keychainWrapper.set(json, forKey: KeyChainAccountStorage.keychainKey) {
|
||||
FxALog.error("Could not write account state.")
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
if !keychainWrapper.removeObject(forKey: KeyChainAccountStorage.keychainKey) {
|
||||
FxALog.error("Could not clear account state.")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -47,6 +47,11 @@ char *_Nullable fxa_begin_oauth_flow(FirefoxAccountHandle handle,
|
|||
const char *_Nonnull scopes,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
char *_Nullable fxa_begin_pairing_flow(FirefoxAccountHandle handle,
|
||||
const char *_Nonnull pairing_url,
|
||||
const char *_Nonnull scopes,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
void fxa_complete_oauth_flow(FirefoxAccountHandle handle,
|
||||
const char *_Nonnull code,
|
||||
const char *_Nonnull state,
|
||||
|
@ -80,6 +85,51 @@ FxARustBuffer fxa_profile(FirefoxAccountHandle handle,
|
|||
bool ignore_cache,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
FxARustBuffer fxa_get_devices(FirefoxAccountHandle handle,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
FxARustBuffer fxa_poll_device_commands(FirefoxAccountHandle handle,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
FxARustBuffer fxa_handle_push_message(FirefoxAccountHandle handle,
|
||||
const char *_Nonnull payload,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
void fxa_send_tab(FirefoxAccountHandle handle,
|
||||
const char *_Nonnull targetId,
|
||||
const char *_Nonnull title,
|
||||
const char *_Nonnull url,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
void fxa_set_device_name(FirefoxAccountHandle handle,
|
||||
const char *_Nonnull displayName,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
void fxa_set_push_subscription(FirefoxAccountHandle handle,
|
||||
const char *_Nonnull endpoint,
|
||||
const char *_Nonnull publicKey,
|
||||
const char *_Nonnull authKey,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
void fxa_initialize_device(FirefoxAccountHandle handle,
|
||||
const char *_Nonnull name,
|
||||
int32_t device_type,
|
||||
uint8_t const *_Nonnull capabilities_ptr,
|
||||
int32_t capabilities_len,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
void fxa_ensure_capabilities(FirefoxAccountHandle handle,
|
||||
uint8_t const *_Nonnull capabilities_ptr,
|
||||
int32_t capabilities_len,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
void fxa_migrate_from_session_token(FirefoxAccountHandle handle,
|
||||
const char *_Nonnull sessionToken,
|
||||
const char *_Nonnull kSync,
|
||||
const char *_Nonnull kXCS,
|
||||
bool copySessionToken,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
char *_Nullable fxa_get_token_server_endpoint_url(FirefoxAccountHandle handle,
|
||||
FxAError *_Nonnull out);
|
||||
|
||||
|
|
|
@ -0,0 +1,263 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
import SwiftProtobuf
|
||||
|
||||
/// This class wraps Rust calls safely and performs necessary type conversions.
|
||||
open class RustFxAccount {
|
||||
private let raw: UInt64
|
||||
|
||||
internal init(raw: UInt64) {
|
||||
self.raw = raw
|
||||
}
|
||||
|
||||
/// Create a `RustFxAccount` from scratch. This is suitable for callers using the
|
||||
/// OAuth Flow.
|
||||
public required convenience init(config: FxAConfig) throws {
|
||||
let pointer = try rustCall { err in
|
||||
fxa_new(config.contentUrl, config.clientId, config.redirectUri, err)
|
||||
}
|
||||
self.init(raw: pointer)
|
||||
}
|
||||
|
||||
/// Restore a previous instance of `RustFxAccount` from a serialized state (obtained with `toJSON(...)`).
|
||||
public required convenience init(fromJsonState state: String) throws {
|
||||
let pointer = try rustCall { err in fxa_from_json(state, err) }
|
||||
self.init(raw: pointer)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if self.raw != 0 {
|
||||
try! rustCall { err in
|
||||
// Is `try!` the right thing to do? We should only hit an error here
|
||||
// for panics and handle misuse, both inidicate bugs in our code
|
||||
// (the first in the rust code, the 2nd in this swift wrapper).
|
||||
fxa_free(self.raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes the state of a `RustFxAccount` instance. It can be restored
|
||||
/// later with `fromJSON(...)`. It is the responsability of the caller to
|
||||
/// persist that serialized state regularly (after operations that mutate
|
||||
/// `RustFxAccount`) in a **secure** location.
|
||||
open func toJSON() throws -> String {
|
||||
let ptr = try rustCall { err in fxa_to_json(self.raw, err) }
|
||||
return String(freeingFxaString: ptr)
|
||||
}
|
||||
|
||||
/// Gets the logged-in user profile.
|
||||
/// Throws `FirefoxAccountError.Unauthorized` if we couldn't find any suitable access token
|
||||
/// to make that call. The caller should then start the OAuth Flow again with
|
||||
/// the "profile" scope.
|
||||
open func getProfile() throws -> Profile {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_profile(self.raw, false, err)
|
||||
}
|
||||
defer { fxa_bytebuffer_free(ptr) }
|
||||
let msg = try! MsgTypes_Profile(serializedData: Data(rustBuffer: ptr))
|
||||
return Profile(msg: msg)
|
||||
}
|
||||
|
||||
open func getTokenServerEndpointURL() throws -> URL {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_get_token_server_endpoint_url(self.raw, err)
|
||||
}
|
||||
return URL(string: String(freeingFxaString: ptr))!
|
||||
}
|
||||
|
||||
open func getConnectionSuccessURL() throws -> URL {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_get_connection_success_url(self.raw, err)
|
||||
}
|
||||
return URL(string: String(freeingFxaString: ptr))!
|
||||
}
|
||||
|
||||
open func getManageAccountURL(entrypoint: String) throws -> URL {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_get_manage_account_url(self.raw, entrypoint, err)
|
||||
}
|
||||
return URL(string: String(freeingFxaString: ptr))!
|
||||
}
|
||||
|
||||
open func getManageDevicesURL(entrypoint: String) throws -> URL {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_get_manage_devices_url(self.raw, entrypoint, err)
|
||||
}
|
||||
return URL(string: String(freeingFxaString: ptr))!
|
||||
}
|
||||
|
||||
/// Request a OAuth token by starting a new OAuth flow.
|
||||
///
|
||||
/// This function returns a URL string that the caller should open in a webview.
|
||||
///
|
||||
/// Once the user has confirmed the authorization grant, they will get redirected to `redirect_url`:
|
||||
/// the caller must intercept that redirection, extract the `code` and `state` query parameters and call
|
||||
/// `completeOAuthFlow(...)` to complete the flow.
|
||||
open func beginOAuthFlow(scopes: [String]) throws -> URL {
|
||||
let scope = scopes.joined(separator: " ")
|
||||
let ptr = try rustCall { err in
|
||||
fxa_begin_oauth_flow(self.raw, scope, err)
|
||||
}
|
||||
return URL(string: String(freeingFxaString: ptr))!
|
||||
}
|
||||
|
||||
open func beginPairingFlow(pairingUrl: String, scopes: [String]) throws -> URL {
|
||||
let scope = scopes.joined(separator: " ")
|
||||
let ptr = try rustCall { err in
|
||||
fxa_begin_pairing_flow(self.raw, pairingUrl, scope, err)
|
||||
}
|
||||
return URL(string: String(freeingFxaString: ptr))!
|
||||
}
|
||||
|
||||
/// Finish an OAuth flow initiated by `beginOAuthFlow(...)` and returns token/keys.
|
||||
///
|
||||
/// This resulting token might not have all the `scopes` the caller have requested (e.g. the user
|
||||
/// might have denied some of them): it is the responsibility of the caller to accomodate that.
|
||||
open func completeOAuthFlow(code: String, state: String) throws {
|
||||
try rustCall { err in
|
||||
fxa_complete_oauth_flow(self.raw, code, state, err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get an OAuth access token.
|
||||
///
|
||||
/// Throws `FirefoxAccountError.Unauthorized` if we couldn't provide an access token
|
||||
/// for this scope. The caller should then start the OAuth Flow again with
|
||||
/// the desired scope.
|
||||
open func getAccessToken(scope: String) throws -> AccessTokenInfo {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_get_access_token(self.raw, scope, err)
|
||||
}
|
||||
defer { fxa_bytebuffer_free(ptr) }
|
||||
let msg = try! MsgTypes_AccessTokenInfo(serializedData: Data(rustBuffer: ptr))
|
||||
return AccessTokenInfo(msg: msg)
|
||||
}
|
||||
|
||||
/// Check whether the refreshToken is active
|
||||
open func checkAuthorizationStatus() throws -> IntrospectInfo {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_check_authorization_status(self.raw, err)
|
||||
}
|
||||
defer { fxa_bytebuffer_free(ptr) }
|
||||
let msg = try! MsgTypes_IntrospectInfo(serializedData: Data(rustBuffer: ptr))
|
||||
return IntrospectInfo(msg: msg)
|
||||
}
|
||||
|
||||
/// This method should be called when a request made with
|
||||
/// an OAuth token failed with an authentication error.
|
||||
/// It clears the internal cache of OAuth access tokens,
|
||||
/// so the caller can try to call `getAccessToken` or `getProfile`
|
||||
/// again.
|
||||
open func clearAccessTokenCache() throws {
|
||||
try rustCall { err in
|
||||
fxa_clear_access_token_cache(self.raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from the account and optionaly destroy our device record.
|
||||
/// `beginOAuthFlow(...)` will need to be called to reconnect.
|
||||
open func disconnect() throws {
|
||||
try rustCall { err in
|
||||
fxa_disconnect(self.raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
open func fetchDevices() throws -> [Device] {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_get_devices(self.raw, err)
|
||||
}
|
||||
defer { fxa_bytebuffer_free(ptr) }
|
||||
let msg = try! MsgTypes_Devices(serializedData: Data(rustBuffer: ptr))
|
||||
return Device.fromCollectionMsg(msg: msg)
|
||||
}
|
||||
|
||||
open func setDeviceDisplayName(_ name: String) throws {
|
||||
try rustCall { err in
|
||||
fxa_set_device_name(self.raw, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
open func pollDeviceCommands() throws -> [DeviceEvent] {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_poll_device_commands(self.raw, err)
|
||||
}
|
||||
defer { fxa_bytebuffer_free(ptr) }
|
||||
let msg = try! MsgTypes_AccountEvents(serializedData: Data(rustBuffer: ptr))
|
||||
return DeviceEvent.fromCollectionMsg(msg: msg)
|
||||
}
|
||||
|
||||
open func handlePushMessage(payload: String) throws -> [DeviceEvent] {
|
||||
let ptr = try rustCall { err in
|
||||
fxa_handle_push_message(self.raw, payload, err)
|
||||
}
|
||||
defer { fxa_bytebuffer_free(ptr) }
|
||||
let msg = try! MsgTypes_AccountEvents(serializedData: Data(rustBuffer: ptr))
|
||||
return DeviceEvent.fromCollectionMsg(msg: msg)
|
||||
}
|
||||
|
||||
open func sendSingleTab(targetId: String, title: String, url: String) throws {
|
||||
try rustCall { err in
|
||||
fxa_send_tab(self.raw, targetId, title, url, err)
|
||||
}
|
||||
}
|
||||
|
||||
open func setDevicePushSubscription(endpoint: String, publicKey: String, authKey: String) throws {
|
||||
try rustCall { err in
|
||||
fxa_set_push_subscription(self.raw, endpoint, publicKey, authKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
open func initializeDevice(
|
||||
name: String,
|
||||
deviceType: DeviceType,
|
||||
supportedCapabilities: [DeviceCapability]
|
||||
) throws {
|
||||
let (data, size) = msgToBuffer(msg: supportedCapabilities.toCollectionMsg())
|
||||
try data.withUnsafeBytes { bytes in
|
||||
try rustCall { err in
|
||||
fxa_initialize_device(
|
||||
self.raw,
|
||||
name,
|
||||
Int32(deviceType.toMsg().rawValue),
|
||||
bytes.bindMemory(to: UInt8.self).baseAddress!,
|
||||
size,
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open func ensureCapabilities(supportedCapabilities: [DeviceCapability]) throws {
|
||||
let (data, size) = msgToBuffer(msg: supportedCapabilities.toCollectionMsg())
|
||||
try data.withUnsafeBytes { bytes in
|
||||
try rustCall { err in
|
||||
fxa_ensure_capabilities(
|
||||
self.raw,
|
||||
bytes.bindMemory(to: UInt8.self).baseAddress!,
|
||||
size,
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func msgToBuffer(msg: SwiftProtobuf.Message) -> (Data, Int32) {
|
||||
let data = try! msg.serializedData()
|
||||
let size = Int32(data.count)
|
||||
return (data, size)
|
||||
}
|
||||
}
|
||||
|
||||
// This queue serves as a semaphore to the rust layer.
|
||||
private let fxaRustQueue = DispatchQueue(label: "com.mozilla.fxa-rust")
|
||||
|
||||
internal func rustCall<T>(_ cb: (UnsafeMutablePointer<FxAError>) throws -> T?) throws -> T {
|
||||
return try FirefoxAccountError.unwrap { err in
|
||||
try fxaRustQueue.sync {
|
||||
try cb(err)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -76,6 +76,7 @@ No additional Kotlin dependencies should be added to the project unless absolute
|
|||
We currently depend only on the [following dependencies](https://github.com/mozilla/application-services/blob/master/Cartfile):
|
||||
|
||||
* [swift-protobuf](https://github.com/apple/swift-protobuf)
|
||||
* [SwiftKeychainWrapper](https://github.com/jrendel/SwiftKeychainWrapper/)
|
||||
|
||||
No additional Swift dependencies should be added to the project unless absolutely necessary.
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ the details of which are reproduced below.
|
|||
|
||||
* [Mozilla Public License 2.0](#mozilla-public-license-20)
|
||||
* [Apache License 2.0](#apache-license-20)
|
||||
* [MIT License: SwiftKeychainWrapper](#mit-license-swiftkeychainwrapper)
|
||||
* [MIT License: aho-corasick, byteorder, memchr, termcolor](#mit-license-aho-corasick-byteorder-memchr-termcolor)
|
||||
* [MIT License: ansi_term](#mit-license-ansi_term)
|
||||
* [MIT License: atty](#mit-license-atty)
|
||||
|
@ -734,6 +735,37 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
```
|
||||
-------------
|
||||
## MIT License: SwiftKeychainWrapper
|
||||
|
||||
The following text applies to code linked from these dependencies:
|
||||
[SwiftKeychainWrapper](https://github.com/jrendel/SwiftKeychainWrapper)
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Jason Rendel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
```
|
||||
-------------
|
||||
## MIT License: aho-corasick, byteorder, memchr, termcolor
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
import SwiftKeychainWrapper
|
||||
|
||||
extension KeychainWrapper {
|
||||
/// Return the base bundle identifier.
|
||||
///
|
||||
/// This function is smart enough to find out if it is being called from an extension or the main application. In
|
||||
/// case of the former, it will chop off the extension identifier from the bundle since that is a suffix not part
|
||||
/// of the *base* bundle identifier.
|
||||
static var baseBundleIdentifier: String {
|
||||
let bundle = Bundle.main
|
||||
let packageType = bundle.object(forInfoDictionaryKey: "CFBundlePackageType") as? String
|
||||
let baseBundleIdentifier = bundle.bundleIdentifier!
|
||||
if packageType == "XPC!" {
|
||||
let components = baseBundleIdentifier.components(separatedBy: ".")
|
||||
return components[0 ..< components.count - 1].joined(separator: ".")
|
||||
}
|
||||
return baseBundleIdentifier
|
||||
}
|
||||
|
||||
static var shared: KeychainWrapper?
|
||||
|
||||
static func sharedAppContainerKeychain(keychainAccessGroup: String?) -> KeychainWrapper {
|
||||
if let s = shared {
|
||||
return s
|
||||
}
|
||||
let wrapper = KeychainWrapper(serviceName: baseBundleIdentifier, accessGroup: keychainAccessGroup)
|
||||
shared = wrapper
|
||||
return wrapper
|
||||
}
|
||||
}
|
|
@ -31,8 +31,23 @@
|
|||
CDC0089F2236CAD100893800 /* places_msg_types.proto in Sources */ = {isa = PBXBuildFile; fileRef = CDC0089E2236CAD100893800 /* places_msg_types.proto */; };
|
||||
CDC21B14221DCE3700AA71E5 /* RustLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC21B12221DCE3700AA71E5 /* RustLog.swift */; };
|
||||
CDC21B15221DCE3700AA71E5 /* RustLogFFI.h in Headers */ = {isa = PBXBuildFile; fileRef = CDC21B13221DCE3700AA71E5 /* RustLogFFI.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
CE1445AC23D6058B00B1E808 /* FxAccountConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1445AB23D6058B00B1E808 /* FxAccountConfig.swift */; };
|
||||
CE1445AF23D6315200B1E808 /* FxAccountLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1445AE23D6315200B1E808 /* FxAccountLogging.swift */; };
|
||||
CE1ADA9722249FDA00E89714 /* Data+RustBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1ADA9622249FDA00E89714 /* Data+RustBuffer.swift */; };
|
||||
CE1B09A3231863D7006226E1 /* KeychainWrapper+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1B09A2231863D7006226E1 /* KeychainWrapper+.swift */; };
|
||||
CE1B09A5231865BC006226E1 /* FxAccountStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1B09A4231865BC006226E1 /* FxAccountStorage.swift */; };
|
||||
CE2D04C5231822AC00AF5722 /* FxAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D04C4231822AC00AF5722 /* FxAccountManager.swift */; };
|
||||
CE3A2F38225BDE5300EA569C /* PlacesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3A2F37225BDE5300EA569C /* PlacesTests.swift */; };
|
||||
CE90D1BB23D7570A00FD9A5F /* FxAccountManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE34DB3C23D0C9640027AD63 /* FxAccountManagerTests.swift */; };
|
||||
CE96459923D7A77500B662F8 /* SwiftKeychainWrapper.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE96459823D7A77500B662F8 /* SwiftKeychainWrapper.framework */; };
|
||||
CE96459C23D7A7D500B662F8 /* SwiftKeychainWrapper.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE96459823D7A77500B662F8 /* SwiftKeychainWrapper.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
CEB1A06823D8A42D005BD4DD /* FxAccountMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB1A06723D8A42D005BD4DD /* FxAccountMocks.swift */; };
|
||||
CED443CB23CCF385007E5FA6 /* FxAccountDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED443CA23CCF385007E5FA6 /* FxAccountDevice.swift */; };
|
||||
CED443CD23CD13E5007E5FA6 /* FxAccountDeviceConstellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED443CC23CD13E5007E5FA6 /* FxAccountDeviceConstellation.swift */; };
|
||||
CEDDBC8C23DB438F00CFF5AA /* RustFxAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDDBC8B23DB438F00CFF5AA /* RustFxAccount.swift */; };
|
||||
CEDDBC8E23DB4CD600CFF5AA /* FxAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDDBC8D23DB4CD600CFF5AA /* FxAccount.swift */; };
|
||||
CEDDBC9023DB4F7400CFF5AA /* FxAccountOAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDDBC8F23DB4F7400CFF5AA /* FxAccountOAuth.swift */; };
|
||||
CEDDBC9223DB4F9A00CFF5AA /* FxAccountProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDDBC9123DB4F9A00CFF5AA /* FxAccountProfile.swift */; };
|
||||
CEFB1EB022EF708B0001E20F /* ResultError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB1EAF22EF708B0001E20F /* ResultError.swift */; };
|
||||
D05434A1225680D900FDE4EF /* MozillaAppServices.h in Headers */ = {isa = PBXBuildFile; fileRef = C852EEF2220A3C6800A6E79A /* MozillaAppServices.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
D05434A2225680F400FDE4EF /* RustFxAFFI.h in Headers */ = {isa = PBXBuildFile; fileRef = C852EEE0220A2A2B00A6E79A /* RustFxAFFI.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
|
@ -48,6 +63,8 @@
|
|||
compilerSpec = com.apple.compilers.proxy.script;
|
||||
filePatterns = "*.proto";
|
||||
fileType = pattern.proxy;
|
||||
inputFiles = (
|
||||
);
|
||||
isEditable = 1;
|
||||
outputFiles = (
|
||||
"$(DERIVED_FILE_DIR)/$(INPUT_FILE_BASE).pb.swift",
|
||||
|
@ -73,6 +90,7 @@
|
|||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
CE96459C23D7A7D500B662F8 /* SwiftKeychainWrapper.framework in CopyFiles */,
|
||||
CD440A452238A703003F004B /* SwiftProtobuf.framework in CopyFiles */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -106,9 +124,23 @@
|
|||
CDC0089E2236CAD100893800 /* places_msg_types.proto */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.protobuf; name = places_msg_types.proto; path = ../../src/places_msg_types.proto; sourceTree = "<group>"; };
|
||||
CDC21B12221DCE3700AA71E5 /* RustLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RustLog.swift; sourceTree = "<group>"; };
|
||||
CDC21B13221DCE3700AA71E5 /* RustLogFFI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RustLogFFI.h; sourceTree = "<group>"; };
|
||||
CE1445AB23D6058B00B1E808 /* FxAccountConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountConfig.swift; sourceTree = "<group>"; };
|
||||
CE1445AE23D6315200B1E808 /* FxAccountLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountLogging.swift; sourceTree = "<group>"; };
|
||||
CE1ADA9622249FDA00E89714 /* Data+RustBuffer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+RustBuffer.swift"; sourceTree = "<group>"; };
|
||||
CE1B09A2231863D7006226E1 /* KeychainWrapper+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeychainWrapper+.swift"; sourceTree = "<group>"; };
|
||||
CE1B09A4231865BC006226E1 /* FxAccountStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountStorage.swift; sourceTree = "<group>"; };
|
||||
CE2D04C4231822AC00AF5722 /* FxAccountManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FxAccountManager.swift; sourceTree = "<group>"; };
|
||||
CE34DB3C23D0C9640027AD63 /* FxAccountManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountManagerTests.swift; sourceTree = "<group>"; };
|
||||
CE3A2F37225BDE5300EA569C /* PlacesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacesTests.swift; sourceTree = "<group>"; };
|
||||
CE96459823D7A77500B662F8 /* SwiftKeychainWrapper.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftKeychainWrapper.framework; path = ../../Carthage/Build/iOS/SwiftKeychainWrapper.framework; sourceTree = "<group>"; };
|
||||
CE9D202020914D0D00F1C8FA /* MozillaAppServices.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MozillaAppServices.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CEB1A06723D8A42D005BD4DD /* FxAccountMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountMocks.swift; sourceTree = "<group>"; };
|
||||
CED443CA23CCF385007E5FA6 /* FxAccountDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountDevice.swift; sourceTree = "<group>"; };
|
||||
CED443CC23CD13E5007E5FA6 /* FxAccountDeviceConstellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountDeviceConstellation.swift; sourceTree = "<group>"; };
|
||||
CEDDBC8B23DB438F00CFF5AA /* RustFxAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustFxAccount.swift; sourceTree = "<group>"; };
|
||||
CEDDBC8D23DB4CD600CFF5AA /* FxAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccount.swift; sourceTree = "<group>"; };
|
||||
CEDDBC8F23DB4F7400CFF5AA /* FxAccountOAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountOAuth.swift; sourceTree = "<group>"; };
|
||||
CEDDBC9123DB4F9A00CFF5AA /* FxAccountProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAccountProfile.swift; sourceTree = "<group>"; };
|
||||
CEFB1EAF22EF708B0001E20F /* ResultError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultError.swift; sourceTree = "<group>"; };
|
||||
EB7DE84C2214D30B00E7CF17 /* SwiftProtobuf.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftProtobuf.framework; path = ../../Carthage/Build/iOS/SwiftProtobuf.framework; sourceTree = "<group>"; };
|
||||
EB879D7A221234EB00753DC9 /* MozillaAppServicesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MozillaAppServicesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -124,6 +156,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CE96459923D7A77500B662F8 /* SwiftKeychainWrapper.framework in Frameworks */,
|
||||
C852EEEF220A2E9400A6E79A /* libmegazord_ios.a in Frameworks */,
|
||||
EB7DE84D2214D30B00E7CF17 /* SwiftProtobuf.framework in Frameworks */,
|
||||
C852EE9E220A283200A6E79A /* libsqlcipher.a in Frameworks */,
|
||||
|
@ -188,6 +221,16 @@
|
|||
children = (
|
||||
CDC0089C2236CAB900893800 /* fxa_msg_types.proto */,
|
||||
C852EEDD220A2A2B00A6E79A /* FirefoxAccount.swift */,
|
||||
CEDDBC8B23DB438F00CFF5AA /* RustFxAccount.swift */,
|
||||
CEDDBC8D23DB4CD600CFF5AA /* FxAccount.swift */,
|
||||
CE1445AB23D6058B00B1E808 /* FxAccountConfig.swift */,
|
||||
CED443CA23CCF385007E5FA6 /* FxAccountDevice.swift */,
|
||||
CED443CC23CD13E5007E5FA6 /* FxAccountDeviceConstellation.swift */,
|
||||
CE1445AE23D6315200B1E808 /* FxAccountLogging.swift */,
|
||||
CE2D04C4231822AC00AF5722 /* FxAccountManager.swift */,
|
||||
CEDDBC8F23DB4F7400CFF5AA /* FxAccountOAuth.swift */,
|
||||
CEDDBC9123DB4F9A00CFF5AA /* FxAccountProfile.swift */,
|
||||
CE1B09A4231865BC006226E1 /* FxAccountStorage.swift */,
|
||||
C852EEDE220A2A2B00A6E79A /* Extensions */,
|
||||
C852EEE0220A2A2B00A6E79A /* RustFxAFFI.h */,
|
||||
C852EEE2220A2A2B00A6E79A /* Errors */,
|
||||
|
@ -253,11 +296,20 @@
|
|||
path = RustLog;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CE1B099D231862D5006226E1 /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CE1B09A2231863D7006226E1 /* KeychainWrapper+.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CE9D201620914D0D00F1C8FA = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C852EEF2220A3C6800A6E79A /* MozillaAppServices.h */,
|
||||
CD02CF3822568BCC00124DA2 /* SyncUnlockInfo.swift */,
|
||||
CE1B099D231862D5006226E1 /* Extensions */,
|
||||
CEFB1EB122EF70960001E20F /* Errors */,
|
||||
CD85A44822361E880099BFA9 /* Places */,
|
||||
CDC21B11221DCE3700AA71E5 /* RustLog */,
|
||||
|
@ -282,6 +334,7 @@
|
|||
CE9D203720914D4800F1C8FA /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CE96459823D7A77500B662F8 /* SwiftKeychainWrapper.framework */,
|
||||
EB7DE84C2214D30B00E7CF17 /* SwiftProtobuf.framework */,
|
||||
C852EE9D220A283200A6E79A /* libsqlcipher.a */,
|
||||
C852EEEE220A2E9400A6E79A /* libmegazord_ios.a */,
|
||||
|
@ -301,6 +354,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
EB879D7E221234EB00753DC9 /* Info.plist */,
|
||||
CEB1A06723D8A42D005BD4DD /* FxAccountMocks.swift */,
|
||||
CE34DB3C23D0C9640027AD63 /* FxAccountManagerTests.swift */,
|
||||
EB879D8A22123FD900753DC9 /* LoginsTests.swift */,
|
||||
CD4CFDD2221DFA5100EB3B33 /* LogTest.swift */,
|
||||
CE3A2F37225BDE5300EA569C /* PlacesTests.swift */,
|
||||
|
@ -442,16 +497,26 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CEFB1EB022EF708B0001E20F /* ResultError.swift in Sources */,
|
||||
CE1B09A5231865BC006226E1 /* FxAccountStorage.swift in Sources */,
|
||||
CEDDBC9023DB4F7400CFF5AA /* FxAccountOAuth.swift in Sources */,
|
||||
CE1ADA9722249FDA00E89714 /* Data+RustBuffer.swift in Sources */,
|
||||
C852EEE8220A2A2B00A6E79A /* String+Free_FxAClient.swift in Sources */,
|
||||
CDC0089F2236CAD100893800 /* places_msg_types.proto in Sources */,
|
||||
CEDDBC8E23DB4CD600CFF5AA /* FxAccount.swift in Sources */,
|
||||
CEDDBC8C23DB438F00CFF5AA /* RustFxAccount.swift in Sources */,
|
||||
CED443CD23CD13E5007E5FA6 /* FxAccountDeviceConstellation.swift in Sources */,
|
||||
CEDDBC9223DB4F9A00CFF5AA /* FxAccountProfile.swift in Sources */,
|
||||
C852EEEB220A2A2B00A6E79A /* FxAError.swift in Sources */,
|
||||
C852EED7220A29FE00A6E79A /* LoginStoreError.swift in Sources */,
|
||||
CD02CF3922568BCC00124DA2 /* SyncUnlockInfo.swift in Sources */,
|
||||
CDC0089D2236CAB900893800 /* fxa_msg_types.proto in Sources */,
|
||||
CD85A45A22361E890099BFA9 /* PlacesError.swift in Sources */,
|
||||
CE1445AC23D6058B00B1E808 /* FxAccountConfig.swift in Sources */,
|
||||
CD85A45422361E890099BFA9 /* Places.swift in Sources */,
|
||||
CED443CB23CCF385007E5FA6 /* FxAccountDevice.swift in Sources */,
|
||||
C852EED8220A29FE00A6E79A /* LockError.swift in Sources */,
|
||||
CE2D04C5231822AC00AF5722 /* FxAccountManager.swift in Sources */,
|
||||
CE1B09A3231863D7006226E1 /* KeychainWrapper+.swift in Sources */,
|
||||
CD85A45722361E890099BFA9 /* String+Free_Places.swift in Sources */,
|
||||
C852EED5220A29FE00A6E79A /* LoginRecord.swift in Sources */,
|
||||
C852EEE7220A2A2B00A6E79A /* FirefoxAccount.swift in Sources */,
|
||||
|
@ -459,6 +524,7 @@
|
|||
CDC21B14221DCE3700AA71E5 /* RustLog.swift in Sources */,
|
||||
C852EED6220A29FE00A6E79A /* LoginsStorage.swift in Sources */,
|
||||
C852EED3220A29FE00A6E79A /* String+Free_Logins.swift in Sources */,
|
||||
CE1445AF23D6315200B1E808 /* FxAccountLogging.swift in Sources */,
|
||||
CD85A45922361E890099BFA9 /* Bookmark.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -468,8 +534,10 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CE3A2F38225BDE5300EA569C /* PlacesTests.swift in Sources */,
|
||||
CEB1A06823D8A42D005BD4DD /* FxAccountMocks.swift in Sources */,
|
||||
CD4CFDD3221DFA5100EB3B33 /* LogTest.swift in Sources */,
|
||||
EB879D8B22123FD900753DC9 /* LoginsTests.swift in Sources */,
|
||||
CE90D1BB23D7570A00FD9A5F /* FxAccountManagerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,277 @@
|
|||
/* 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/. */
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import MozillaAppServices
|
||||
|
||||
class FxAccountManagerTests: XCTestCase {
|
||||
func testStateTransitionsStart() {
|
||||
let state: AccountState = .start
|
||||
XCTAssertEqual(.start, FxaAccountManager.nextState(state: state, event: .initialize))
|
||||
XCTAssertEqual(.notAuthenticated, FxaAccountManager.nextState(state: state, event: .accountNotFound))
|
||||
XCTAssertEqual(.authenticatedNoProfile, FxaAccountManager.nextState(state: state, event: .accountRestored))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .authenticated(authData: FxaAuthData(code: "foo", state: "bar", actionQueryParam: "bobo"))))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .authenticationError))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .fetchProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .fetchedProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .failedToFetchProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .logout))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .recoveredFromAuthenticationProblem))
|
||||
}
|
||||
|
||||
func testStateTransitionsNotAuthenticated() {
|
||||
let state: AccountState = .notAuthenticated
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .initialize))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .accountNotFound))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .accountRestored))
|
||||
XCTAssertEqual(.authenticatedNoProfile, FxaAccountManager.nextState(state: state, event: .authenticated(authData: FxaAuthData(code: "foo", state: "bar", actionQueryParam: "bobo"))))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .authenticationError))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .fetchProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .fetchedProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .failedToFetchProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .logout))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .recoveredFromAuthenticationProblem))
|
||||
}
|
||||
|
||||
func testStateTransitionsAuthenticatedNoProfile() {
|
||||
let state: AccountState = .authenticatedNoProfile
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .initialize))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .accountNotFound))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .accountRestored))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .authenticated(authData: FxaAuthData(code: "foo", state: "bar", actionQueryParam: "bobo"))))
|
||||
XCTAssertEqual(.authenticationProblem, FxaAccountManager.nextState(state: state, event: .authenticationError))
|
||||
XCTAssertEqual(.authenticatedNoProfile, FxaAccountManager.nextState(state: state, event: .fetchProfile))
|
||||
XCTAssertEqual(.authenticatedWithProfile, FxaAccountManager.nextState(state: state, event: .fetchedProfile))
|
||||
XCTAssertEqual(.authenticatedNoProfile, FxaAccountManager.nextState(state: state, event: .failedToFetchProfile))
|
||||
XCTAssertEqual(.notAuthenticated, FxaAccountManager.nextState(state: state, event: .logout))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .recoveredFromAuthenticationProblem))
|
||||
}
|
||||
|
||||
func testStateTransitionsAuthenticatedWithProfile() {
|
||||
let state: AccountState = .authenticatedWithProfile
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .initialize))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .accountNotFound))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .accountRestored))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .authenticated(authData: FxaAuthData(code: "foo", state: "bar", actionQueryParam: "bobo"))))
|
||||
XCTAssertEqual(.authenticationProblem, FxaAccountManager.nextState(state: state, event: .authenticationError))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .fetchProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .fetchedProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .failedToFetchProfile))
|
||||
XCTAssertEqual(.notAuthenticated, FxaAccountManager.nextState(state: state, event: .logout))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .recoveredFromAuthenticationProblem))
|
||||
}
|
||||
|
||||
func testStateTransitionsAuthenticationProblem() {
|
||||
let state: AccountState = .authenticationProblem
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .initialize))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .accountNotFound))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .accountRestored))
|
||||
XCTAssertEqual(.authenticatedNoProfile, FxaAccountManager.nextState(state: state, event: .authenticated(authData: FxaAuthData(code: "foo", state: "bar", actionQueryParam: "bobo"))))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .authenticationError))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .fetchProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .fetchedProfile))
|
||||
XCTAssertNil(FxaAccountManager.nextState(state: state, event: .failedToFetchProfile))
|
||||
XCTAssertEqual(.notAuthenticated, FxaAccountManager.nextState(state: state, event: .logout))
|
||||
XCTAssertEqual(.authenticatedNoProfile, FxaAccountManager.nextState(state: state, event: .recoveredFromAuthenticationProblem))
|
||||
}
|
||||
|
||||
func testAccountNotFound() {
|
||||
let mgr = mockFxAManager()
|
||||
|
||||
let initDone = XCTestExpectation(description: "Initialization done")
|
||||
mgr.initialize { _ in
|
||||
initDone.fulfill()
|
||||
}
|
||||
wait(for: [initDone], timeout: 5)
|
||||
|
||||
let account = mgr.account as! MockFxAccount
|
||||
let constellation = mgr.constellation as! MockDeviceConstellation
|
||||
|
||||
XCTAssertEqual(account.invocations, [])
|
||||
XCTAssertEqual(constellation.invocations, [])
|
||||
}
|
||||
|
||||
func testAccountRestoration() {
|
||||
let mgr = mockFxAManager()
|
||||
let account = MockFxAccount()
|
||||
mgr.storedAccount = account
|
||||
|
||||
expectation(forNotification: .accountAuthenticated, object: nil, handler: nil)
|
||||
expectation(forNotification: .accountProfileUpdate, object: nil, handler: nil)
|
||||
|
||||
let initDone = XCTestExpectation(description: "Initialization done")
|
||||
mgr.initialize { _ in
|
||||
initDone.fulfill()
|
||||
}
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
|
||||
// Fetch devices is run async, so it could happen after getProfile, hence we don't do a strict
|
||||
// equality.
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.registerPersistCallback))
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.ensureCapabilities))
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.getProfile))
|
||||
|
||||
let constellation = mgr.constellation as! MockDeviceConstellation
|
||||
XCTAssertEqual(constellation.invocations, [
|
||||
MockDeviceConstellation.MethodInvocation.ensureCapabilities,
|
||||
MockDeviceConstellation.MethodInvocation.refreshState,
|
||||
])
|
||||
}
|
||||
|
||||
func testAccountRestorationEnsureCapabilitiesNonAuthError() {
|
||||
class MockAccount: MockFxAccount {
|
||||
override func ensureCapabilities(supportedCapabilities _: [DeviceCapability]) throws {
|
||||
throw FirefoxAccountError.network(message: "The WiFi cable is detached.")
|
||||
}
|
||||
}
|
||||
let mgr = mockFxAManager()
|
||||
let account = MockAccount()
|
||||
mgr.storedAccount = account
|
||||
|
||||
expectation(forNotification: .accountAuthenticated, object: nil, handler: nil)
|
||||
expectation(forNotification: .accountProfileUpdate, object: nil, handler: nil)
|
||||
|
||||
let initDone = XCTestExpectation(description: "Initialization done")
|
||||
mgr.initialize { _ in
|
||||
initDone.fulfill()
|
||||
}
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.registerPersistCallback))
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.getProfile))
|
||||
|
||||
let constellation = mgr.constellation as! MockDeviceConstellation
|
||||
XCTAssertEqual(constellation.invocations, [
|
||||
MockDeviceConstellation.MethodInvocation.ensureCapabilities,
|
||||
MockDeviceConstellation.MethodInvocation.refreshState,
|
||||
])
|
||||
}
|
||||
|
||||
func testAccountRestorationEnsureCapabilitiesAuthError() {
|
||||
class MockAccount: MockFxAccount {
|
||||
override func ensureCapabilities(supportedCapabilities _: [DeviceCapability]) throws {
|
||||
notifyAuthError()
|
||||
throw FirefoxAccountError.unauthorized(message: "Your token is expired yo.")
|
||||
}
|
||||
|
||||
override func checkAuthorizationStatus() throws -> IntrospectInfo {
|
||||
_ = try super.checkAuthorizationStatus()
|
||||
return IntrospectInfo(active: false, tokenType: "refresh_token")
|
||||
}
|
||||
}
|
||||
let mgr = mockFxAManager()
|
||||
let account = MockAccount()
|
||||
mgr.storedAccount = account
|
||||
|
||||
expectation(forNotification: .accountAuthenticated, object: nil, handler: nil)
|
||||
expectation(forNotification: .accountProfileUpdate, object: nil, handler: nil)
|
||||
expectation(forNotification: .accountAuthProblems, object: nil, handler: nil)
|
||||
|
||||
let initDone = XCTestExpectation(description: "Initialization done")
|
||||
mgr.initialize { _ in
|
||||
initDone.fulfill()
|
||||
}
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
|
||||
XCTAssertTrue(mgr.accountNeedsReauth())
|
||||
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.registerPersistCallback))
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.getProfile))
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.checkAuthorizationStatus))
|
||||
|
||||
let constellation = mgr.constellation as! MockDeviceConstellation
|
||||
XCTAssertEqual(constellation.invocations, [
|
||||
MockDeviceConstellation.MethodInvocation.ensureCapabilities,
|
||||
MockDeviceConstellation.MethodInvocation.refreshState,
|
||||
])
|
||||
}
|
||||
|
||||
func testNewAccountLogIn() {
|
||||
let mgr = mockFxAManager()
|
||||
let beginAuthDone = XCTestExpectation(description: "beginAuthDone")
|
||||
var authURL: String?
|
||||
mgr.initialize { _ in
|
||||
mgr.beginAuthentication { url in
|
||||
authURL = try! url.get().absoluteString
|
||||
beginAuthDone.fulfill()
|
||||
}
|
||||
}
|
||||
wait(for: [beginAuthDone], timeout: 5)
|
||||
XCTAssertEqual(authURL, "https://foo.bar/oauth?state=bobo")
|
||||
|
||||
let finishAuthDone = XCTestExpectation(description: "finishAuthDone")
|
||||
mgr.finishAuthentication(authData: FxaAuthData(code: "bobo", state: "bobo", actionQueryParam: "email")) { result in
|
||||
if case .success = result {
|
||||
finishAuthDone.fulfill()
|
||||
}
|
||||
}
|
||||
wait(for: [finishAuthDone], timeout: 5)
|
||||
|
||||
let account = mgr.account! as! MockFxAccount
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.registerPersistCallback))
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.initializeDevice))
|
||||
XCTAssertTrue(account.invocations.contains(MockFxAccount.MethodInvocation.getProfile))
|
||||
|
||||
let constellation = mgr.constellation as! MockDeviceConstellation
|
||||
XCTAssertEqual(constellation.invocations, [
|
||||
MockDeviceConstellation.MethodInvocation.initDevice,
|
||||
MockDeviceConstellation.MethodInvocation.refreshState,
|
||||
])
|
||||
}
|
||||
|
||||
func testAuthStateVerification() {
|
||||
let mgr = mockFxAManager()
|
||||
let beginAuthDone = XCTestExpectation(description: "beginAuthDone")
|
||||
var authURL: String?
|
||||
mgr.initialize { _ in
|
||||
mgr.beginAuthentication { url in
|
||||
authURL = try! url.get().absoluteString
|
||||
beginAuthDone.fulfill()
|
||||
}
|
||||
}
|
||||
wait(for: [beginAuthDone], timeout: 5)
|
||||
XCTAssertEqual(authURL, "https://foo.bar/oauth?state=bobo")
|
||||
|
||||
let finishAuthDone = XCTestExpectation(description: "finishAuthDone")
|
||||
mgr.finishAuthentication(authData: FxaAuthData(code: "bobo", state: "NOTBOBO", actionQueryParam: "email")) { result in
|
||||
if case .failure = result {
|
||||
finishAuthDone.fulfill()
|
||||
}
|
||||
}
|
||||
wait(for: [finishAuthDone], timeout: 5)
|
||||
}
|
||||
|
||||
func testProfileRecoverableAuthError() {
|
||||
class MockAccount: MockFxAccount {
|
||||
var profileCallCount = 0
|
||||
override func getProfile() throws -> Profile {
|
||||
let profile = try super.getProfile()
|
||||
profileCallCount += 1
|
||||
if profileCallCount == 1 {
|
||||
notifyAuthError()
|
||||
throw FirefoxAccountError.unauthorized(message: "Uh oh.")
|
||||
} else {
|
||||
return profile
|
||||
}
|
||||
}
|
||||
}
|
||||
let mgr = mockFxAManager()
|
||||
let account = MockAccount()
|
||||
mgr.storedAccount = account
|
||||
|
||||
expectation(forNotification: .accountAuthenticated, object: nil, handler: nil)
|
||||
expectation(forNotification: .accountProfileUpdate, object: nil, handler: nil)
|
||||
|
||||
let initDone = XCTestExpectation(description: "Initialization done")
|
||||
mgr.initialize { _ in
|
||||
initDone.fulfill()
|
||||
}
|
||||
waitForExpectations(timeout: 10, handler: nil)
|
||||
|
||||
XCTAssertFalse(mgr.accountNeedsReauth())
|
||||
|
||||
XCTAssertTrue(account.invocations.contains(MockAccount.MethodInvocation.checkAuthorizationStatus))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
/* 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/. */
|
||||
|
||||
import Foundation
|
||||
@testable import MozillaAppServices
|
||||
|
||||
class MockFxAccount: FxAccount {
|
||||
var invocations: [MethodInvocation] = []
|
||||
enum MethodInvocation {
|
||||
case checkAuthorizationStatus
|
||||
case ensureCapabilities
|
||||
case getProfile
|
||||
case registerPersistCallback
|
||||
case clearAccessTokenCache
|
||||
case getAccessToken
|
||||
case initializeDevice
|
||||
case fetchDevices
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(raw: 0)
|
||||
}
|
||||
|
||||
required convenience init(fromJsonState _: String) throws {
|
||||
fatalError("init(fromJsonState:) has not been implemented")
|
||||
}
|
||||
|
||||
required convenience init(config _: FxAConfig) throws {
|
||||
fatalError("init(config:) has not been implemented")
|
||||
}
|
||||
|
||||
override func initializeDevice(name _: String, deviceType _: DeviceType, supportedCapabilities _: [DeviceCapability]) throws {
|
||||
invocations.append(.initializeDevice)
|
||||
}
|
||||
|
||||
override func fetchDevices() throws -> [Device] {
|
||||
invocations.append(.fetchDevices)
|
||||
return []
|
||||
}
|
||||
|
||||
override func registerPersistCallback(_: PersistCallback) {
|
||||
invocations.append(.registerPersistCallback)
|
||||
}
|
||||
|
||||
override func ensureCapabilities(supportedCapabilities _: [DeviceCapability]) throws {
|
||||
invocations.append(.ensureCapabilities)
|
||||
}
|
||||
|
||||
override func checkAuthorizationStatus() throws -> IntrospectInfo {
|
||||
invocations.append(.checkAuthorizationStatus)
|
||||
return IntrospectInfo(active: true, tokenType: "refresh_token")
|
||||
}
|
||||
|
||||
override func clearAccessTokenCache() throws {
|
||||
invocations.append(.clearAccessTokenCache)
|
||||
}
|
||||
|
||||
override func getAccessToken(scope _: String) throws -> AccessTokenInfo {
|
||||
invocations.append(.getAccessToken)
|
||||
return AccessTokenInfo(scope: "profile", token: "toktok")
|
||||
}
|
||||
|
||||
override func getProfile() throws -> Profile {
|
||||
invocations.append(.getProfile)
|
||||
return Profile(uid: "uid", email: "foo@bar.bobo")
|
||||
}
|
||||
|
||||
override func beginOAuthFlow(scopes _: [String]) throws -> URL {
|
||||
return URL(string: "https://foo.bar/oauth?state=bobo")!
|
||||
}
|
||||
}
|
||||
|
||||
class MockFxaAccountManager: FxaAccountManager {
|
||||
var invocations: [MethodInvocation] = []
|
||||
enum MethodInvocation {}
|
||||
|
||||
var storedAccount: FxAccount?
|
||||
|
||||
override func createAccount() -> FxAccount {
|
||||
return MockFxAccount()
|
||||
}
|
||||
|
||||
override func makeDeviceConstellation(account _: FxAccount) -> DeviceConstellation {
|
||||
return MockDeviceConstellation(account: account)
|
||||
}
|
||||
|
||||
override func tryRestoreAccount() -> FxAccount? {
|
||||
return storedAccount
|
||||
}
|
||||
}
|
||||
|
||||
class MockDeviceConstellation: DeviceConstellation {
|
||||
var invocations: [MethodInvocation] = []
|
||||
enum MethodInvocation {
|
||||
case ensureCapabilities
|
||||
case initDevice
|
||||
case refreshState
|
||||
}
|
||||
|
||||
override init(account: FxAccount?) {
|
||||
super.init(account: account ?? MockFxAccount())
|
||||
}
|
||||
|
||||
override func initDevice(name: String, type: DeviceType, capabilities: [DeviceCapability]) {
|
||||
invocations.append(.initDevice)
|
||||
super.initDevice(name: name, type: type, capabilities: capabilities)
|
||||
}
|
||||
|
||||
override func ensureCapabilities(capabilities: [DeviceCapability]) {
|
||||
invocations.append(.ensureCapabilities)
|
||||
super.ensureCapabilities(capabilities: capabilities)
|
||||
}
|
||||
|
||||
override func refreshState() {
|
||||
invocations.append(.refreshState)
|
||||
super.refreshState()
|
||||
}
|
||||
}
|
||||
|
||||
func mockFxAManager() -> MockFxaAccountManager {
|
||||
return MockFxaAccountManager(
|
||||
config: .release(clientId: "clientid", redirectUri: "redirect"),
|
||||
deviceConfig: DeviceConfig(name: "foo", type: .mobile, capabilities: [])
|
||||
)
|
||||
}
|
|
@ -108,6 +108,12 @@ EXTRA_PACKAGE_METADATA = {
|
|||
"license": "Apache-2.0",
|
||||
"license_file": "https://raw.githubusercontent.com/apple/swift-protobuf/master/LICENSE.txt"
|
||||
},
|
||||
"ext-swift-keychain-wrapper": {
|
||||
"name": "SwiftKeychainWrapper",
|
||||
"repository": "https://github.com/jrendel/SwiftKeychainWrapper",
|
||||
"license": "MIT",
|
||||
"license_file": "https://raw.githubusercontent.com/jrendel/SwiftKeychainWrapper/develop/LICENSE"
|
||||
},
|
||||
"ext-nss": {
|
||||
"name": "NSS",
|
||||
"repository": "https://hg.mozilla.org/projects/nss",
|
||||
|
@ -642,6 +648,7 @@ class WorkspaceMetadata(object):
|
|||
extras.add("ext-protobuf")
|
||||
if self.target_is_ios(target):
|
||||
extras.add("ext-swift-protobuf")
|
||||
extras.add("ext-swift-keychain-wrapper")
|
||||
for dep in deps:
|
||||
name = self.pkgInfoById[dep]["name"]
|
||||
if name in PACKAGES_WITH_EXTRA_DEPENDENCIES:
|
||||
|
|
Загрузка…
Ссылка в новой задаче