diff --git a/Account/SyncAuthState.swift b/Account/SyncAuthState.swift new file mode 100644 index 000000000000..8bb14714734e --- /dev/null +++ b/Account/SyncAuthState.swift @@ -0,0 +1,189 @@ +// 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 Common +import MozillaAppServices +import Shared +import SwiftyJSON + +public let FxAClientErrorDomain = "org.mozilla.fxa.error" +public let FxAClientUnknownError = NSError( + domain: FxAClientErrorDomain, + code: 999, + userInfo: [NSLocalizedDescriptionKey: "Invalid server response"]) + +public struct FxAccountRemoteError { + static let AttemptToOperateOnAnUnverifiedAccount: Int32 = 104 + static let InvalidAuthenticationToken: Int32 = 110 + static let EndpointIsNoLongerSupported: Int32 = 116 + static let IncorrectLoginMethodForThisAccount: Int32 = 117 + static let IncorrectKeyRetrievalMethodForThisAccount: Int32 = 118 + static let IncorrectAPIVersionForThisAccount: Int32 = 119 + static let UnknownDevice: Int32 = 123 + static let DeviceSessionConflict: Int32 = 124 + static let UnknownError: Int32 = 999 +} + +public enum FxAClientError: Error, CustomStringConvertible { + case remote(RemoteError) + case local(NSError) + + public var description: String { + switch self { + case .remote(let err): return "FxA remote error: \(err)" + case .local(let err): return "FxA local error: \(err)" + } + } +} + +public struct RemoteError { + let code: Int32 + let errno: Int32 + let error: String? + let message: String? + let info: String? + + var isUpgradeRequired: Bool { + return errno == FxAccountRemoteError.EndpointIsNoLongerSupported + || errno == FxAccountRemoteError.IncorrectLoginMethodForThisAccount + || errno == FxAccountRemoteError.IncorrectKeyRetrievalMethodForThisAccount + || errno == FxAccountRemoteError.IncorrectAPIVersionForThisAccount + } + + var isInvalidAuthentication: Bool { + return code == 401 + } + + var isUnverified: Bool { + return errno == FxAccountRemoteError.AttemptToOperateOnAnUnverifiedAccount + } +} + +private let CurrentSyncAuthStateCacheVersion = 1 + +public struct SyncAuthStateCache { + let token: TokenServerToken + let forKey: Data + let expiresAt: Timestamp +} + +public protocol SyncAuthState { + func invalidate() + func token(_ now: Timestamp, canBeExpired: Bool) -> Deferred> + var enginesEnablements: [String: Bool]? { get set } + var clientName: String? { get set } +} + +public func syncAuthStateCachefromJSON(_ json: JSON) -> SyncAuthStateCache? { + if let version = json["version"].int { + if version != CurrentSyncAuthStateCacheVersion { + DefaultLogger.shared.log("Sync Auth State Cache is wrong version; dropping.", + level: .warning, + category: .sync) + return nil + } + if let token = TokenServerToken.fromJSON(json["token"]), + let forKey = json["forKey"].string?.hexDecodedData, + let expiresAt = json["expiresAt"].int64 { + return SyncAuthStateCache(token: token, forKey: forKey, expiresAt: Timestamp(expiresAt)) + } + } + return nil +} + +extension SyncAuthStateCache: JSONLiteralConvertible { + public func asJSON() -> JSON { + return JSON([ + "version": CurrentSyncAuthStateCacheVersion, + "token": token.asJSON(), + "forKey": forKey.hexEncodedString, + "expiresAt": NSNumber(value: expiresAt), + ] as NSDictionary) + } +} + +open class FirefoxAccountSyncAuthState: SyncAuthState { + private var logger: Logger + fileprivate let cache: KeychainCache + public var enginesEnablements: [String: Bool]? + public var clientName: String? + + init(cache: KeychainCache, + logger: Logger = DefaultLogger.shared) { + self.cache = cache + self.logger = logger + } + + // If a token gives you a 401, invalidate it and request a new one. + open func invalidate() { + logger.log("Invalidating cached token server token.", + level: .info, + category: .sync) + self.cache.value = nil + } + + open func token(_ now: Timestamp, canBeExpired: Bool) -> Deferred> { + if let value = cache.value { + // Give ourselves some room to do work. + let isExpired = value.expiresAt < now + 5 * OneMinuteInMilliseconds + if canBeExpired { + if isExpired { + logger.log("Returning cached expired token.", + level: .info, + category: .sync) + } else { + logger.log("Returning cached token, which should be valid.", + level: .info, + category: .sync) + } + return deferMaybe((token: value.token, forKey: value.forKey)) + } + + if !isExpired { + logger.log("Returning cached token, which should be valid.", + level: .info, + category: .sync) + return deferMaybe((token: value.token, forKey: value.forKey)) + } + } + + let deferred = Deferred>() + + RustFirefoxAccounts.shared.accountManager.uponQueue(.main) { accountManager in + accountManager.getTokenServerEndpointURL { result in + guard case .success(let tokenServerEndpointURL) = result else { + deferred.fill(Maybe(failure: FxAClientError.local(NSError()))) + return + } + + let client = TokenServerClient(url: tokenServerEndpointURL) + accountManager.getAccessToken(scope: OAuthScope.oldSync) { res in + switch res { + case .failure(let err): + deferred.fill(Maybe(failure: err as MaybeErrorType)) + case .success(let accessToken): + self.logger.log("Fetching token server token.", + level: .debug, + category: .sync) + client.token(token: accessToken.token, kid: accessToken.key!.kid).upon { result in + guard let token = result.successValue else { + deferred.fill(Maybe(failure: result.failureValue!)) + return + } + let kSync = Bytes.base64urlSafeDecodedData(accessToken.key!.k)! + let newCache = SyncAuthStateCache(token: token, forKey: kSync, expiresAt: now + 1000 * token.durationInSeconds) + self.logger.log("Fetched token server token! Token expires at \(newCache.expiresAt).", + level: .debug, + category: .sync) + self.cache.value = newCache + deferred.fill(Maybe(success: (token: token, forKey: kSync))) + } + } + } + } + } + return deferred + } +} diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index ab71a68ea4d2..5109c06f0849 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -225,6 +225,7 @@ 2FCAE2781ABB531100877008 /* Visit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCAE25C1ABB531100877008 /* Visit.swift */; }; 2FCAE2841ABB533A00877008 /* MockFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCAE2791ABB533A00877008 /* MockFiles.swift */; }; 2FDB10931A9FBEC5006CF312 /* PrefsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FDB10921A9FBEC5006CF312 /* PrefsTests.swift */; }; + 2FDBCF611ABFC9DE00AFF7F0 /* SyncAuthState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FDBCF601ABFC9DE00AFF7F0 /* SyncAuthState.swift */; }; 2FDE87FE1ABB3817005317B1 /* LegacyRemoteTabsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FDE87FD1ABB3817005317B1 /* LegacyRemoteTabsPanel.swift */; }; 318FB6EB1DB5600D0004E40F /* SQLiteHistoryFactories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318FB6EA1DB5600D0004E40F /* SQLiteHistoryFactories.swift */; }; 31ADB5DA1E58CEC300E87909 /* ClipboardBarDisplayHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31ADB5D91E58CEC300E87909 /* ClipboardBarDisplayHandler.swift */; }; @@ -590,6 +591,7 @@ 8A3EF7FD2A2FCFAC00796E3A /* AppReviewPromptSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3EF7FC2A2FCFAC00796E3A /* AppReviewPromptSetting.swift */; }; 8A3EF7FF2A2FCFBB00796E3A /* ChangeToChinaSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3EF7FE2A2FCFBB00796E3A /* ChangeToChinaSetting.swift */; }; 8A3EF8012A2FCFC900796E3A /* FasterInactiveTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3EF8002A2FCFC900796E3A /* FasterInactiveTabs.swift */; }; + 8A3EF8052A2FCFE500796E3A /* ForgetSyncAuthStateDebugSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3EF8042A2FCFE500796E3A /* ForgetSyncAuthStateDebugSetting.swift */; }; 8A3EF8072A2FCFF700796E3A /* SentryIDSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3EF8062A2FCFF700796E3A /* SentryIDSetting.swift */; }; 8A3EF8092A2FD02B00796E3A /* ExperimentsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3EF8082A2FD02B00796E3A /* ExperimentsSettings.swift */; }; 8A3EF80D2A2FD04D00796E3A /* ResetWallpaperOnboardingPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3EF80C2A2FD04D00796E3A /* ResetWallpaperOnboardingPage.swift */; }; @@ -2379,6 +2381,7 @@ 2FCAE33D1ABB5F1800877008 /* Storage-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Storage-Bridging-Header.h"; sourceTree = ""; }; 2FCF4713ABA14D85F70567AC /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = kk.lproj/Today.strings; sourceTree = ""; }; 2FDB10921A9FBEC5006CF312 /* PrefsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrefsTests.swift; sourceTree = ""; }; + 2FDBCF601ABFC9DE00AFF7F0 /* SyncAuthState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncAuthState.swift; sourceTree = ""; }; 2FDE46BCA5E27EF7DC02CE20 /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sq; path = sq.lproj/FindInPage.strings; sourceTree = ""; }; 2FDE87FD1ABB3817005317B1 /* LegacyRemoteTabsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyRemoteTabsPanel.swift; sourceTree = ""; }; 2FEBABAE1AB3659000DB5728 /* ResultTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResultTests.swift; sourceTree = ""; }; @@ -4971,6 +4974,7 @@ 8A3EF7FC2A2FCFAC00796E3A /* AppReviewPromptSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewPromptSetting.swift; sourceTree = ""; }; 8A3EF7FE2A2FCFBB00796E3A /* ChangeToChinaSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeToChinaSetting.swift; sourceTree = ""; }; 8A3EF8002A2FCFC900796E3A /* FasterInactiveTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FasterInactiveTabs.swift; sourceTree = ""; }; + 8A3EF8042A2FCFE500796E3A /* ForgetSyncAuthStateDebugSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgetSyncAuthStateDebugSetting.swift; sourceTree = ""; }; 8A3EF8062A2FCFF700796E3A /* SentryIDSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryIDSetting.swift; sourceTree = ""; }; 8A3EF8082A2FD02B00796E3A /* ExperimentsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentsSettings.swift; sourceTree = ""; }; 8A3EF80C2A2FD04D00796E3A /* ResetWallpaperOnboardingPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetWallpaperOnboardingPage.swift; sourceTree = ""; }; @@ -7586,6 +7590,7 @@ 2F14E1391ABB890800FF98DB /* Account-Bridging-Header.h */, 3905B4D41E8E7A6B0027D953 /* FxAPushMessageHandler.swift */, 2FA436271ABB8436008031D1 /* HawkHelper.swift */, + 2FDBCF601ABFC9DE00AFF7F0 /* SyncAuthState.swift */, 2FA436281ABB8436008031D1 /* TokenServerClient.swift */, 2FA435FD1ABB83B4008031D1 /* Supporting Files */, ); @@ -8349,6 +8354,7 @@ 8A3EF8002A2FCFC900796E3A /* FasterInactiveTabs.swift */, BCFF93F12AAF9688005B5B71 /* FirefoxSuggestSettings.swift */, 8A3EF7FA2A2FCF9D00796E3A /* ForceCrashSetting.swift */, + 8A3EF8042A2FCFE500796E3A /* ForgetSyncAuthStateDebugSetting.swift */, 8A3EF7EF2A2FCF3100796E3A /* HiddenSettings.swift */, 8A3EF8142A2FD08800796E3A /* OpenFiftyTabsDebugOption.swift */, 8A3EF8122A2FD07A00796E3A /* ResetContextualHints.swift */, @@ -12133,6 +12139,7 @@ EB07F860240D696000924860 /* PushNotificationSetup.swift in Sources */, C8E2E80D23D20FB3005AACE6 /* RustFirefoxAccounts.swift in Sources */, C8E2E80C23D20FB3005AACE6 /* Avatar.swift in Sources */, + 2FDBCF611ABFC9DE00AFF7F0 /* SyncAuthState.swift in Sources */, 96666D0229969F7D00A4029F /* GeneralizedImageFetcher.swift in Sources */, 45355B272A269EAC00B1EA8E /* PushConfiguration.swift in Sources */, 2FA436351ABB8436008031D1 /* TokenServerClient.swift in Sources */, @@ -12458,6 +12465,7 @@ C2D1A10D2A66C70000205DCC /* BookmarksCoordinator.swift in Sources */, 396E38F11EE0C8EC00CC180F /* FxAPushMessageHandler.swift in Sources */, 8A76B01629F6EB3900A82607 /* ScreenshotService.swift in Sources */, + 8A3EF8052A2FCFE500796E3A /* ForgetSyncAuthStateDebugSetting.swift in Sources */, E4CD9F6D1A77DD2800318571 /* ReaderModeStyleViewController.swift in Sources */, E13E9AB52AAB0FB5001A0E9D /* FakespotViewModel.swift in Sources */, 8A5D1CBD2A30DC4E005AD35C /* AccountStatusSetting.swift in Sources */, diff --git a/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift b/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift index 0e2cf0fa84a6..542f119e8d4e 100644 --- a/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift +++ b/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift @@ -313,6 +313,7 @@ class AppSettingsTableViewController: SettingsTableViewController, ExportBrowserDataSetting(settings: self), DeleteExportedDataSetting(settings: self), ForceCrashSetting(settings: self), + ForgetSyncAuthStateDebugSetting(settings: self), SwitchFakespotProduction(settings: self, settingsDelegate: self), ChangeToChinaSetting(settings: self), AppReviewPromptSetting(settings: self, settingsDelegate: self), diff --git a/Client/Frontend/Settings/Main/Debug/ForgetSyncAuthStateDebugSetting.swift b/Client/Frontend/Settings/Main/Debug/ForgetSyncAuthStateDebugSetting.swift new file mode 100644 index 000000000000..8981b9984fd4 --- /dev/null +++ b/Client/Frontend/Settings/Main/Debug/ForgetSyncAuthStateDebugSetting.swift @@ -0,0 +1,18 @@ +// 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 + +class ForgetSyncAuthStateDebugSetting: HiddenSetting { + override var title: NSAttributedString? { + return NSAttributedString( + string: "Forget Sync auth state", + attributes: [NSAttributedString.Key.foregroundColor: theme.colors.textPrimary]) + } + + override func onClick(_ navigationController: UINavigationController?) { + settings.profile.rustFxA.syncAuthState.invalidate() + settings.tableView.reloadData() + } +} diff --git a/Client/Telemetry/TelemetryWrapper.swift b/Client/Telemetry/TelemetryWrapper.swift index 1ec21617757d..a56258745fc2 100644 --- a/Client/Telemetry/TelemetryWrapper.swift +++ b/Client/Telemetry/TelemetryWrapper.swift @@ -177,6 +177,7 @@ class TelemetryWrapper: TelemetryWrapperProtocol, FeatureFlaggable { // Save the profile so we can record settings from it when the notification below fires. self.profile = profile + setSyncDeviceId() SponsoredTileTelemetry.setupContextId() // Register an observer to record settings and other metrics that are more appropriate to @@ -195,6 +196,20 @@ class TelemetryWrapper: TelemetryWrapperProtocol, FeatureFlaggable { ) } + // Sets hashed fxa sync device id for glean deletion ping + func setSyncDeviceId() { + // Grab our token so we can use the hashed_fxa_uid and clientGUID for deletion-request ping + guard let accountManager = RustFirefoxAccounts.shared.accountManager.peek(), + let state = accountManager.deviceConstellation()?.state(), + let clientGUID = state.localDevice?.id + else { return } + + RustFirefoxAccounts.shared.syncAuthState.token(Date.now(), canBeExpired: true) >>== { (token, _) in + let deviceId = (clientGUID + token.hashedFxAUID).sha256.hexEncodedString + GleanMetrics.Deletion.syncDeviceId.set(deviceId) + } + } + @objc func recordFinishedLaunchingPreferenceMetrics(notification: NSNotification) { guard let profile = self.profile else { return } diff --git a/RustFxA/RustFirefoxAccounts.swift b/RustFxA/RustFirefoxAccounts.swift index 5d71b67314d9..2b5758aa7220 100644 --- a/RustFxA/RustFirefoxAccounts.swift +++ b/RustFxA/RustFirefoxAccounts.swift @@ -32,6 +32,7 @@ open class RustFirefoxAccounts { public var accountManager = Deferred() private static var isInitializingAccountManager = false public var avatar: Avatar? + public let syncAuthState: SyncAuthState fileprivate static var prefs: Prefs? public let pushNotifications = PushNotificationSetup() private let logger: Logger @@ -81,6 +82,19 @@ open class RustFirefoxAccounts { return RustFirefoxAccounts.shared.accountManager } + private static let prefKeySyncAuthStateUniqueID = "PrefKeySyncAuthStateUniqueID" + private static func syncAuthStateUniqueId(prefs: Prefs?) -> String { + let id: String + let key = RustFirefoxAccounts.prefKeySyncAuthStateUniqueID + if let _id = prefs?.stringForKey(key) { + id = _id + } else { + id = UUID().uuidString + prefs?.setString(id, forKey: key) + } + return id + } + @discardableResult public static func reconfig(prefs: Prefs) -> Deferred { if isInitializingAccountManager { @@ -138,7 +152,12 @@ open class RustFirefoxAccounts { // before any Application Services component gets used. Viaduct.shared.useReqwestBackend() + let prefs = RustFirefoxAccounts.prefs self.logger = logger + let cache = KeychainCache.fromBranch("rustAccounts.syncAuthState", + withLabel: RustFirefoxAccounts.syncAuthStateUniqueId(prefs: prefs), + factory: syncAuthStateCachefromJSON) + syncAuthState = FirefoxAccountSyncAuthState(cache: cache) // Called when account is logged in for the first time, on every app start when the account is found (even if offline). NotificationCenter.default.addObserver(forName: .accountAuthenticated, object: nil, queue: .main) { [weak self] notification in @@ -207,8 +226,10 @@ open class RustFirefoxAccounts { guard let accountManager = accountManager.peek() else { return } accountManager.logout { _ in } let prefs = RustFirefoxAccounts.prefs + prefs?.removeObjectForKey(RustFirefoxAccounts.prefKeySyncAuthStateUniqueID) prefs?.removeObjectForKey(prefKeyCachedUserProfile) prefs?.removeObjectForKey(PendingAccountDisconnectedKey) + self.syncAuthState.invalidate() cachedUserProfile = nil MZKeychainWrapper.sharedClientAppContainerKeychain.removeObject(forKey: KeychainKey.apnsToken, withAccessibility: .afterFirstUnlock) } diff --git a/Shared/Bytes.swift b/Shared/Bytes.swift index 7bc416c0d33b..38bf9248d70e 100644 --- a/Shared/Bytes.swift +++ b/Shared/Bytes.swift @@ -21,6 +21,24 @@ extension Bytes { return Data(base64Encoded: b64, options: []) } + public static func base64urlSafeDecodedData(_ b64: String) -> Data? { + // Replace the URL-safe chars with their URL-unsafe variants + // https://en.wikipedia.org/wiki/Base64#Variants_summary_table + var base64 = b64 + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + // Add padding if needed + if base64.count % 4 != 0 { + base64.append(String(repeating: "=", count: 4 - base64.count % 4)) + } + + if let data = Data(base64Encoded: base64) { + return data + } + return nil + } + /** * Turn a string of base64 characters into an NSData *without decoding*. * This is to allow HMAC to be computed of the raw base64 string. diff --git a/Tests/AccountTests/Push/AutopushTests.swift b/Tests/AccountTests/Push/AutopushTests.swift index 7727a854c2f6..dca44771506a 100644 --- a/Tests/AccountTests/Push/AutopushTests.swift +++ b/Tests/AccountTests/Push/AutopushTests.swift @@ -5,8 +5,7 @@ import Foundation import XCTest import MozillaAppServices -@testable import Account -@testable import Client +import Account class AutopushTests: XCTestCase { private var mockPushManager: MockPushManager! diff --git a/Tests/SyncTests/CryptoTests.swift b/Tests/SyncTests/CryptoTests.swift index 6a2bb7e399eb..40ff6ff38add 100644 --- a/Tests/SyncTests/CryptoTests.swift +++ b/Tests/SyncTests/CryptoTests.swift @@ -43,4 +43,26 @@ c2l0cyI6W3siZGF0ZSI6MTMxOTE0OTAxMjM3MjQyNSwidHlwZSI6MX1dfQ== func testBadBase64() { XCTAssertNil(Bytes.decodeBase64(invalidB64)) } + + func testBase64DecodeUrlSafe() { + var decodedData = Bytes.base64urlSafeDecodedData("VGhpcyB3b3JrcyE") + var decodedString = String(data: decodedData!, encoding: .utf8) + XCTAssertEqual(decodedString, "This works!") + + decodedData = Bytes.base64urlSafeDecodedData("cUw4UjRRSWNRL1pzUnFPQWJlUmZjWmhpbE4vTWtzUnREYUVyTUErPQ") + decodedString = String(data: decodedData!, encoding: .utf8) + XCTAssertEqual(decodedString, "qL8R4QIcQ/ZsRqOAbeRfcZhilN/MksRtDaErMA+=") + + decodedData = Bytes.base64urlSafeDecodedData("VGhpcytzaG91bGQvd29yay1maW5l") + decodedString = String(data: decodedData!, encoding: .utf8) + XCTAssertEqual(decodedString, "This+should/work-fine") + + decodedData = Bytes.base64urlSafeDecodedData("c29tZS90b2tlbi9zZXJ2ZXIvc3R1ZmY=") + decodedString = String(data: decodedData!, encoding: .utf8) + XCTAssertEqual(decodedString, "some/token/server/stuff") + + decodedData = Bytes.base64urlSafeDecodedData("c3ViamVjdHM_X2Q") + decodedString = String(data: decodedData!, encoding: .utf8) + XCTAssertEqual(decodedString, "subjects?_d") + } }