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"))
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,7 @@ import { ImagePicker } from './plugins/image-picker';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { PayWall } from './plugins/paywall';
|
||||
import { Preview } from './plugins/preview';
|
||||
import {
|
||||
deleteEndpointToken,
|
||||
readEndpointToken,
|
||||
writeEndpointToken,
|
||||
} from './proxy';
|
||||
import { clearEndpointSession, getValidAccessToken } from './proxy';
|
||||
import { enableNavigationGesture$ } from './web-navigation-control';
|
||||
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
@@ -187,46 +183,44 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
|
||||
const endpoint = serverService.server.baseUrl;
|
||||
return {
|
||||
async signInMagicLink(email, linkToken, clientNonce) {
|
||||
const { token } = await Auth.signInMagicLink({
|
||||
await Auth.signInMagicLink({
|
||||
endpoint,
|
||||
email,
|
||||
token: linkToken,
|
||||
clientNonce,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signInOauth(code, state, _provider, clientNonce) {
|
||||
const { token } = await Auth.signInOauth({
|
||||
await Auth.signInOauth({
|
||||
endpoint,
|
||||
code,
|
||||
state,
|
||||
clientNonce,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
return {};
|
||||
},
|
||||
async signInPassword(credential) {
|
||||
const { token } = await Auth.signInPassword({
|
||||
await Auth.signInPassword({
|
||||
endpoint,
|
||||
...credential,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signInOpenAppSignInCode(code) {
|
||||
const { token } = await Auth.signInOpenApp({
|
||||
await Auth.signInOpenApp({
|
||||
endpoint,
|
||||
code,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
const token = await readEndpointToken(endpoint);
|
||||
try {
|
||||
await Auth.signOut({ endpoint, token });
|
||||
await Auth.signOut({ endpoint });
|
||||
} finally {
|
||||
await deleteEndpointToken(endpoint);
|
||||
await clearEndpointSession(endpoint);
|
||||
}
|
||||
},
|
||||
async clearSession() {
|
||||
await clearEndpointSession(endpoint);
|
||||
},
|
||||
};
|
||||
});
|
||||
framework.impl(NativePaywallProvider, {
|
||||
@@ -463,6 +457,13 @@ window.addEventListener('focus', () => {
|
||||
frameworkProvider.get(LifecycleService).applicationFocus();
|
||||
});
|
||||
frameworkProvider.get(LifecycleService).applicationStart();
|
||||
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
|
||||
if (!isActive) return;
|
||||
const servers = frameworkProvider.get(ServersService).servers$.value;
|
||||
Promise.allSettled(
|
||||
servers.map(server => getValidAccessToken(server.baseUrl))
|
||||
).catch(console.error);
|
||||
}).catch(console.error);
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (typeof error === 'string' && error) {
|
||||
@@ -628,13 +629,21 @@ function createStoreManagerClient() {
|
||||
authTokenChannelServer.addEventListener('message', event => {
|
||||
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
||||
if (!id || !endpoint) return;
|
||||
readEndpointToken(endpoint)
|
||||
getValidAccessToken(endpoint)
|
||||
.then(token => authTokenChannelServer.postMessage({ id, token }))
|
||||
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
|
||||
.catch(error =>
|
||||
authTokenChannelServer.postMessage({
|
||||
id,
|
||||
error:
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? error.code
|
||||
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
|
||||
})
|
||||
);
|
||||
});
|
||||
authTokenChannelServer.start();
|
||||
worker.postMessage(
|
||||
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
|
||||
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
|
||||
[authTokenChannelClient]
|
||||
);
|
||||
return new StoreManagerClient(new OpClient(worker));
|
||||
|
||||
@@ -19,21 +19,49 @@ import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
let authTokenPort: MessagePort | undefined;
|
||||
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
|
||||
const terminalAuthErrors = new Set([
|
||||
'ACCESS_TOKEN_INVALID',
|
||||
'AUTH_SESSION_EXPIRED',
|
||||
'AUTH_SESSION_REVOKED',
|
||||
'REFRESH_TOKEN_INVALID',
|
||||
'REFRESH_TOKEN_REUSED',
|
||||
'UNSUPPORTED_CLIENT_VERSION',
|
||||
'AUTH_SESSION_EMPTY',
|
||||
]);
|
||||
const pendingTokenRequests = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (token: string | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
configureSocketAuthMethod((endpoint, cb) => {
|
||||
readEndpointToken(endpoint)
|
||||
getValidAccessToken(endpoint)
|
||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||
.catch(() => cb({}));
|
||||
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
|
||||
});
|
||||
|
||||
globalThis.addEventListener('message', e => {
|
||||
if (e.data.type === 'native-auth-token-channel') {
|
||||
if (e.data.type === 'auth-access-token-channel') {
|
||||
authTokenPort = e.ports[0] as MessagePort;
|
||||
authTokenPort.addEventListener('message', e => {
|
||||
const { id, token } = e.data as { id?: string; token?: string | null };
|
||||
const { id, token, error } = e.data as {
|
||||
id?: string;
|
||||
token?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
if (!id) return;
|
||||
pendingTokenRequests.get(id)?.(token ?? null);
|
||||
const pending = pendingTokenRequests.get(id);
|
||||
if (error) {
|
||||
if (terminalAuthErrors.has(error)) {
|
||||
pending?.resolve(null);
|
||||
} else {
|
||||
pending?.reject(new Error(error));
|
||||
}
|
||||
} else {
|
||||
pending?.resolve(token ?? null);
|
||||
}
|
||||
pendingTokenRequests.delete(id);
|
||||
});
|
||||
authTokenPort.start();
|
||||
@@ -66,20 +94,26 @@ globalThis.addEventListener('message', e => {
|
||||
}
|
||||
});
|
||||
|
||||
function readEndpointToken(endpoint: string) {
|
||||
function getValidAccessToken(endpoint: string) {
|
||||
if (!authTokenPort) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const id = `${Date.now()}:${Math.random()}`;
|
||||
return new Promise<string | null>(resolve => {
|
||||
return new Promise<string | null>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingTokenRequests.delete(id);
|
||||
resolve(null);
|
||||
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
|
||||
}, 5000);
|
||||
pendingTokenRequests.set(id, token => {
|
||||
clearTimeout(timeout);
|
||||
resolve(token);
|
||||
pendingTokenRequests.set(id, {
|
||||
resolve: token => {
|
||||
clearTimeout(timeout);
|
||||
resolve(token);
|
||||
},
|
||||
reject: error => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
authTokenPort?.postMessage({ id, endpoint });
|
||||
});
|
||||
|
||||
@@ -4,31 +4,25 @@ export interface AuthPlugin {
|
||||
email: string;
|
||||
token: string;
|
||||
clientNonce?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
}): Promise<void>;
|
||||
signInOauth(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
state: string;
|
||||
clientNonce?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
}): Promise<void>;
|
||||
signInPassword(options: {
|
||||
endpoint: string;
|
||||
email: string;
|
||||
password: string;
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signInOpenApp(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
|
||||
readEndpointToken(options: {
|
||||
}): Promise<void>;
|
||||
signInOpenApp(options: { endpoint: string; code: string }): Promise<void>;
|
||||
signOut(options: { endpoint: string }): Promise<void>;
|
||||
getValidAccessToken(options: {
|
||||
endpoint: string;
|
||||
}): Promise<{ token?: string | null }>;
|
||||
writeEndpointToken(options: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}): Promise<void>;
|
||||
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
|
||||
refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>;
|
||||
clearEndpointSession(options: { endpoint: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
|
||||
|
||||
import { Auth } from './plugins/auth';
|
||||
|
||||
function authEndpointForUrl(url: string | URL) {
|
||||
@@ -11,10 +13,6 @@ function authEndpointForUrl(url: string | URL) {
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalEndpoint(endpoint: string) {
|
||||
return authEndpointForUrl(endpoint) ?? endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
|
||||
* should be included in the entry file of the app or webworker.
|
||||
@@ -22,22 +20,84 @@ function canonicalEndpoint(endpoint: string) {
|
||||
const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
const retry = request.clone();
|
||||
|
||||
const origin = authEndpointForUrl(request.url);
|
||||
|
||||
const token = origin
|
||||
? await readEndpointToken(origin).catch(() => null)
|
||||
: null;
|
||||
const token = origin ? await getValidAccessToken(origin) : null;
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return rawFetch(request);
|
||||
const response = await rawFetch(request);
|
||||
if (response.status !== 401 || !origin) return response;
|
||||
const body = await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null);
|
||||
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
|
||||
const { token: refreshed } = await Auth.refreshAccessToken({
|
||||
endpoint: origin,
|
||||
});
|
||||
retry.headers.set('Authorization', `Bearer ${refreshed}`);
|
||||
return rawFetch(retry);
|
||||
};
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
private request:
|
||||
| {
|
||||
method: string;
|
||||
url: string | URL;
|
||||
async: boolean;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
| undefined;
|
||||
private readonly headers = new Map<string, string>();
|
||||
private requestBody?: Document | XMLHttpRequestBodyInit | null;
|
||||
private replaying = false;
|
||||
private hasReplayed = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const suppressExpiredResponse = (event: Event) => {
|
||||
if (this.replaying) event.stopImmediatePropagation();
|
||||
};
|
||||
this.addEventListener('load', suppressExpiredResponse, true);
|
||||
this.addEventListener('loadend', suppressExpiredResponse, true);
|
||||
this.addEventListener(
|
||||
'readystatechange',
|
||||
event => {
|
||||
if (
|
||||
this.readyState !== rawXMLHttpRequest.DONE ||
|
||||
this.status !== 401 ||
|
||||
this.replaying ||
|
||||
this.hasReplayed ||
|
||||
!this.request?.async
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let code: unknown;
|
||||
try {
|
||||
code =
|
||||
this.responseType === 'json'
|
||||
? this.response?.code
|
||||
: JSON.parse(this.responseText)?.code;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (code !== 'ACCESS_TOKEN_EXPIRED') return;
|
||||
event.stopImmediatePropagation();
|
||||
this.replaying = true;
|
||||
this.hasReplayed = true;
|
||||
this.replayWithFreshToken().catch(() => {});
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
override open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
@@ -45,6 +105,11 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
username?: string | null,
|
||||
password?: string | null
|
||||
): void {
|
||||
this.request = { method, url, async, username, password };
|
||||
this.headers.clear();
|
||||
this.requestBody = undefined;
|
||||
this.replaying = false;
|
||||
this.hasReplayed = false;
|
||||
xhrRequestUrls.set(this, url.toString());
|
||||
return super.open(
|
||||
method,
|
||||
@@ -55,40 +120,81 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
);
|
||||
}
|
||||
|
||||
override setRequestHeader(name: string, value: string): void {
|
||||
this.headers.set(name, value);
|
||||
super.setRequestHeader(name, value);
|
||||
}
|
||||
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
this.requestBody = body;
|
||||
const requestUrl = xhrRequestUrls.get(this);
|
||||
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
||||
|
||||
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
|
||||
token => {
|
||||
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
|
||||
.then(token => {
|
||||
if (token) {
|
||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return super.send(body);
|
||||
},
|
||||
() => {
|
||||
return super.send(body);
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
this.dispatchEvent(new Event('error'));
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
});
|
||||
}
|
||||
|
||||
private async replayWithFreshToken() {
|
||||
const request = this.request;
|
||||
if (!request) return this.failReplay();
|
||||
const origin = authEndpointForUrl(request.url);
|
||||
if (!origin) return this.failReplay();
|
||||
try {
|
||||
const { token } = await Auth.refreshAccessToken({ endpoint: origin });
|
||||
const responseType = this.responseType;
|
||||
const timeout = this.timeout;
|
||||
const withCredentials = this.withCredentials;
|
||||
super.open(
|
||||
request.method,
|
||||
request.url,
|
||||
true,
|
||||
request.username ?? undefined,
|
||||
request.password ?? undefined
|
||||
);
|
||||
this.replaying = false;
|
||||
this.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase() !== 'authorization') {
|
||||
super.setRequestHeader(name, value);
|
||||
}
|
||||
});
|
||||
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
this.responseType = responseType;
|
||||
this.timeout = timeout;
|
||||
this.withCredentials = withCredentials;
|
||||
super.send(this.requestBody);
|
||||
} catch {
|
||||
this.failReplay();
|
||||
}
|
||||
}
|
||||
|
||||
private failReplay() {
|
||||
this.replaying = false;
|
||||
this.dispatchEvent(new Event('readystatechange'));
|
||||
this.dispatchEvent(new Event('error'));
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
}
|
||||
};
|
||||
|
||||
export async function readEndpointToken(
|
||||
export async function getValidAccessToken(
|
||||
endpoint: string
|
||||
): Promise<string | null> {
|
||||
const { token } = await Auth.readEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
const { token } = await Auth.getValidAccessToken({
|
||||
endpoint: canonicalAuthEndpoint(endpoint),
|
||||
});
|
||||
return token ?? null;
|
||||
}
|
||||
|
||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
||||
await Auth.writeEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
token,
|
||||
export async function clearEndpointSession(endpoint: string) {
|
||||
await Auth.clearEndpointSession({
|
||||
endpoint: canonicalAuthEndpoint(endpoint),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEndpointToken(endpoint: string) {
|
||||
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user