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:
DarkSky
2026-07-12 18:11:02 +08:00
committed by GitHub
parent 02b25e05d8
commit abf37d3dfa
57 changed files with 2919 additions and 789 deletions

View File

@@ -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 = (

View File

@@ -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>

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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"))
}
}