feat(core): improve login flow (#15219)
#### PR Dependency Tree * **PR #15219** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added secure, automatic auth session token refresh and request replay for expired-token responses across Android, iOS, and Electron. * Updated sign-in flows to manage sessions without returning tokens to the app layer. * Added “Devices” management UI with sign out per device and sign out all. * Enabled support for both Hashcash and Turnstile captcha providers. * **Bug Fixes** * Improved refresh de-duplication, inflight cancellation/clear behavior, and recovery from corrupted/invalid sessions. * **Tests** * Expanded auth-session, refresh/revoke, and replay coverage (Electron unit tests, Android instrumentation tests, iOS auth date parser tests). * **Chores** * Removed CAPTCHA site key from build-time configuration and adjusted CI test execution. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -33,6 +33,8 @@
|
||||
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
|
||||
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */; };
|
||||
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */; };
|
||||
AA0000040000000000000000 /* AuthDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* AuthDateParser.swift */; };
|
||||
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -83,6 +85,9 @@
|
||||
C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = affine_mobile_nativeFFI.modulemap; sourceTree = "<group>"; };
|
||||
E5E5070D1CA1200D4964D91F /* Pods-AFFiNE.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.release.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.release.xcconfig"; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = "<group>"; };
|
||||
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
|
||||
AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -94,6 +99,13 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
AA0000070000000000000000 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -127,6 +139,7 @@
|
||||
children = (
|
||||
C4C97C722D0307B700BC2AD1 /* uniffi */,
|
||||
9D90BE242CCB9876006677DB /* App */,
|
||||
AA00000D0000000000000000 /* AppTests */,
|
||||
50802D5F2D112F7D00694021 /* Packages */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||
@@ -140,6 +153,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* AFFiNE.app */,
|
||||
AA0000030000000000000000 /* AFFiNETests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -192,6 +206,15 @@
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA00000D0000000000000000 /* AppTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000010000000000000000 /* AuthDateParser.swift */,
|
||||
AA0000020000000000000000 /* AuthDateParserTests.swift */,
|
||||
);
|
||||
path = AppTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C4C97C722D0307B700BC2AD1 /* uniffi */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -206,6 +229,23 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
AA0000090000000000000000 /* AFFiNETests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */;
|
||||
buildPhases = (
|
||||
AA0000060000000000000000 /* Sources */,
|
||||
AA0000070000000000000000 /* Frameworks */,
|
||||
AA0000080000000000000000 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = AFFiNETests;
|
||||
productName = AFFiNETests;
|
||||
productReference = AA0000030000000000000000 /* AFFiNETests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
504EC3031FED79650016851F /* AFFiNE */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "AFFiNE" */;
|
||||
@@ -240,6 +280,9 @@
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
AA0000090000000000000000 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1600;
|
||||
@@ -260,11 +303,19 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* AFFiNE */,
|
||||
AA0000090000000000000000 /* AFFiNETests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
AA0000080000000000000000 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -340,6 +391,15 @@
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
AA0000060000000000000000 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0000040000000000000000 /* AuthDateParser.swift in Sources */,
|
||||
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -373,6 +433,38 @@
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
AA00000A0000000000000000 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGNING_ALLOWED = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA00000B0000000000000000 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGNING_ALLOWED = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -574,6 +666,15 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA00000A0000000000000000 /* Debug */,
|
||||
AA00000B0000000000000000 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "NO"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A11F1E000000000000000005"
|
||||
BuildableName = "AFFiNETests.xctest"
|
||||
BlueprintName = "AFFiNETests"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A11F1E000000000000000005"
|
||||
BuildableName = "AFFiNETests.xctest"
|
||||
BlueprintName = "AFFiNETests"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
</Scheme>
|
||||
@@ -21,6 +21,20 @@
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "NO"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000090000000000000000"
|
||||
BuildableName = "AFFiNETests.xctest"
|
||||
BlueprintName = "AFFiNETests"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
@@ -29,6 +43,18 @@
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000090000000000000000"
|
||||
BuildableName = "AFFiNETests.xctest"
|
||||
BlueprintName = "AFFiNETests"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
func parseAuthISO8601Date(_ value: String) -> Date? {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value)
|
||||
}
|
||||
@@ -1,6 +1,255 @@
|
||||
import Capacitor
|
||||
import Foundation
|
||||
import Security
|
||||
import UIKit
|
||||
|
||||
private struct AuthSessionInfo: Codable {
|
||||
let id: String
|
||||
let absoluteExpiresAt: String
|
||||
}
|
||||
|
||||
private struct AuthTokenResponse: Codable {
|
||||
let tokenType: String
|
||||
let accessToken: String
|
||||
let expiresIn: Int
|
||||
let refreshToken: String
|
||||
let refreshExpiresAt: String
|
||||
let session: AuthSessionInfo
|
||||
}
|
||||
|
||||
private struct StoredAuthTokenPair: Codable {
|
||||
let version: Int
|
||||
let tokenType: String
|
||||
let accessToken: String
|
||||
let accessExpiresAt: Date
|
||||
let refreshToken: String
|
||||
let refreshExpiresAt: String
|
||||
let session: AuthSessionInfo
|
||||
}
|
||||
|
||||
private struct AuthErrorResponse: Decodable {
|
||||
let code: String?
|
||||
}
|
||||
|
||||
private struct AuthServerError: Error {
|
||||
let code: String?
|
||||
let statusCode: Int
|
||||
|
||||
var permanentlyInvalidatesSession: Bool {
|
||||
switch code {
|
||||
case "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED", "REFRESH_TOKEN_INVALID",
|
||||
"REFRESH_TOKEN_REUSED", "UNSUPPORTED_CLIENT_VERSION", "ACCESS_TOKEN_INVALID":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AuthOperationCancelled: Error {}
|
||||
|
||||
private struct AuthRefreshOperation {
|
||||
let id: UUID
|
||||
let task: Task<StoredAuthTokenPair, Error>
|
||||
}
|
||||
|
||||
private actor AuthSessionBroker {
|
||||
private let tokenService = "app.affine.pro.auth-token"
|
||||
private var refreshTasks: [String: AuthRefreshOperation] = [:]
|
||||
private var mutationEpochs: [String: UInt] = [:]
|
||||
|
||||
func store(_ endpoint: String, response: AuthTokenResponse) throws {
|
||||
invalidateRefresh(canonicalEndpoint(endpoint))
|
||||
try write(endpoint, tokenPair(response))
|
||||
}
|
||||
|
||||
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws -> String? {
|
||||
guard let pair = try read(endpoint) else { return nil }
|
||||
if pair.accessExpiresAt.timeIntervalSinceNow > minValidity {
|
||||
return pair.accessToken
|
||||
}
|
||||
return try await refresh(endpoint).accessToken
|
||||
}
|
||||
|
||||
func refreshAccessToken(_ endpoint: String) async throws -> String {
|
||||
try await refresh(endpoint).accessToken
|
||||
}
|
||||
|
||||
func signOut(_ endpoint: String) async throws {
|
||||
let key = canonicalEndpoint(endpoint)
|
||||
let pair = try read(endpoint)
|
||||
invalidateRefresh(key)
|
||||
try delete(endpoint)
|
||||
guard let pair else { return }
|
||||
_ = try await request(
|
||||
endpoint, action: "/api/auth/session/revoke",
|
||||
body: ["refreshToken": pair.refreshToken])
|
||||
}
|
||||
|
||||
func clear(_ endpoint: String) throws {
|
||||
invalidateRefresh(canonicalEndpoint(endpoint))
|
||||
try delete(endpoint)
|
||||
}
|
||||
|
||||
private func refresh(_ endpoint: String) async throws -> StoredAuthTokenPair {
|
||||
let key = canonicalEndpoint(endpoint)
|
||||
if let operation = refreshTasks[key] { return try await operation.task.value }
|
||||
guard let current = try read(endpoint) else { throw AuthError.tokenNotFound }
|
||||
let epoch = mutationEpochs[key, default: 0]
|
||||
let operationId = UUID()
|
||||
let task = Task {
|
||||
do {
|
||||
let data = try await self.request(
|
||||
endpoint, action: "/api/auth/session/refresh",
|
||||
body: ["refreshToken": current.refreshToken])
|
||||
let response = try JSONDecoder().decode(AuthTokenResponse.self, from: data)
|
||||
let pair = try self.tokenPair(response)
|
||||
guard !Task.isCancelled, self.mutationEpochs[key, default: 0] == epoch else {
|
||||
throw AuthOperationCancelled()
|
||||
}
|
||||
try self.write(endpoint, pair)
|
||||
return pair
|
||||
} catch let error as AuthServerError where error.permanentlyInvalidatesSession {
|
||||
if self.mutationEpochs[key, default: 0] == epoch {
|
||||
try? self.delete(endpoint)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
refreshTasks[key] = AuthRefreshOperation(id: operationId, task: task)
|
||||
defer {
|
||||
if refreshTasks[key]?.id == operationId {
|
||||
refreshTasks[key] = nil
|
||||
}
|
||||
}
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
private func invalidateRefresh(_ key: String) {
|
||||
mutationEpochs[key, default: 0] &+= 1
|
||||
refreshTasks[key]?.task.cancel()
|
||||
refreshTasks[key] = nil
|
||||
}
|
||||
|
||||
private func tokenPair(_ response: AuthTokenResponse) throws -> StoredAuthTokenPair {
|
||||
guard response.tokenType == "Bearer", !response.accessToken.isEmpty,
|
||||
!response.refreshToken.isEmpty, (1...86_400).contains(response.expiresIn),
|
||||
parseAuthISO8601Date(response.refreshExpiresAt) != nil,
|
||||
parseAuthISO8601Date(response.session.absoluteExpiresAt) != nil
|
||||
else {
|
||||
throw AuthError.invalidTokenResponse
|
||||
}
|
||||
return StoredAuthTokenPair(
|
||||
version: 1,
|
||||
tokenType: response.tokenType,
|
||||
accessToken: response.accessToken,
|
||||
accessExpiresAt: Date().addingTimeInterval(TimeInterval(response.expiresIn)),
|
||||
refreshToken: response.refreshToken,
|
||||
refreshExpiresAt: response.refreshExpiresAt,
|
||||
session: response.session)
|
||||
}
|
||||
|
||||
private func request(_ endpoint: String, action: String, body: [String: String]) async throws -> Data {
|
||||
guard let url = URL(string: "\(canonicalEndpoint(endpoint))\(action)") else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.httpShouldHandleCookies = false
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("native", forHTTPHeaderField: "x-affine-client-kind")
|
||||
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
request.timeoutInterval = 10
|
||||
for attempt in 0..<3 {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
if response.statusCode < 400 { return data }
|
||||
let error = AuthServerError(
|
||||
code: try? JSONDecoder().decode(AuthErrorResponse.self, from: data).code,
|
||||
statusCode: response.statusCode)
|
||||
guard response.statusCode >= 500, attempt < 2 else { throw error }
|
||||
} catch let error as AuthServerError {
|
||||
if error.statusCode < 500 || attempt == 2 { throw error }
|
||||
} catch {
|
||||
if Task.isCancelled { throw AuthOperationCancelled() }
|
||||
if attempt == 2 { throw error }
|
||||
}
|
||||
let delay = UInt64((200 * (1 << attempt)) + Int.random(in: 0...150)) * 1_000_000
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
}
|
||||
throw AuthError.internalError
|
||||
}
|
||||
|
||||
private func canonicalEndpoint(_ endpoint: String) -> String {
|
||||
guard let url = URL(string: endpoint), let scheme = url.scheme, let host = url.host else {
|
||||
return endpoint
|
||||
}
|
||||
let normalizedScheme = scheme.lowercased()
|
||||
let defaultPort = normalizedScheme == "http" ? 80 : normalizedScheme == "https" ? 443 : nil
|
||||
let port = url.port.flatMap { $0 == defaultPort ? nil : ":\($0)" } ?? ""
|
||||
return "\(normalizedScheme)://\(host.lowercased())\(port)"
|
||||
}
|
||||
|
||||
private func query(_ endpoint: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: tokenService,
|
||||
kSecAttrAccount as String: canonicalEndpoint(endpoint),
|
||||
]
|
||||
}
|
||||
|
||||
private func read(_ endpoint: String) throws -> StoredAuthTokenPair? {
|
||||
var query = query(endpoint)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
if status == errSecInteractionNotAllowed || status == errSecNotAvailable {
|
||||
throw AuthError.credentialStoreUnavailable
|
||||
}
|
||||
guard status == errSecSuccess, let data = item as? Data else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
guard let pair = try? JSONDecoder().decode(StoredAuthTokenPair.self, from: data),
|
||||
pair.version == 1, pair.tokenType == "Bearer", !pair.accessToken.isEmpty,
|
||||
!pair.refreshToken.isEmpty, pair.accessExpiresAt.timeIntervalSince1970.isFinite,
|
||||
parseAuthISO8601Date(pair.refreshExpiresAt) != nil,
|
||||
parseAuthISO8601Date(pair.session.absoluteExpiresAt) != nil
|
||||
else {
|
||||
try delete(endpoint)
|
||||
return nil
|
||||
}
|
||||
return pair
|
||||
}
|
||||
|
||||
private func write(_ endpoint: String, _ pair: StoredAuthTokenPair) throws {
|
||||
let data = try JSONEncoder().encode(pair)
|
||||
var add = query(endpoint)
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let update = [kSecValueData as String: data]
|
||||
let status = SecItemUpdate(query(endpoint) as CFDictionary, update as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
guard SecItemAdd(add as CFDictionary, nil) == errSecSuccess else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(_ endpoint: String) throws {
|
||||
let status = SecItemDelete(query(endpoint) as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "AuthPlugin"
|
||||
@@ -11,12 +260,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "readEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "writeEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "deleteEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getValidAccessToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "refreshAccessToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clearEndpointSession", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
private let tokenService = "app.affine.pro.auth-token"
|
||||
private let broker = AuthSessionBroker()
|
||||
private let authCookieNames = Set(["affine_session", "affine_user_id", "affine_csrf_token"])
|
||||
|
||||
private func canonicalEndpoint(_ endpoint: String) -> String {
|
||||
@@ -38,37 +287,37 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return "\(normalizedScheme)://\(normalizedHost)\(port)"
|
||||
}
|
||||
|
||||
@objc public func readEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
if let token = try self.readToken(endpoint) {
|
||||
call.resolve(["token": token])
|
||||
} else {
|
||||
call.resolve(["token": NSNull()])
|
||||
@objc public func getValidAccessToken(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let token = try await broker.validAccessToken(endpoint)
|
||||
call.resolve(["token": token ?? NSNull()])
|
||||
} catch {
|
||||
call.reject("Failed to get access token, \(error)", nil, error)
|
||||
}
|
||||
} catch {
|
||||
call.reject("Failed to read endpoint token, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func writeEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let token = try call.getStringEnsure("token")
|
||||
try self.writeToken(endpoint, token)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to write endpoint token, \(error)", nil, error)
|
||||
@objc public func clearEndpointSession(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
try await broker.clear(call.getStringEnsure("endpoint"))
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to clear auth session, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func deleteEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
try self.deleteToken(endpoint)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to delete endpoint token, \(error)", nil, error)
|
||||
@objc public func refreshAccessToken(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let token = try await broker.refreshAccessToken(call.getStringEnsure("endpoint"))
|
||||
call.resolve(["token": token])
|
||||
} catch {
|
||||
call.reject("Failed to refresh access token, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +344,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
try await self.exchangeSession(endpoint, data)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -125,7 +375,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
try await self.exchangeSession(endpoint, data)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -147,6 +398,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"x-affine-client-kind": "native",
|
||||
"x-captcha-token": verifyToken,
|
||||
"x-captcha-challenge": challenge,
|
||||
"x-captcha-provider": verifyToken == nil ? nil : (challenge == nil ? "turnstile" : "hashcash"),
|
||||
], body: ["email": email, "password": password])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
@@ -158,7 +410,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
try await self.exchangeSession(endpoint, data)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -186,7 +439,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
try await self.exchangeSession(endpoint, data)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -197,23 +451,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
Task {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let token = call.getString("token")
|
||||
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/sign-out",
|
||||
headers: [
|
||||
"Authorization": token.map { "Bearer \($0)" }
|
||||
], body: nil)
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
} else {
|
||||
call.reject("Failed to sign out")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try await broker.signOut(endpoint)
|
||||
self.clearAuthCookies(endpoint)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
@@ -222,16 +460,6 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private func tokenFromResponse(_ data: Data) throws -> String {
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = json["token"] as? String
|
||||
else {
|
||||
throw AuthError.tokenNotFound
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
private func exchangeCodeFromResponse(_ data: Data) throws -> String {
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let code = json["exchangeCode"] as? String
|
||||
@@ -242,21 +470,33 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return code
|
||||
}
|
||||
|
||||
private func exchangeSession(_ endpoint: String, _ signInData: Data) async throws -> String {
|
||||
private func exchangeSession(_ endpoint: String, _ signInData: Data) async throws {
|
||||
let code = try exchangeCodeFromResponse(signInData)
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/native/exchange",
|
||||
endpoint, method: "POST", action: "/api/auth/session/exchange",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["code": code])
|
||||
], body: [
|
||||
"code": code,
|
||||
"installationId": self.installationId(),
|
||||
"platform": "ios",
|
||||
"deviceName": UIDevice.current.name,
|
||||
])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
throw AuthError.exchangeFailed
|
||||
}
|
||||
|
||||
let token = try tokenFromResponse(data)
|
||||
try await broker.store(endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
|
||||
self.clearAuthCookies(endpoint)
|
||||
return token
|
||||
}
|
||||
|
||||
private func installationId() -> String {
|
||||
let key = "app.affine.pro.auth-installation-id"
|
||||
if let value = UserDefaults.standard.string(forKey: key) { return value }
|
||||
let value = UUID().uuidString
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
return value
|
||||
}
|
||||
|
||||
private func clearAuthCookies(_ endpoint: String) {
|
||||
@@ -274,85 +514,6 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private func tokenQuery(_ endpoint: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: tokenService,
|
||||
kSecAttrAccount as String: canonicalEndpoint(endpoint),
|
||||
]
|
||||
}
|
||||
|
||||
private func legacyTokenQuery(_ endpoint: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: tokenService,
|
||||
kSecAttrAccount as String: endpoint,
|
||||
]
|
||||
}
|
||||
|
||||
private func readToken(_ endpoint: String) throws -> String? {
|
||||
var query = tokenQuery(endpoint)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
if status == errSecItemNotFound {
|
||||
guard canonicalEndpoint(endpoint) != endpoint else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacyQuery = legacyTokenQuery(endpoint)
|
||||
legacyQuery[kSecReturnData as String] = true
|
||||
legacyQuery[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &item)
|
||||
if legacyStatus == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard legacyStatus == errSecSuccess, let data = item as? Data else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
let token = String(data: data, encoding: .utf8)
|
||||
if let token = token {
|
||||
try writeToken(endpoint, token)
|
||||
let deleteStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
guard status == errSecSuccess, let data = item as? Data else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func writeToken(_ endpoint: String, _ token: String) throws {
|
||||
try deleteToken(endpoint)
|
||||
var query = tokenQuery(endpoint)
|
||||
query[kSecValueData as String] = Data(token.utf8)
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteToken(_ endpoint: String) throws {
|
||||
let status = SecItemDelete(tokenQuery(endpoint) as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
if canonicalEndpoint(endpoint) != endpoint {
|
||||
let legacyStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
|
||||
guard legacyStatus == errSecSuccess || legacyStatus == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetch(
|
||||
_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
@@ -382,5 +543,6 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
|
||||
enum AuthError: Error {
|
||||
case invalidEndpoint, internalError, tokenNotFound, exchangeCodeNotFound, exchangeFailed
|
||||
case invalidEndpoint, internalError, credentialStoreUnavailable, tokenNotFound,
|
||||
exchangeCodeNotFound, exchangeFailed, invalidTokenResponse
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import XCTest
|
||||
|
||||
final class AuthDateParserTests: XCTestCase {
|
||||
func testAcceptsServerTimestamps() {
|
||||
XCTAssertNotNil(parseAuthISO8601Date("2026-07-12T03:14:37.000Z"))
|
||||
XCTAssertNotNil(parseAuthISO8601Date("2026-07-12T03:14:37Z"))
|
||||
XCTAssertNil(parseAuthISO8601Date("not-a-date"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user