diff --git a/.gitignore b/.gitignore index 192d737c..1ff5b988 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ results*.json App/BlurtiOS/BlurtiOS.xcodeproj/ App/BlurtiOS/*/Info.plist App/BlurtiOS/*/*.entitlements + +# Sign in with AssemblyAI's dev values name AssemblyAI's sandbox deployments, +# which stay out of this public repo (see App/Blurt/Config/SignIn.xcconfig). +App/Blurt/Config/SignIn.local.xcconfig diff --git a/AGENTS.md b/AGENTS.md index 30f3a422..4dd103d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1142,6 +1142,45 @@ status and VoiceOver wording, connect-vs-rotate control titles. The mask reveals characters and, below `minimumLengthToMask`, none at all (a bare `suffix(4)` in the view once rendered a short key whole). +Sign in with AssemblyAI: the other way to fill that same key item. **`AssemblyAIAccount`** +(`@MainActor @Observable`, in `Sources/BlurtEngine/Account/`) runs the authorization-code + PKCE flow +against a public Connected App: the shell's `AccountSignIn` opens the consent page in an +`ASWebAuthenticationSession` (redirect `com.assemblyai.blurt://oauth/callback`, caught by the session, so no URL +handler is registered), and the engine trades the code for tokens and the access token for the +connection's API key (`POST /api/v2/oauth/api-key`), which it saves through `APIKeyModel` like a +pasted key — the transcriber, the press gate and the TTS clients can't tell the two apart. Beside it, +a second Keychain item (`AssemblyAISignIn`, `SignInStore`) holds the **`SignInSession`**: refresh +token, email, key expiry and last renewal. Its read keeps "none" apart from "unavailable" (a locked +Keychain), for the reason `MemoizedKeyStore` gives: an unreadable item must not read as signed out, +or Sign Out would delete the key without ending the connection. `runRenewal()` (started once at launch) sleeps until the renewal is due — when the exchange said the +key rotates (`api_key_rotates_at`: AMS mints the next key and disables the old one once an exchange +lands inside its window, two days before expiry), else two days before expiry (`SignInSession.renewalDue`; +at most a day per sleep, on the continuous clock so a renewal that fell due while the Mac slept runs on +wake) — then refreshes and re-exchanges: one renewal per key, since the +refresh token's three-month sliding lifetime dwarfs a key's two weeks. `KeyRejectionReporter` wraps the +transcriber and calls `keyWasRejected()` when dictation refuses the key (401, or the API's invalid-key +404), which renews at once (at most every 15 minutes) so the next dictation has a working key; the +refused utterance itself can't be retried, its audio having streamed as it was spoken. A renewal that brings back the same key with its renewal already due (a server that didn't rotate +early) defers the next one to the key's expiry, or a day out once it has expired, instead of asking +every minute. Renewal keeps a rotated refresh token before anything else can fail; a refused refresh +or exchange means the connection was ended elsewhere and drops key and session, an unreachable server +changes nothing. A renewal suspends twice on the network, so a `generation` counter makes one that +wakes after a sign-out, reset or new sign-in write nothing, and `signOut()` waits out a renewal in +flight first. `signOut()` ends the connection server-side (`POST /api/v2/oauth/api-key/revoke`) and +only then forgets it here; a revoke the server couldn't confirm (503) leaves everything in place and +returns an inline report so the user can retry (`SignInFailure.failureReport` makes the same +inline-vs-alert split as `APIKeySubmission`). `CredentialRows.resolve` decides which of the account +and key rows show and which button is filled. `InstallReset` clears the session item with the key. +The wire half (`OAuthAuthorization`, `OAuthClient`) is in the `AssemblyAI` library. The build turns +sign-in on with four Info.plist values (`BLURT_OAUTH_*`, from the gitignored `App/Blurt/Config/SignIn.local.xcconfig` via `SignIn.xcconfig` on the dev configurations, read by +`OAuthConfiguration.blurt`); any one empty hides it, which is how release ships until a production +Connected App exists, and a session inherited by such a build can still be signed out (locally). A signed-in key also +dictates against the host its sign-in configures (`BLURT_OAUTH_DICTATION_ORIGIN`, optional; a sandbox +key only works against its sandbox's host): the account keeps a `DictationOrigin` current as it signs +in and out, and the app's `baseURL` closure for `AssemblyAITranscriber` reads it at every request and +warm-up. A pasted key, or no +origin configured, dictates against production. + Setup gating: **`SetupReadiness.isReady(permissions:hasAPIKey:)`** is the fully-configured rule (deliberately excluding the trigger key, which has a default binding, so a shortcut change can't trap the user in the wizard), `SetupReadiness.pollInterval(isReady:)` the permission-poll cadence (brisk diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 41e22e90..6aa816a2 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ 2F42560EDE7C8F097889A32D /* juno-33-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F7FE2B1E0DFA593ACFCEDA76 /* juno-33-start.m4a */; }; 2F5526E7BF0E08FE4E74F2B2 /* juno-69-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DED921D522FA87E2970C2EFF /* juno-69-stop.m4a */; }; 3015C1E46AE0ECA3A96F819C /* juno-69-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 6A29FFB86C18EA299EAFFCFD /* juno-69-start.m4a */; }; + 3182C793A397A4AC6BF4F24D /* AccountSignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC096B44D037E7BB119609A /* AccountSignIn.swift */; }; 3246BF1C37C7A027817D9458 /* juno-110-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E552326770AD5967B8128420 /* juno-110-stop.m4a */; }; 336F75E5060815018D79ACEB /* rom1b-21-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 01B663528C4E1AFEF24675E1 /* rom1b-21-start.m4a */; }; 33C581B5DE3847C05F540701 /* juno-81-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 3FCE941C19A3FF0DC61462B7 /* juno-81-stop.m4a */; }; @@ -174,6 +175,7 @@ 5EF49E6114C9C7BF79F857D9 /* juno-8-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2FFA326076660C91492A6CE1 /* juno-8-stop.m4a */; }; 60CE1FFB2833C564F335DFF3 /* juno-47-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 11F215137F50A2D4AC61E414 /* juno-47-stop.m4a */; }; 623F42C6E50E5B9EBF9F207A /* juno-22-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AD303A24D4133CE3F1F1DE23 /* juno-22-start.m4a */; }; + 625949CE5B21104715419724 /* AccountRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75844EDF6969530EFB56CF4 /* AccountRowView.swift */; }; 643FE1E9922319122B1C087D /* rom1a-17-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = ED3C851EF55F6BBEBFDAE53A /* rom1a-17-stop.m4a */; }; 645A40A3B0E75729A0E3D1AC /* juno-66-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0CDBED88D2D7CE84776B7574 /* juno-66-start.m4a */; }; 65B01C04BC66F1F633EEA966 /* juno-124-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 143E94106D9A6342EFD4F909 /* juno-124-start.m4a */; }; @@ -484,6 +486,7 @@ 0F3D1DBFA369BAF807DBB55F /* juno-1-start.m4a */ = {isa = PBXFileReference; path = "juno-1-start.m4a"; sourceTree = ""; }; 0FB3FBD7C42F949C52B09F0C /* juno-21-start.m4a */ = {isa = PBXFileReference; path = "juno-21-start.m4a"; sourceTree = ""; }; 0FB812876EC4870E9A74BB50 /* juno-55-start.m4a */ = {isa = PBXFileReference; path = "juno-55-start.m4a"; sourceTree = ""; }; + 0FDF98A81095D247FB92B90D /* SignIn.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SignIn.xcconfig; sourceTree = ""; }; 10A0336924600313EE5243AE /* rom1a-8-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-8-stop.m4a"; sourceTree = ""; }; 1118A82ED3D1CD5CF429099E /* rom1b-23-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-23-stop.m4a"; sourceTree = ""; }; 114E0EA1827EA005CF188FC8 /* juno-119-start.m4a */ = {isa = PBXFileReference; path = "juno-119-start.m4a"; sourceTree = ""; }; @@ -860,6 +863,7 @@ E617272806BFEB303D94F315 /* rom1b-16-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-16-stop.m4a"; sourceTree = ""; }; E6A271DA085CFB136745066C /* juno-36-stop.m4a */ = {isa = PBXFileReference; path = "juno-36-stop.m4a"; sourceTree = ""; }; E6D106BBE0E8586827C14E59 /* rom1b-14-start.m4a */ = {isa = PBXFileReference; path = "rom1b-14-start.m4a"; sourceTree = ""; }; + E75844EDF6969530EFB56CF4 /* AccountRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountRowView.swift; sourceTree = ""; }; E77ECDD66C1549039CB276A6 /* juno-25-stop.m4a */ = {isa = PBXFileReference; path = "juno-25-stop.m4a"; sourceTree = ""; }; E83BDAC7AB4479E8A12A5613 /* juno-74-start.m4a */ = {isa = PBXFileReference; path = "juno-74-start.m4a"; sourceTree = ""; }; E84C190EF80F6B36F8241BFC /* juno-82-stop.m4a */ = {isa = PBXFileReference; path = "juno-82-stop.m4a"; sourceTree = ""; }; @@ -869,6 +873,7 @@ E97DEAE3F8FD9ADD7DB41B02 /* juno-63-stop.m4a */ = {isa = PBXFileReference; path = "juno-63-stop.m4a"; sourceTree = ""; }; EA933A670288BEAFD43D468E /* rom1b-7-start.m4a */ = {isa = PBXFileReference; path = "rom1b-7-start.m4a"; sourceTree = ""; }; EAB0871FD8CB94A4DCF837CC /* rom1b-23-start.m4a */ = {isa = PBXFileReference; path = "rom1b-23-start.m4a"; sourceTree = ""; }; + EAC096B44D037E7BB119609A /* AccountSignIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSignIn.swift; sourceTree = ""; }; EB08EC40A267C71B185EC782 /* juno-109-stop.m4a */ = {isa = PBXFileReference; path = "juno-109-stop.m4a"; sourceTree = ""; }; EB504AAB7330EE4D4C1E4948 /* rom1a-26-start.m4a */ = {isa = PBXFileReference; path = "rom1a-26-start.m4a"; sourceTree = ""; }; EBB8641EF9B0108DC0B103B7 /* juno-120-stop.m4a */ = {isa = PBXFileReference; path = "juno-120-stop.m4a"; sourceTree = ""; }; @@ -960,6 +965,7 @@ 1560402FBA5911463036085F /* Blurt */, 73649A7C02EB13B17D81A412 /* BlurtUITests */, E0FCB536C7F1A0AD06EE625D /* brand */, + CE00AEC94B430DEF79A46820 /* Config */, 4B1375E36CD42DBEFF08BC7E /* Packages */, D8F69C40E4A93A6B3433BAC0 /* Shared */, 10E2E11380C974390308C1D2 /* Products */, @@ -1439,6 +1445,14 @@ path = Shared; sourceTree = ""; }; + CE00AEC94B430DEF79A46820 /* Config */ = { + isa = PBXGroup; + children = ( + 0FDF98A81095D247FB92B90D /* SignIn.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; D8F69C40E4A93A6B3433BAC0 /* Shared */ = { isa = PBXGroup; children = ( @@ -1486,6 +1500,8 @@ F61744A7BF2B24E0C8078DE6 /* Steps */ = { isa = PBXGroup; children = ( + E75844EDF6969530EFB56CF4 /* AccountRowView.swift */, + EAC096B44D037E7BB119609A /* AccountSignIn.swift */, 3DA3A304528FD1C7FE6C2B94 /* APIKeyStepView.swift */, 50FFCDBD1FB2CF0A81A68D18 /* HotkeyStepView.swift */, C59092F9220BBE7465068325 /* KeyTermsStepView.swift */, @@ -2034,6 +2050,8 @@ buildActionMask = 2147483647; files = ( 0A259385C40D25FC87E377D6 /* APIKeyStepView.swift in Sources */, + 625949CE5B21104715419724 /* AccountRowView.swift in Sources */, + 3182C793A397A4AC6BF4F24D /* AccountSignIn.swift in Sources */, 73FA46BD466212E9AE09C847 /* App.swift in Sources */, B391EA5F093506B3DF318F10 /* AppCoordinator+UITesting.swift in Sources */, 3407E8759D61D569A998EE78 /* AppCoordinator.swift in Sources */, @@ -2366,6 +2384,7 @@ }; CD58E0085469061FEE527931 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0FDF98A81095D247FB92B90D /* SignIn.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BLURT_APP_NAME = "Blurt Dev"; @@ -2386,6 +2405,7 @@ }; DEC447B087F81B0E6C3138D2 /* Debug-Local */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0FDF98A81095D247FB92B90D /* SignIn.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BLURT_APP_NAME = "Blurt Dev"; diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index aa323d39..831585ac 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -1,4 +1,6 @@ +import AssemblyAI import BlurtEngine +import Foundation import Observation @Observable @@ -25,6 +27,9 @@ final class AppCoordinator { /// pipeline↔UI wiring. The wizard and the API-key view observe this directly /// rather than reaching through the coordinator (see `APIKeyModel`). let apiKey: APIKeyModel + /// "Sign in with AssemblyAI", which fills the same key `apiKey` holds. Its + /// configuration comes from Info.plist; a build without one hides sign-in. + let account: AssemblyAIAccount @ObservationIgnored private var phaseObserver: Task? @ObservationIgnored private var levelsObserver: Task? @ObservationIgnored var keyTap: DictationKeyTap? @@ -58,18 +63,33 @@ final class AppCoordinator { /// its `.recording` half (via `menuBarStatus`) drives the listening state. private(set) var isCapturing = false - /// `components` defaults to the production pipeline; `apiKey` defaults to the + /// `components` defaults to the production pipeline, its transcriber dictating + /// against the account's `dictationOrigin`; `apiKey` defaults to the /// production Keychain-backed model. Tests/UI-tests inject deterministic /// doubles (see `DictationComponents`) and an `APIKeyModel` built over an /// in-memory store with an offline validator — the engine's `APIKeySubmission` /// still owns the never-persist-an-unverified-key invariant either way. init( onSetupBlocked: @escaping @MainActor () -> Void, - components: DictationComponents = .production(), - apiKey: APIKeyModel = APIKeyModel() + components: DictationComponents? = nil, + apiKey: APIKeyModel = APIKeyModel(), + account: AssemblyAIAccount? = nil ) { self.onSetupBlocked = onSetupBlocked self.apiKey = apiKey + let info = Bundle.main.infoDictionary ?? [:] + let account = + account + ?? AssemblyAIAccount( + configuration: .blurt(infoDictionary: info), + dictationHost: OAuthConfiguration.blurtDictationOrigin(infoDictionary: info), + apiKey: apiKey) + self.account = account + let components = + components + ?? .production( + dictationOrigin: account.dictationOrigin, + onKeyRejected: { Task { await account.keyWasRejected() } }) // Buffering the newest is enough: each element is the *whole* ring as of that // delivery, not a delta, so a value dropped under contention is one the next diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index c4fad645..9fa2d3ac 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -107,12 +107,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // normal launch is untouched — running the UI tests locally does reset // these, by design. PersistedSettings.resetAll() + let apiKey = APIKeyModel( + keyStore: InMemoryAPIKeyStore(), + validateKey: { UITestKeyValidation.result(for: $0) }) coord = AppCoordinator( onSetupBlocked: onSetupBlocked, components: .uiTest(), - apiKey: APIKeyModel( - keyStore: InMemoryAPIKeyStore(), - validateKey: { UITestKeyValidation.result(for: $0) })) + apiKey: apiKey, + // No configuration: the suite covers the paste-a-key path, and sign-in + // would open a real browser. + account: AssemblyAIAccount(configuration: nil, apiKey: apiKey, store: .inMemory())) } else { coord = AppCoordinator(onSetupBlocked: onSetupBlocked) } @@ -127,6 +131,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // on a configured launch, which is what actually installs the tap. If the // wizard ran first the tap wouldn't exist yet and would never come up. coord.start() + // A signed-in Mac renews its connection's key shortly before it expires; + // the schedule is the engine's (`AssemblyAIAccount.runRenewal`). + Task { await coord.account.runRenewal() } // Created before the run loop presents any scene, so `MainWindowRoot` always // finds it. On a configured launch its init reveals the overlay pill even diff --git a/App/Blurt/Blurt/DictationComposition.swift b/App/Blurt/Blurt/DictationComposition.swift index c0ea6b87..1570fe50 100644 --- a/App/Blurt/Blurt/DictationComposition.swift +++ b/App/Blurt/Blurt/DictationComposition.swift @@ -15,10 +15,20 @@ struct DictationComponents { /// The real pipeline: a fresh `MicCapture`, the AssemblyAI dictation transcriber, /// and the clipboard-paste injector. This is what `AppCoordinator` builds, so /// production behavior is unchanged by the test seam existing. - static func production() -> DictationComponents { + /// `dictationOrigin` is the signed-in key's dictation host + /// (`AssemblyAIAccount.dictationOrigin`); production when it holds none. + /// `onKeyRejected` hears about a dictation refused for its key + /// (`KeyRejectionReporter`), so the account can renew at once. + static func production( + dictationOrigin: DictationOrigin? = nil, onKeyRejected: @escaping @Sendable () -> Void = {} + ) -> DictationComponents { DictationComponents( mic: MicCapture(), - transcriber: AssemblyAITranscriber(), + transcriber: KeyRejectionReporter( + AssemblyAITranscriber(baseURL: { + dictationOrigin?.current ?? AssemblyAITranscriber.defaultBaseURL + }), + onRejected: onKeyRejected), injector: KeyInjector() ) } diff --git a/App/Blurt/Blurt/Info.plist b/App/Blurt/Blurt/Info.plist index 5ebfa759..4ec7b7f3 100644 --- a/App/Blurt/Blurt/Info.plist +++ b/App/Blurt/Blurt/Info.plist @@ -2,6 +2,16 @@ + BlurtOAuthAPIOrigin + $(BLURT_OAUTH_API_ORIGIN) + BlurtOAuthAuthorizeURL + $(BLURT_OAUTH_AUTHORIZE_URL) + BlurtOAuthClientID + $(BLURT_OAUTH_CLIENT_ID) + BlurtOAuthDictationOrigin + $(BLURT_OAUTH_DICTATION_ORIGIN) + BlurtOAuthTokenURL + $(BLURT_OAUTH_TOKEN_URL) CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index 20a9ea9e..4393dbf2 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -301,8 +301,10 @@ struct ResetSection: View { private func reset() { let report = InstallReset( bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem, - keyStore: coordinator.apiKey.storage + keyStore: coordinator.apiKey.storage, + signInStore: coordinator.account.storage ).run() + coordinator.account.refreshStatus() // `hasAPIKey` is a mirror of the store, not a read-through, so it has to be // re-read for the wizard to see the key go — which matters on the failure // path, where the app stays running. diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 8a6d9468..dda7c2f6 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -58,7 +58,7 @@ private struct GeneralSettingsTab: View { var body: some View { SettingsPane { - APIKeyStepView(apiKey: coordinator.apiKey) + APIKeyStepView(apiKey: coordinator.apiKey, account: coordinator.account) HotkeyStepView(coordinator: coordinator) MicrophoneStepView() SoundStepView(coordinator: coordinator) diff --git a/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift b/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift index 61b02911..893e7b59 100644 --- a/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift @@ -23,8 +23,14 @@ import SwiftUI /// Everything the row can't hold — why a key is needed at all, where it's /// stored, how to get one, and what went wrong when AssemblyAI rejects one — /// lives in the sheet, which has room for it without crowding the form. +/// +/// With sign-in configured (`AssemblyAIAccount.isAvailable`), an account row +/// comes first: signing in fills the same key a paste would, so once signed in +/// the key row gives way to "Signed in as …" and a Sign Out button, and the key +/// is the connection's to rotate, not the user's to change. struct APIKeyStepView: View { var apiKey: APIKeyModel + var account: AssemblyAIAccount /// The key currently in the Keychain, loaded on appear (empty when none). @State private var savedKey = "" @@ -36,20 +42,29 @@ struct APIKeyStepView: View { /// This view just renders what it resolves to. private var display: APIKeyDisplay { .resolve(key: savedKey) } + /// Which rows show and which button is filled — the engine's + /// `CredentialRows`, so the rule is tested rather than restated here. + private var rows: CredentialRows { + .resolve(isKeyStored: display.isConnected, isSignInAvailable: account.isAvailable, status: account.status) + } + var body: some View { Section { - SettingRow(title: "API Key", systemImage: "key.fill") { - HStack(spacing: 12) { - statusLabel - Button(display.editButtonTitle) { isPresentingEditor = true } - // Prominent (brand-green fill) only while there's no key: "Connect…" - // is the one thing setup is waiting on, but once a key is stored, - // "Change…" is a rare errand, and a lone filled button in Settings - // marked it as the pane's main action. - .glassButtonStyleCompat(prominent: !display.isConnected) - .accessibilityIdentifier( - display.isConnected ? UITestIdentifiers.apiKeyChange : UITestIdentifiers.apiKeyConnect - ) + if rows.showsAccountRow { + AccountRowView(account: account, isPrimary: rows.primary == .signIn) + } + if rows.showsKeyRow { + SettingRow(title: "API Key", systemImage: "key.fill") { + HStack(spacing: 12) { + statusLabel + Button(display.editButtonTitle) { isPresentingEditor = true } + // Prominent (brand-green fill) only while it is the one thing + // setup is waiting on; see `CredentialRows.primary`. + .glassButtonStyleCompat(prominent: rows.primary == .connect) + .accessibilityIdentifier( + display.isConnected ? UITestIdentifiers.apiKeyChange : UITestIdentifiers.apiKeyConnect + ) + } } } } header: { @@ -66,6 +81,7 @@ struct APIKeyStepView: View { // a key is already saved, this flips `hasAPIKey` true so the wizard advances // to the ready screen instead of stranding the user on a setup step. apiKey.refreshStatus() + account.refreshStatus() } // The setup window and Settings both host this section, so a key connected // in one has to reach the other's row — a snapshot taken once on appear diff --git a/App/Blurt/Blurt/Wizard/Steps/AccountRowView.swift b/App/Blurt/Blurt/Wizard/Steps/AccountRowView.swift new file mode 100644 index 00000000..a6a6d9d1 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/Steps/AccountRowView.swift @@ -0,0 +1,112 @@ +import BlurtEngine +import SwiftUI + +/// The "Sign in with AssemblyAI" row of the AssemblyAI section: "Sign In…" +/// while signed out, or who is signed in and "Sign Out". The wording and the +/// inline-vs-alert split are the engine's (`AssemblyAIAccount.Status.statusText`, +/// `SignInFailure.failureReport`, `signOut()`), the same split the key sheet +/// follows: a problem a retry can fix shows under the row, only a Keychain fault +/// is an alert. +struct AccountRowView: View { + var account: AssemblyAIAccount + /// Whether "Sign In…" is the section's filled button (`CredentialRows.primary`). + let isPrimary: Bool + + @State private var signIn = AccountSignIn() + /// A sign-in or sign-out in flight; the button is disabled so they can't overlap. + @State private var isWorking = false + /// A recoverable failure, shown in red under the row until the next attempt. + @State private var inlineError: String? + @State private var fault: Fault? + + /// The alert-shaped failure, carried as view state so the alert can present + /// it. The strings are the engine's. + private struct Fault { + let title: String + let message: String + } + + private var isPresentingFault: Binding { + Binding(get: { fault != nil }, set: { if !$0 { fault = nil } }) + } + + var body: some View { + // A `Group` so the row and its inline error stay sibling rows of the + // parent `Section` while sharing the alert and the teardown. + Group { + row + if let inlineError { + // Red, no caution glyph: "retry this", not "something broke". + Text(inlineError) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + .alert(fault?.title ?? "", isPresented: isPresentingFault, presenting: fault) { _ in + Button("OK", role: .cancel) {} + } message: { fault in + Text(fault.message) + } + // The row goes away with its window or a Settings tab switch; either ends + // the browser session too, since nothing would be left to finish it. + .onDisappear { signIn.cancel() } + } + + private var row: some View { + SettingRow(title: "Account", systemImage: "person.crop.circle") { + HStack(spacing: 12) { + if let statusText = account.status.statusText { + Text(statusText) + .foregroundStyle(.secondary) + // A long work address mustn't push Sign Out off the row; the full + // text is on hover. + .lineLimit(1) + .truncationMode(.middle) + .help(statusText) + } + if isWorking { + ProgressView().controlSize(.small) + .accessibilityLabel(account.status.statusText == nil ? "Signing in" : "Signing out") + } + if account.status != .signedOut { + Button("Sign Out", action: signOut) + .disabled(isWorking) + } else { + Button("Sign In with AssemblyAI…", action: startSignIn) + .glassButtonStyleCompat(prominent: isPrimary) + .disabled(isWorking) + } + } + } + } + + private func startSignIn() { + begin() + Task { + let outcome = await signIn.run(account) + isWorking = false + if case .failed(let failure) = outcome { report(failure.failureReport) } + } + } + + private func signOut() { + begin() + Task { + let failure = await account.signOut() + isWorking = false + if let failure { report(failure) } + } + } + + private func begin() { + isWorking = true + inlineError = nil + } + + private func report(_ report: APIKeySubmission.FailureReport) { + switch report { + case .inline(let message): inlineError = message + case .alert(let title, let message): fault = Fault(title: title, message: message) + } + } +} diff --git a/App/Blurt/Blurt/Wizard/Steps/AccountSignIn.swift b/App/Blurt/Blurt/Wizard/Steps/AccountSignIn.swift new file mode 100644 index 00000000..19fb0f93 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/Steps/AccountSignIn.swift @@ -0,0 +1,72 @@ +import AppKit +import AuthenticationServices +import BlurtEngine +import Synchronization + +/// Runs the browser half of "Sign in with AssemblyAI": opens the consent page in +/// a web authentication session and hands the redirect to `AssemblyAIAccount`, +/// which owns everything either side of it (PKCE, the exchange, storage). +/// +/// Not ephemeral on purpose: a member already signed in to the dashboard in +/// their browser only has to approve. +@MainActor +final class AccountSignIn: NSObject, ASWebAuthenticationPresentationContextProviding { + private var session: ASWebAuthenticationSession? + + /// Signs in and reports how it ended. A closed window is `.cancelled`. + func run(_ account: AssemblyAIAccount) async -> SignInOutcome { + guard let authorization = account.beginSignIn(), + let scheme = authorization.configuration.callbackScheme + else { return .failed(.unavailable) } + + let callback: URL? = await withCheckedContinuation { continuation in + // A session that fails to start may or may not also call its handler, and + // a continuation resumed twice traps, so whichever comes first wins. + let resume = ResumeOnce(continuation) + let session = ASWebAuthenticationSession( + url: authorization.url, callback: .customScheme(scheme) + ) { @Sendable url, _ in + // An error here is the user closing the window (or the session failing to + // start); either way there is no redirect to finish. + resume(url) + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + self.session = session + if !session.start() { resume(nil) } + } + session = nil + guard let callback else { return .cancelled } + return await account.completeSignIn(authorization, callback: callback) + } + + func cancel() { + session?.cancel() + session = nil + } + + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + MainActor.assumeIsolated { + NSApp.keyWindow ?? NSApp.windows.first { $0.isVisible } ?? ASPresentationAnchor() + } + } +} + +/// Resumes a continuation at most once. `nonisolated` because the app target +/// defaults to the main actor, and AuthenticationServices calls the completion +/// handler on its own XPC queue: a main-actor type here trips Swift's isolation +/// check and crashes the app the moment the consent page redirects. +private nonisolated final class ResumeOnce: Sendable { + private let continuation: Mutex?> + + init(_ continuation: CheckedContinuation) { + self.continuation = Mutex(continuation) + } + + func callAsFunction(_ url: URL?) { + continuation.withLock { pending in + pending?.resume(returning: url) + pending = nil + } + } +} diff --git a/App/Blurt/Blurt/Wizard/WizardView.swift b/App/Blurt/Blurt/Wizard/WizardView.swift index a5948dc3..89293f57 100644 --- a/App/Blurt/Blurt/Wizard/WizardView.swift +++ b/App/Blurt/Blurt/Wizard/WizardView.swift @@ -24,7 +24,7 @@ struct WizardView: View { Form { // API key first — the reason setup leads with it: a user arriving from // the signup page has the key on their clipboard, ready to paste in. - APIKeyStepView(apiKey: coordinator.apiKey) + APIKeyStepView(apiKey: coordinator.apiKey, account: coordinator.account) PermissionsStepView(controller: controller) } .formStyle(.grouped) diff --git a/App/Blurt/Config/SignIn.local.xcconfig.example b/App/Blurt/Config/SignIn.local.xcconfig.example new file mode 100644 index 00000000..8d435726 --- /dev/null +++ b/App/Blurt/Config/SignIn.local.xcconfig.example @@ -0,0 +1,16 @@ +// Copy to SignIn.local.xcconfig (gitignored) and fill in. Any one left empty +// turns sign-in off. xcconfig treats `//` as the start of a comment, so write +// every URL's `//` as `/$()/`. +// +// BLURT_OAUTH_CLIENT_ID the Connected App's client id +// BLURT_OAUTH_TOKEN_URL the Stytch project's token endpoint (…/v1/oauth2/token) +// BLURT_OAUTH_AUTHORIZE_URL the dashboard's consent page (…/dashboard/oauth/authorize) +// BLURT_OAUTH_API_ORIGIN the web origin serving /api/v2/oauth/api-key[/revoke] +// BLURT_OAUTH_DICTATION_ORIGIN optional: the dictation host the signed-in key +// belongs to; empty means production + +BLURT_OAUTH_CLIENT_ID = +BLURT_OAUTH_TOKEN_URL = https:/$()/ +BLURT_OAUTH_AUTHORIZE_URL = https:/$()/ +BLURT_OAUTH_API_ORIGIN = https:/$()/ +BLURT_OAUTH_DICTATION_ORIGIN = diff --git a/App/Blurt/Config/SignIn.xcconfig b/App/Blurt/Config/SignIn.xcconfig new file mode 100644 index 00000000..2b3af3cb --- /dev/null +++ b/App/Blurt/Config/SignIn.xcconfig @@ -0,0 +1,10 @@ +// Sign in with AssemblyAI, for the Debug and Debug-Local configurations +// (see `configFiles` in project.yml). Release has no config file, so its +// BLURT_OAUTH_* settings are unset and the app offers only the paste-a-key +// path. +// +// The values themselves are not in this repo: they name AssemblyAI's sandbox +// deployments. Copy SignIn.local.xcconfig.example to SignIn.local.xcconfig +// (gitignored) and fill it in; without it, dev builds simply don't show +// sign-in. +#include? "SignIn.local.xcconfig" diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index 2f30cb2d..e140b018 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -136,6 +136,14 @@ targets: Release: PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt BLURT_APP_NAME: Blurt + # Sign in with AssemblyAI's BLURT_OAUTH_* settings, for the dev + # configurations only. They name AssemblyAI's sandbox deployments, so the + # values live in a gitignored include (see Config/SignIn.xcconfig); without + # it, and in Release, they're unset and sign-in is off. Baked into + # Info.plist below and read by `OAuthConfiguration.blurt`. + configFiles: + Debug: Config/SignIn.xcconfig + Debug-Local: Config/SignIn.xcconfig sources: - path: Blurt # Shared with BlurtUITests: the single source of truth for UI-test @@ -158,6 +166,13 @@ targets: LSMinimumSystemVersion: "15.0" LSApplicationCategoryType: public.app-category.productivity NSMicrophoneUsageDescription: Blurt uses your microphone to transcribe your dictation with AssemblyAI. + # Sign in with AssemblyAI; unset or empty values turn it off (see + # `configFiles` above and `OAuthConfiguration.blurt`). + BlurtOAuthClientID: $(BLURT_OAUTH_CLIENT_ID) + BlurtOAuthTokenURL: $(BLURT_OAUTH_TOKEN_URL) + BlurtOAuthAuthorizeURL: $(BLURT_OAUTH_AUTHORIZE_URL) + BlurtOAuthAPIOrigin: $(BLURT_OAUTH_API_ORIGIN) + BlurtOAuthDictationOrigin: $(BLURT_OAUTH_DICTATION_ORIGIN) # Sparkle. The feed is a stable release-asset URL (the # BLURT_SPARKLE_FEED_URL setting above): release-publish.sh attaches # appcast.xml to every release, so /latest/download/ always resolves to diff --git a/Sources/AssemblyAI/OAuth/OAuthAuthorization.swift b/Sources/AssemblyAI/OAuth/OAuthAuthorization.swift new file mode 100644 index 00000000..8276fcc1 --- /dev/null +++ b/Sources/AssemblyAI/OAuth/OAuthAuthorization.swift @@ -0,0 +1,127 @@ +import CryptoKit +import Foundation + +/// Where an app signs a member in with "Sign in with AssemblyAI", and what it +/// asks for. Public client, authorization code with PKCE (RFC 7636): there is no +/// client secret to configure, because a desktop app can't keep one. +public struct OAuthConfiguration: Sendable, Equatable { + /// The app's Connected App client id. + public let clientID: String + /// The consent page the member signs in and approves on. + public let authorizeURL: URL + /// The authorization server's token endpoint (code exchange and refresh). + public let tokenURL: URL + /// AssemblyAI's exchange: an access token in, the connection's API key out. + public let apiKeyURL: URL + /// Ends the connection: revokes the grant and disables its key. + public let revokeURL: URL + /// Where the consent page sends the member back. A custom scheme, so the + /// system's web authentication session can catch it without a listener. + public let redirectURI: URL + public let scopes: [String] + + /// The scopes a key exchange needs: `aai:api` is what the exchange checks for, + /// `offline_access` gets a refresh token, `openid email` an ID token naming the + /// member. + public static let defaultScopes = ["openid", "email", "offline_access", "aai:api"] + + public init( + clientID: String, authorizeURL: URL, tokenURL: URL, apiKeyURL: URL, revokeURL: URL, + redirectURI: URL, scopes: [String] = OAuthConfiguration.defaultScopes + ) { + self.clientID = clientID + self.authorizeURL = authorizeURL + self.tokenURL = tokenURL + self.apiKeyURL = apiKeyURL + self.revokeURL = revokeURL + self.redirectURI = redirectURI + self.scopes = scopes + } + + /// The redirect's scheme, which is what a web authentication session listens + /// for. + public var callbackScheme: String? { redirectURI.scheme } +} + +/// One sign-in attempt: the consent page URL, plus the PKCE verifier and `state` +/// the callback has to be checked against. Single use; a new attempt makes a +/// new one. +public struct OAuthAuthorization: Sendable { + /// Why a callback carried no usable code. + public enum CallbackError: Error, Sendable, Equatable { + /// The member declined on the consent page. + case denied + /// The callback's `state` isn't this attempt's: a stale or forged redirect. + case stateMismatch + /// The authorization server reported an error other than a decline, or the + /// callback carried neither a code nor an error. + case failed(String?) + } + + public let configuration: OAuthConfiguration + /// Sent at the token endpoint; its hash went out on the consent page URL. + public let codeVerifier: String + public let state: String + + /// `randomBytes` is injectable so tests get a fixed verifier and state. + public init( + configuration: OAuthConfiguration, + randomBytes: (Int) -> [UInt8] = OAuthAuthorization.secureRandomBytes + ) { + self.configuration = configuration + // 32 bytes → a 43-character verifier, the shortest RFC 7636 allows. + self.codeVerifier = Self.base64URL(randomBytes(32)) + self.state = Self.base64URL(randomBytes(16)) + } + + /// The S256 challenge for `codeVerifier`. + public var codeChallenge: String { + Self.base64URL(Array(SHA256.hash(data: Data(codeVerifier.utf8)))) + } + + /// The consent page URL to open. + public var url: URL { + guard var components = URLComponents(url: configuration.authorizeURL, resolvingAgainstBaseURL: false) + else { return configuration.authorizeURL } + let existing = components.queryItems ?? [] + components.queryItems = + existing + [ + URLQueryItem(name: "client_id", value: configuration.clientID), + URLQueryItem(name: "redirect_uri", value: configuration.redirectURI.absoluteString), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: configuration.scopes.joined(separator: " ")), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: codeChallenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + return components.url ?? configuration.authorizeURL + } + + /// The authorization code from the redirect, after checking it answers this + /// attempt. + public func code(fromCallback callback: URL) throws(CallbackError) -> String { + let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [] + func value(_ name: String) -> String? { items.first { $0.name == name }?.value } + + // `state` first: an error redirect that isn't ours says nothing about this + // attempt either. + guard value("state") == state else { throw .stateMismatch } + if let error = value("error") { + throw error == "access_denied" ? .denied : .failed(value("error_description") ?? error) + } + guard let code = value("code")?.trimmedNonEmpty() else { throw .failed(nil) } + return code + } + + public static func secureRandomBytes(_ count: Int) -> [UInt8] { + var generator = SystemRandomNumberGenerator() + return (0.. String { + Data(bytes).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/AssemblyAI/OAuth/OAuthClient.swift b/Sources/AssemblyAI/OAuth/OAuthClient.swift new file mode 100644 index 00000000..82805a2d --- /dev/null +++ b/Sources/AssemblyAI/OAuth/OAuthClient.swift @@ -0,0 +1,253 @@ +import Foundation + +/// The network half of "Sign in with AssemblyAI": the token endpoint (code +/// exchange, refresh) and AssemblyAI's own two routes, the API-key exchange and +/// the connection's revoke. +public struct OAuthClient: Sendable { + /// Tokens from the token endpoint. A refresh may or may not rotate the refresh + /// token; when it does, the old one stops working, so the caller must keep the + /// new one. + public struct Tokens: Sendable, Equatable { + public let accessToken: String + public let refreshToken: String? + public let idToken: String? + + public init(accessToken: String, refreshToken: String?, idToken: String?) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.idToken = idToken + } + + /// The `email` claim of the ID token, for showing who is signed in. Read + /// without verifying the signature: it is display text from a token this + /// client just received over TLS, never an authorization decision. + public var email: String? { + guard let idToken else { return nil } + let parts = idToken.split(separator: ".") + guard parts.count >= 2 else { return nil } + var payload = parts[1].replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + payload += String(repeating: "=", count: (4 - payload.count % 4) % 4) + guard let data = Data(base64Encoded: payload), + let claims = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return (claims["email"] as? String)?.trimmedNonEmpty() + } + } + + /// The connection's API key and when it stops working. + public struct ConnectionKey: Sendable, Equatable { + public let apiKey: String + public let expiresAt: Date? + /// From when an exchange mints the next key: when to exchange again. Nil + /// from a server that doesn't say. + public let rotatesAt: Date? + + public init(apiKey: String, expiresAt: Date?, rotatesAt: Date? = nil) { + self.apiKey = apiKey + self.expiresAt = expiresAt + self.rotatesAt = rotatesAt + } + } + + public enum Failure: Error, Sendable, Equatable { + /// The credential was refused: an expired or revoked refresh token, an access + /// token the exchange won't take. Signing in again is the way back. + case rejected + /// The exchange refused this member (a role without API access, a disabled + /// key or project). Carries the server's explanation when it gave one. + case forbidden(String?) + /// Couldn't reach the server, or it failed (5xx, 429, a malformed body). + /// Nothing was decided; retry later. + case unreachable + } + + private let configuration: OAuthConfiguration + private let transport: any HTTPTransport + private let userAgent: String? + + public init( + configuration: OAuthConfiguration, transport: any HTTPTransport = URLSession.shared, + userAgent: String? = nil + ) { + self.configuration = configuration + self.transport = transport + self.userAgent = userAgent + } + + /// Trades the consent page's code for tokens. + public func tokens(for authorization: OAuthAuthorization, code: String) async throws(Failure) -> Tokens { + try await tokenRequest([ + "grant_type": "authorization_code", + "client_id": configuration.clientID, + "code": code, + "code_verifier": authorization.codeVerifier, + "redirect_uri": configuration.redirectURI.absoluteString, + ]) + } + + /// A fresh access token from the refresh token. + public func refresh(_ refreshToken: String) async throws(Failure) -> Tokens { + try await tokenRequest([ + "grant_type": "refresh_token", + "client_id": configuration.clientID, + "refresh_token": refreshToken, + ]) + } + + /// The connection's API key for this access token. The same key comes back on + /// every call until it expires; the first call after that mints the next one. + public func apiKey(accessToken: String) async throws(Failure) -> ConnectionKey { + var request = authorizedRequest(configuration.apiKeyURL, accessToken: accessToken) + request.httpMethod = "POST" + let (status, data) = try await send(request) + switch status { + case 200..<300: + guard let body = try? JSONDecoder().decode(APIKeyBody.self, from: data), + let apiKey = body.apiKey.trimmedNonEmpty() + else { throw .unreachable } + return ConnectionKey( + apiKey: apiKey, expiresAt: body.apiKeyExpiresAt.flatMap(Self.date(from:)), + rotatesAt: body.apiKeyRotatesAt.flatMap(Self.date(from:))) + case 401: + Self.logRefusal(request, status: status, data: data) + throw .rejected + case 403: + Self.logRefusal(request, status: status, data: data) + throw .forbidden(Self.explanation(in: data)) + default: + Self.logRefusal(request, status: status, data: data) + throw .unreachable + } + } + + /// Ends the connection server-side: the grant is revoked and the key disabled. + /// Succeeds when there was nothing left to end. + public func revoke(accessToken: String) async throws(Failure) { + var request = authorizedRequest(configuration.revokeURL, accessToken: accessToken) + request.httpMethod = "POST" + let (status, data) = try await send(request) + switch status { + case 200..<300: return + case 401: + Self.logRefusal(request, status: status, data: data) + throw .rejected + // 503 is the server saying the grant revoke failed: the grant is still + // live, so this must not look like a sign-out. + default: + Self.logRefusal(request, status: status, data: data) + throw .unreachable + } + } + + private func tokenRequest(_ fields: [String: String]) async throws(Failure) -> Tokens { + var request = URLRequest(url: configuration.tokenURL) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let userAgent { request.setValue(userAgent, forHTTPHeaderField: "User-Agent") } + request.httpBody = Data(Self.formEncoded(fields).utf8) + + let (status, data) = try await send(request) + switch status { + case 200..<300: + guard let body = try? JSONDecoder().decode(TokenBody.self, from: data), + let accessToken = body.accessToken.trimmedNonEmpty() + else { throw .unreachable } + return Tokens( + accessToken: accessToken, refreshToken: body.refreshToken?.trimmedNonEmpty(), + idToken: body.idToken?.trimmedNonEmpty()) + // RFC 6749 §5.2: a refused grant (`invalid_grant`, an unknown client) is a + // 400 or 401. Anything else says nothing about the credential. + case 400, 401: + Self.logRefusal(request, status: status, data: data) + throw .rejected + default: + Self.logRefusal(request, status: status, data: data) + throw .unreachable + } + } + + private func authorizedRequest(_ url: URL, accessToken: String) -> URLRequest { + var request = URLRequest(url: url) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let userAgent { request.setValue(userAgent, forHTTPHeaderField: "User-Agent") } + return request + } + + private func send(_ request: URLRequest) async throws(Failure) -> (Int, Data) { + do { + let (data, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse else { throw Failure.unreachable } + return (http.statusCode, data) + } catch { + throw .unreachable + } + } + + private struct TokenBody: Decodable { + let accessToken: String + let refreshToken: String? + let idToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case idToken = "id_token" + } + } + + private struct APIKeyBody: Decodable { + let apiKey: String + let apiKeyExpiresAt: String? + let apiKeyRotatesAt: String? + + enum CodingKeys: String, CodingKey { + case apiKey = "api_key" + case apiKeyExpiresAt = "api_key_expires_at" + case apiKeyRotatesAt = "api_key_rotates_at" + } + } + + /// Logs a refused or failed call: the path, the status, and the body's + /// `error`, `error_description` or `detail` (an OAuth error or FastAPI's). + /// Those fields describe the failure, never carry a credential. + static func logRefusal(_ request: URLRequest, status: Int, data: Data) { + let path = request.url?.path ?? "?" + let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + let fields = ["error", "error_description", "detail"].compactMap { key in + (body?[key] as? String).map { "\(key)=\($0)" } + } + let reason = fields.isEmpty ? "-" : fields.joined(separator: " ") + SDKLog.oauth.error("\(path, privacy: .public) -> \(status, privacy: .public): \(reason, privacy: .public)") + } + + /// The 403's reason: `error_description` from an `insufficient_scope` body, or + /// FastAPI's `detail`. + static func explanation(in data: Data) -> String? { + guard let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return ((body["error_description"] ?? body["detail"]) as? String)?.trimmedNonEmpty() + } + + /// RFC 3339, with or without fractional seconds. + static func date(from string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } + + static func formEncoded(_ fields: [String: String]) -> String { + // RFC 3986 unreserved characters only: `+`, `&` and `=` in a value would + // otherwise be read as form syntax. + let allowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + return fields.sorted { $0.key < $1.key } + .map { key, value in + let escaped = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + return "\(key)=\(escaped)" + } + .joined(separator: "&") + } +} diff --git a/Sources/AssemblyAI/README.md b/Sources/AssemblyAI/README.md index d3c5ec8f..71697638 100644 --- a/Sources/AssemblyAI/README.md +++ b/Sources/AssemblyAI/README.md @@ -1,16 +1,18 @@ # AssemblyAI The AssemblyAI API client that Blurt's engine is built on, kept free of Blurt policy so it can become -a standalone Swift SDK. Foundation only; macOS 15 and iOS 18. +a standalone Swift SDK. Foundation (plus CryptoKit for the PKCE challenge); macOS 15 and iOS 18. -| Type | What it is | -| -------------------------------------- | ------------------------------------------------------------------------------------ | -| `HTTPTransport` | The network seam. `URLSession` conforms, including a streamed (chunked) upload body. | -| `DictationConfig`, `DictationResponse` | The dictation API's `config` part and response, with only documented keys. | -| `ErrorResponse` | The dictation API's two documented error shapes. | -| `DictationMultipart` | The upload's multipart framing: `config` first, then the PCM audio. | -| `APIKeyValidator` | A cheap authenticated check that tells a rejected key from one it couldn't verify. | -| `KeyTerms` | Key terms for `keyterms_prompt`: parse a comma-separated list, fit it to the caps. | +| Type | What it is | +| -------------------------------------- | ------------------------------------------------------------------------------------- | +| `HTTPTransport` | The network seam. `URLSession` conforms, including a streamed (chunked) upload body. | +| `DictationConfig`, `DictationResponse` | The dictation API's `config` part and response, with only documented keys. | +| `ErrorResponse` | The dictation API's two documented error shapes. | +| `DictationMultipart` | The upload's multipart framing: `config` first, then the PCM audio. | +| `APIKeyValidator` | A cheap authenticated check that tells a rejected key from one it couldn't verify. | +| `KeyTerms` | Key terms for `keyterms_prompt`: parse a comma-separated list, fit it to the caps. | +| `OAuthAuthorization` | One "Sign in with AssemblyAI" attempt: PKCE verifier, `state`, consent URL, callback. | +| `OAuthClient` | Token endpoint (code, refresh) and the API-key exchange and revoke routes. | What it deliberately doesn't hold: which prompt, cleanup instruction or key terms to send, where the key or the key terms are stored, the `User-Agent`, or anything done to the text on the device (text diff --git a/Sources/AssemblyAI/Support.swift b/Sources/AssemblyAI/Support.swift index 9c1a7b06..beea3f3d 100644 --- a/Sources/AssemblyAI/Support.swift +++ b/Sources/AssemblyAI/Support.swift @@ -4,6 +4,9 @@ import os /// The SDK's own log, apart from any app's. Only transport faults are logged. enum SDKLog { static let transport = Logger(subsystem: "com.assemblyai.sdk", category: "Transport") + /// Refused or failed OAuth calls: the route, the status and the server's own + /// error fields. Never a token. + static let oauth = Logger(subsystem: "com.assemblyai.sdk", category: "OAuth") } extension URL { diff --git a/Sources/BlurtEngine/Account/AssemblyAIAccount.swift b/Sources/BlurtEngine/Account/AssemblyAIAccount.swift new file mode 100644 index 00000000..3210f64a --- /dev/null +++ b/Sources/BlurtEngine/Account/AssemblyAIAccount.swift @@ -0,0 +1,357 @@ +import AssemblyAI +import Foundation +import Observation + +/// "Sign in with AssemblyAI": the alternative to pasting a key. The member signs +/// in and approves Blurt on AssemblyAI's consent page; Blurt trades the access +/// token for the connection's own API key and stores that key exactly where a +/// pasted one goes, so the transcriber, the readiness gate and Settings read it +/// the same way. What signing in adds is renewal (a refresh and an exchange at +/// most once a day, which also picks up a rotated key) and a real sign-out +/// (which ends the connection server-side, not just here). +/// +/// The shell owns the browser (`ASWebAuthenticationSession`); this owns +/// everything either side of it, so the rules are unit-tested. +@MainActor +@Observable +public final class AssemblyAIAccount { + public enum Status: Sendable, Equatable { + case signedOut + /// `email` is nil when the ID token didn't carry one. + case signedIn(email: String?) + + /// The account row's text while signed in; nil while signed out, when the + /// row shows only its button. + public var statusText: String? { + switch self { + case .signedOut: nil + case .signedIn(let email?): "Signed in as \(email)" + case .signedIn(nil): "Signed in" + } + } + } + + private static let log = HostIdentity.current.logger("Account") + + /// The longest `runRenewal` sleeps between checks. A renewal is normally + /// due days away, and the loop sleeps until then; the cap bounds how long a + /// sign-in made while it sleeps waits for its schedule, and how far a + /// changed system clock can throw it. + public static let longestRenewalSleep: TimeInterval = 24 * 60 * 60 + /// The shortest: a renewal that keeps failing (offline) retries no faster. + public static let shortestRenewalSleep: TimeInterval = 60 + /// A rejected key renews at once, but no more often than this, so repeated + /// dictation failures can't hammer the token endpoint. + public static let rejectedKeyRenewalSpacing: TimeInterval = 15 * 60 + + /// Whether this build has a Connected App configured. Builds without one + /// (today: release) show only the paste-a-key path. + public var isAvailable: Bool { configuration != nil } + public private(set) var status: Status { + didSet { syncDictationOrigin() } + } + + /// The dictation host for the current sign-in, for the transcriber. Set to + /// `dictationHost` while signed in, nil otherwise. + public nonisolated let dictationOrigin = DictationOrigin() + + @ObservationIgnored private let configuration: OAuthConfiguration? + /// Where this sign-in's keys dictate; nil means production. + @ObservationIgnored private let dictationHost: URL? + @ObservationIgnored private let apiKey: APIKeyModel + @ObservationIgnored private let store: SignInStore + @ObservationIgnored private let client: OAuthClient? + @ObservationIgnored private let now: @Sendable () -> Date + /// The renewal in flight, which `signOut()` waits out: the renewal may have + /// rotated the refresh token, and refreshing with the old one would read as + /// "already over". + @ObservationIgnored private var renewal: Task? + /// When a rejected key last forced a renewal (`keyWasRejected`). + @ObservationIgnored private var lastForcedRenewal: Date? + /// Bumped whenever the stored session is replaced or dropped (a sign-in, a + /// sign-out, a reset). A renewal suspends twice on the network; one that wakes + /// to a different generation writes nothing, or it would bring back a session + /// the user just ended. + @ObservationIgnored private var generation = 0 + + public init( + configuration: OAuthConfiguration?, + dictationHost: URL? = nil, + apiKey: APIKeyModel, + store: SignInStore = .production, + transport: any HTTPTransport = URLSession.shared, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.configuration = configuration + self.dictationHost = dictationHost + self.apiKey = apiKey + self.store = store + self.client = configuration.map { + OAuthClient(configuration: $0, transport: transport, userAgent: UserAgent.current) + } + self.now = now + // An unreadable item (a locked Keychain at launch) starts as signed out; + // `refreshStatus()` catches up once it reads. + if case .session(let session) = store.load { + self.status = .signedIn(email: session.email) + } else { + self.status = .signedOut + } + // `didSet` doesn't run for the initial value. + syncDictationOrigin() + } + + private func syncDictationOrigin() { + dictationOrigin.set(status == .signedOut ? nil : dictationHost) + } + + /// The session storage, for the install reset's sweep (the in-memory store + /// under UI testing). `refreshStatus()` catches `status` up afterwards. + public var storage: SignInStore { store } + + /// Re-reads the session item and updates `status`, leaving it alone when the + /// item can't be read. Called when the account UI appears, like + /// `APIKeyModel.refreshStatus()`. + public func refreshStatus() { + switch store.load { + case .session(let session): status = .signedIn(email: session.email) + case .none: status = .signedOut + case .unavailable: break + } + } + + /// A new sign-in attempt for the shell to open, or nil when sign-in isn't + /// configured. + public func beginSignIn() -> OAuthAuthorization? { + configuration.map { OAuthAuthorization(configuration: $0) } + } + + /// Finishes a sign-in from the consent page's redirect: code → tokens → key, + /// then stores both. Nothing is stored unless every step succeeds, so a failed + /// attempt leaves whatever was there (a pasted key, or nothing) as it was. + public func completeSignIn(_ authorization: OAuthAuthorization, callback: URL) async -> SignInOutcome { + guard let client else { return .failed(.unavailable) } + let code: String + do throws(OAuthAuthorization.CallbackError) { + code = try authorization.code(fromCallback: callback) + } catch .denied { + return .cancelled + } catch { + // The callback's error, never its code. + Self.log.error("sign-in callback refused: \(String(describing: error), privacy: .public)") + return .failed(.consent) + } + do throws(OAuthClient.Failure) { + let tokens = try await client.tokens(for: authorization, code: code) + guard let refreshToken = tokens.refreshToken else { + Self.log.error("token response carried no refresh token (offline_access not granted?)") + return .failed(.server) + } + let key = try await client.apiKey(accessToken: tokens.accessToken) + let session = SignInSession( + refreshToken: refreshToken, email: tokens.email, keyExpiresAt: key.expiresAt, + keyRotatesAt: key.rotatesAt, renewedAt: now()) + generation += 1 + guard store.save(session), apiKey.save(key.apiKey) else { + store.save(nil) + return .failed(.keychain) + } + status = .signedIn(email: session.email) + return .signedIn + } catch { + return .failed(SignInFailure(error)) + } + } + + /// Renews when due and otherwise sleeps until the next renewal is, until + /// cancelled. The shell starts it once at launch. The default clock keeps + /// counting while the Mac sleeps, so a renewal that fell due during sleep + /// runs on wake. + public func runRenewal>(clock: C = ContinuousClock()) async { + while !Task.isCancelled { + await renewIfDue() + do { try await clock.sleep(for: .seconds(nextRenewalCheck())) } catch { return } + } + } + + /// Seconds until `runRenewal` should look again: until the session's + /// renewal is due, within the shortest and longest sleep. Signed out (or + /// unreadable), the longest. + func nextRenewalCheck() -> TimeInterval { + guard case .session(let session) = store.load else { return Self.longestRenewalSleep } + let untilDue = session.renewalDue.timeIntervalSince(now()) + return min(max(untilDue, Self.shortestRenewalSleep), Self.longestRenewalSleep) + } + + /// Dictation refused the stored key (expired early, deleted by an admin, a + /// revoked connection). Renews at once rather than when due, so the next + /// dictation has a working key, or the account notices the connection is + /// over. At most once per `rejectedKeyRenewalSpacing`; nothing to do for a + /// pasted key. + public func keyWasRejected() async { + let now = now() + if let lastForcedRenewal, now.timeIntervalSince(lastForcedRenewal) < Self.rejectedKeyRenewalSpacing { + return + } + guard case .session = store.load else { return } + lastForcedRenewal = now + await renewIfDue(force: true) + } + + /// Refreshes and re-exchanges when `SignInSession.isRenewalDue` says so. A + /// refused refresh or exchange means the connection is over (revoked from the + /// dashboard, the member removed, the refresh token lapsed), so the key and + /// session go and the row offers sign-in again. An unreachable server, or an + /// unreadable session item, changes nothing; the next check tries again. + public func renewIfDue() async { + await renewIfDue(force: false) + } + + private func renewIfDue(force: Bool) async { + if let renewal { return await renewal.value } + guard case .session(let session) = store.load, + force || session.isRenewalDue(now: now(), hasKey: apiKey.current != nil) + else { return } + let task = Task { await renew(session) } + renewal = task + await task.value + renewal = nil + } + + /// Ends the connection server-side, then forgets it here. Returns the failure + /// to show, with everything left in place, when the server couldn't confirm: + /// the connection is still live and its key still works, so claiming a + /// sign-out would be wrong. A refresh token the server already refuses means + /// the connection is already over, which counts as signed out. Nil on success. + public func signOut() async -> APIKeySubmission.FailureReport? { + if let renewal { await renewal.value } + let session: SignInSession + switch store.load { + case .session(let stored): session = stored + case .none: + forget() + return nil + case .unavailable: + return .inline(message: Self.keychainUnreadableMessage) + } + // A build without sign-in configured can't reach the server, but the + // session it inherited must still be removable: forget it here. + guard let client else { + forget() + return nil + } + do throws(OAuthClient.Failure) { + let tokens = try await client.refresh(session.refreshToken) + try await client.revoke(accessToken: tokens.accessToken) + } catch .rejected { + // Nothing left server-side to end. + } catch { + return .inline(message: Self.signOutUnconfirmedMessage) + } + forget() + return nil + } + + static let signOutUnconfirmedMessage = + "AssemblyAI couldn’t confirm the sign-out, so Blurt is still connected. Check your connection and try again." + static let keychainUnreadableMessage = + "Blurt couldn’t read your sign-in from the macOS Keychain. Check Keychain access and try again." + + /// Drops the session and its key from this Mac without telling the server, + /// for a connection the server already ended. + func forget() { + generation += 1 + store.save(nil) + apiKey.clear() + status = .signedOut + } + + private func renew(_ session: SignInSession) async { + guard let client else { return } + let started = generation + var session = session + do throws(OAuthClient.Failure) { + let tokens = try await client.refresh(session.refreshToken) + guard generation == started else { return } + // Keep a rotated refresh token before anything else can fail: the old one + // is already dead. + if let rotated = tokens.refreshToken { session.refreshToken = rotated } + session.email = tokens.email ?? session.email + store.save(session) + let key = try await client.apiKey(accessToken: tokens.accessToken) + guard generation == started else { return } + session.keyExpiresAt = key.expiresAt + session.keyRotatesAt = key.rotatesAt + session.renewedAt = now() + session.deferRenewalIfStillDue(now: session.renewedAt) + if apiKey.current != key.apiKey { apiKey.save(key.apiKey) } + store.save(session) + status = .signedIn(email: session.email) + } catch .unreachable { + return + } catch { + if generation == started { forget() } + } + } +} + +/// How a sign-in attempt ended. +public enum SignInOutcome: Sendable, Equatable { + case signedIn + /// The member closed the window or declined on the consent page. Nothing to + /// report. + case cancelled + case failed(SignInFailure) +} + +/// Why a sign-in failed, with the wording the shell shows. Owned here, next to +/// the outcomes, so the shell renders one judgement instead of assembling text +/// at an alert call site no test reaches. +public enum SignInFailure: Sendable, Equatable { + /// This build has no Connected App configured. + case unavailable + /// The redirect didn't answer this attempt or reported an error. + case consent + /// The exchange refused this member; carries the server's reason. + case forbidden(String?) + /// A credential was refused mid-flow (an expired code, a revoked grant). + case rejected + /// Couldn't reach AssemblyAI, or it failed. + case server + /// The key or the session couldn't be written to the Keychain. + case keychain + + init(_ failure: OAuthClient.Failure) { + switch failure { + case .rejected: self = .rejected + case .forbidden(let reason): self = .forbidden(reason) + case .unreachable: self = .server + } + } + + /// How to report it: inline beside the row for anything a retry (or an admin) + /// can fix, an alert only for the Keychain fault retrying can't touch. The + /// same split `APIKeySubmission.Outcome.failureReport` makes for a pasted key. + public var failureReport: APIKeySubmission.FailureReport { + switch self { + case .keychain: .alert(title: "Couldn’t Save Your Sign-In", message: message) + default: .inline(message: message) + } + } + + var message: String { + switch self { + case .unavailable: + "Signing in isn’t available in this version of Blurt. Use an API key instead." + case .consent, .rejected: + "AssemblyAI didn’t complete the sign-in. Try again." + case .forbidden(let reason): + reason.map { "AssemblyAI didn’t allow it: \($0)" } + ?? "AssemblyAI didn’t allow Blurt to use your account. Ask an admin of your organization." + case .server: + "Couldn’t reach AssemblyAI. Check your connection and try again." + case .keychain: + "Blurt couldn’t write to your macOS Keychain. Check Keychain access and try again." + } + } +} diff --git a/Sources/BlurtEngine/Account/CredentialRows.swift b/Sources/BlurtEngine/Account/CredentialRows.swift new file mode 100644 index 00000000..a954c056 --- /dev/null +++ b/Sources/BlurtEngine/Account/CredentialRows.swift @@ -0,0 +1,31 @@ +/// Which rows the AssemblyAI section shows and which button is its primary +/// action, from the stored key, whether sign-in is configured, and whether a +/// session exists. A pure projection in the engine, like `APIKeyDisplay`, so the +/// rule is tested rather than spread across two SwiftUI files. +public struct CredentialRows: Sendable, Equatable { + public enum Primary: Sendable, Equatable { + /// "Sign In with AssemblyAI…" is filled. + case signIn + /// The key row's "Connect…" is filled. + case connect + } + + /// The account row: offered where sign-in is configured, and kept for a + /// session inherited by a build without it, so its Sign Out stays reachable. + public let showsAccountRow: Bool + /// The key row, hidden while signed in: the key is the connection's to rotate. + public let showsKeyRow: Bool + /// The filled button, or nil once a key is stored (nothing is waiting on the + /// user, and a lone filled button would mark a rare errand as the main action). + public let primary: Primary? + + public static func resolve( + isKeyStored: Bool, isSignInAvailable: Bool, status: AssemblyAIAccount.Status + ) -> CredentialRows { + let signedIn = status != .signedOut + let primary: Primary? = + signedIn || isKeyStored ? nil : (isSignInAvailable ? .signIn : .connect) + return CredentialRows( + showsAccountRow: isSignInAvailable || signedIn, showsKeyRow: !signedIn, primary: primary) + } +} diff --git a/Sources/BlurtEngine/Account/DictationOrigin.swift b/Sources/BlurtEngine/Account/DictationOrigin.swift new file mode 100644 index 00000000..88caa6df --- /dev/null +++ b/Sources/BlurtEngine/Account/DictationOrigin.swift @@ -0,0 +1,25 @@ +import Foundation +import Synchronization + +/// The dictation host a signed-in key belongs to, or nil to use the default +/// (production). A key minted by a sandbox sign-in only works against that +/// sandbox's dictation host, and a pasted key is a production key, so the host +/// follows the sign-in rather than the build. +/// +/// `AssemblyAIAccount` keeps it current as it signs in and out. The app hands +/// the transcriber a `baseURL` closure that reads it at every request and +/// warm-up, off the main actor, so it is a `Mutex` rather than observable +/// state. +public final class DictationOrigin: Sendable { + private let url = Mutex(nil) + + public init() {} + + /// The signed-in key's host, or nil while signed out or when the sign-in + /// configures none. + public var current: URL? { url.withLock { $0 } } + + func set(_ newValue: URL?) { + url.withLock { $0 = newValue } + } +} diff --git a/Sources/BlurtEngine/Account/KeyRejectionReporter.swift b/Sources/BlurtEngine/Account/KeyRejectionReporter.swift new file mode 100644 index 00000000..c459cc8b --- /dev/null +++ b/Sources/BlurtEngine/Account/KeyRejectionReporter.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Wraps a transcriber to report a dictation the API refused for its key, so a +/// signed-in account can renew at once (`AssemblyAIAccount.keyWasRejected`) +/// instead of waiting for its scheduled renewal. The error still reaches the +/// caller unchanged: the utterance can't be retried, because its audio was +/// streamed as it was spoken. +/// +/// A refused key is a 401, or the 404 the dictation API relays from the +/// transcription service for an invalid key (see `ErrorResponse`). +public struct KeyRejectionReporter: TranscriberProtocol { + private let base: any TranscriberProtocol + private let onRejected: @Sendable () -> Void + + public init(_ base: any TranscriberProtocol, onRejected: @escaping @Sendable () -> Void) { + self.base = base + self.onRejected = onRejected + } + + public func transcribe( + frames: AsyncStream, sampleRate: Int, context: TranscriptionContext? + ) async throws -> String { + do { + return try await base.transcribe(frames: frames, sampleRate: sampleRate, context: context) + } catch let error as AssemblyAIError { + if case .http(let status, _) = error, status == 401 || status == 404 { onRejected() } + throw error + } + } + + public func warmUp() async { + await base.warmUp() + } +} diff --git a/Sources/BlurtEngine/Account/OAuthConfiguration+Blurt.swift b/Sources/BlurtEngine/Account/OAuthConfiguration+Blurt.swift new file mode 100644 index 00000000..9df90f4a --- /dev/null +++ b/Sources/BlurtEngine/Account/OAuthConfiguration+Blurt.swift @@ -0,0 +1,69 @@ +import AssemblyAI +import Foundation + +extension OAuthConfiguration { + /// The Info.plist keys a build sets to turn sign-in on. They come from build + /// settings in `App/Blurt/project.yml`, so a build without them (release, + /// until a production Connected App exists) offers only the paste-a-key path. + public enum InfoKey { + /// The Connected App's client id. + public static let clientID = "BlurtOAuthClientID" + /// The authorization server's token endpoint. + public static let tokenURL = "BlurtOAuthTokenURL" + /// The dashboard's consent page. + public static let authorizeURL = "BlurtOAuthAuthorizeURL" + /// The AssemblyAI web origin that serves the key routes + /// (`/api/v2/oauth/api-key[/revoke]`). In production the same host as the + /// consent page; on staging the dashboard and the site deploy apart. + public static let apiOrigin = "BlurtOAuthAPIOrigin" + /// The dictation host a signed-in key belongs to (`DictationOrigin`). + /// Optional: unset, a signed-in key dictates against production. + public static let dictationOrigin = "BlurtOAuthDictationOrigin" + } + + /// Blurt's redirect. ASWebAuthenticationSession catches the scheme itself, so + /// the app registers no URL handler; the Connected App must list it exactly. + /// A reverse-domain private-use scheme, as RFC 8252 §7.1 asks, so it can't + /// collide with another app's. `://` rather than RFC 8252's single slash + /// because Stytch refuses a redirect URL without it. + public static let blurtRedirectURI = URL(staticString: "com.assemblyai.blurt://oauth/callback") + + /// The dictation host this build's sign-in mints keys for, or nil (unset, + /// blank, or neither https nor loopback http) for production. + public static func blurtDictationOrigin(infoDictionary: [String: Any]) -> URL? { + guard let value = (infoDictionary[InfoKey.dictationOrigin] as? String)?.trimmedNonEmpty(), + let url = URL(string: value), isAllowed(url) + else { return nil } + return url + } + + /// https, or plain http to this machine (`localhost`, `127.0.0.1`, `::1`) so + /// a dev build can point at a locally run dashboard or site. Nothing on the + /// loopback interface leaves the Mac. + static func isAllowed(_ url: URL) -> Bool { + switch url.scheme { + case "https": true + case "http": ["localhost", "127.0.0.1", "::1"].contains(url.host() ?? "") + default: false + } + } + + /// Sign-in as this build configures it, or nil when any key is missing, + /// not https, or blank (an unset build setting expands to an empty string). + public static func blurt(infoDictionary: [String: Any]) -> OAuthConfiguration? { + func value(_ key: String) -> String? { (infoDictionary[key] as? String)?.trimmedNonEmpty() } + guard let clientID = value(InfoKey.clientID), + let tokenURL = value(InfoKey.tokenURL).flatMap(URL.init(string:)), + let authorizeURL = value(InfoKey.authorizeURL).flatMap(URL.init(string:)), + let origin = value(InfoKey.apiOrigin).flatMap(URL.init(string:)), + [tokenURL, authorizeURL, origin].allSatisfy(isAllowed) + else { return nil } + return OAuthConfiguration( + clientID: clientID, + authorizeURL: authorizeURL, + tokenURL: tokenURL, + apiKeyURL: origin.appending(path: "api/v2/oauth/api-key"), + revokeURL: origin.appending(path: "api/v2/oauth/api-key/revoke"), + redirectURI: blurtRedirectURI) + } +} diff --git a/Sources/BlurtEngine/Account/SignInSession.swift b/Sources/BlurtEngine/Account/SignInSession.swift new file mode 100644 index 00000000..31709b6e --- /dev/null +++ b/Sources/BlurtEngine/Account/SignInSession.swift @@ -0,0 +1,144 @@ +import Foundation + +/// What Blurt keeps about a "Sign in with AssemblyAI" connection, beside the API +/// key itself (which lives in the ordinary key item, so every reader of the key +/// works unchanged whether it was pasted or signed in for). +public struct SignInSession: Codable, Sendable, Equatable { + /// Mints fresh access tokens. Rotated by the server on some refreshes, so the + /// latest one is always written back. + public var refreshToken: String + /// Who signed in, for the account row. Nil when the ID token didn't say. + public var email: String? + /// When the connection's key stops working, as the exchange reported it. + public var keyExpiresAt: Date? + /// When to renew: from when the exchange mints the next key, as it reported + /// it (`api_key_rotates_at`). Nil from a server that doesn't say, or a + /// session saved before it did. + public var keyRotatesAt: Date? + /// The last successful refresh and exchange. + public var renewedAt: Date + + public init( + refreshToken: String, email: String?, keyExpiresAt: Date?, keyRotatesAt: Date? = nil, renewedAt: Date + ) { + self.refreshToken = refreshToken + self.email = email + self.keyExpiresAt = keyExpiresAt + self.keyRotatesAt = keyRotatesAt + self.renewedAt = renewedAt + } + + /// Without `keyRotatesAt`, renew this far ahead of the key's expiry (the + /// server's usual rotation window), so the next key is in place before the + /// old one stops working mid-dictation. One renewal per key is all the + /// refresh token needs: its sliding lifetime (three months) dwarfs a key's + /// (two weeks). + public static let expiryMargin: TimeInterval = 2 * 24 * 60 * 60 + /// When the exchange didn't say when the key expires: renew this long after + /// the last renewal instead. + public static let unknownExpiryInterval: TimeInterval = 24 * 60 * 60 + + /// When the next renewal is due: when the server said the key rotates, else + /// `expiryMargin` before it expires. + public var renewalDue: Date { + keyRotatesAt + ?? keyExpiresAt.map { $0.addingTimeInterval(-Self.expiryMargin) } + ?? renewedAt.addingTimeInterval(Self.unknownExpiryInterval) + } + + /// After a renewal that brought back a key still due for renewal (a server + /// that didn't rotate it early): push the next renewal to the key's expiry, + /// when any server rotates, or once it has expired, a day out, rather than + /// asking again every minute. A dictation the key fails meanwhile still + /// renews at once (`AssemblyAIAccount.keyWasRejected`). + mutating func deferRenewalIfStillDue(now: Date) { + guard renewalDue <= now else { return } + if let keyExpiresAt, keyExpiresAt > now { + keyRotatesAt = keyExpiresAt + } else { + keyRotatesAt = now.addingTimeInterval(Self.unknownExpiryInterval) + } + } + + /// Whether a renewal should refresh and exchange now: it's due, or no key is + /// stored at all. + public func isRenewalDue(now: Date, hasKey: Bool) -> Bool { + !hasKey || now >= renewalDue + } +} + +/// Keychain storage for the `SignInSession`, as JSON in its own item next to the +/// API key. `read`/`write` are closures, the way `MemoizedKeyStore` takes its +/// storage, so tests hand over a double instead of the real item. +public struct SignInStore: Sendable { + /// A read that keeps "nothing stored" apart from "couldn't read it" (a locked + /// Keychain, an ACL prompt pending after a re-sign) — the distinction + /// `KeychainStore.ReadResult` exists for. Treating the second as the first + /// would show a signed-in Mac as signed out, and let Sign Out delete the key + /// without ending the connection. + public enum Load: Sendable, Equatable { + case none + case session(SignInSession) + case unavailable + } + + private let read: @Sendable () -> KeychainStore.ReadResult + private let write: @Sendable (String?) -> Bool + + init(read: @escaping @Sendable () -> KeychainStore.ReadResult, write: @escaping @Sendable (String?) -> Bool) { + self.read = read + self.write = write + } + + /// The production item, in the host's Keychain service. + public static let production: SignInStore = { + let keychain = KeychainStore(service: HostIdentity.current.keychainService, account: "AssemblyAISignIn") + return SignInStore(read: { keychain.read() }, write: { keychain.write($0) }) + }() + + /// An in-memory store for tests and UI-test runs. + public static func inMemory() -> SignInStore { + let box = InMemoryAPIKeyStore() + return SignInStore( + read: { box.current.map { .value($0) } ?? .absent }, + write: { box.save($0) }) + } + + public var load: Load { + switch read() { + case .absent: return .none + case .unavailable: return .unavailable + case .value(let json): + // An item that doesn't decode is as good as none: nothing in it can be + // refreshed. + guard let session = try? JSONDecoder.signIn.decode(SignInSession.self, from: Data(json.utf8)) + else { return .none } + return .session(session) + } + } + + /// Stores `session`, or deletes the item for nil. + @discardableResult + public func save(_ session: SignInSession?) -> Bool { + guard let session else { return write(nil) } + guard let data = try? JSONEncoder.signIn.encode(session), let json = String(bytes: data, encoding: .utf8) + else { return false } + return write(json) + } +} + +extension JSONEncoder { + fileprivate static var signIn: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } +} + +extension JSONDecoder { + fileprivate static var signIn: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Sources/BlurtEngine/Config/APIKeyModel.swift b/Sources/BlurtEngine/Config/APIKeyModel.swift index ce997436..43f879cb 100644 --- a/Sources/BlurtEngine/Config/APIKeyModel.swift +++ b/Sources/BlurtEngine/Config/APIKeyModel.swift @@ -52,11 +52,20 @@ public final class APIKeyModel { /// The storage seam itself, handed to the Settings window's full-install reset /// (`InstallReset`) so the sweep deletes the key from the *injected* store — /// the in-memory one under UI testing — rather than reaching past this model - /// to the production Keychain item. Exposed rather than wrapped in a `clear()` - /// here because the reset owns the sweep; what this model owns is - /// `refreshStatus()`, which is how `hasAPIKey` catches up afterwards. + /// to the production Keychain item. The reset owns that sweep and reports on + /// it; `refreshStatus()` is how `hasAPIKey` catches up afterwards. public var storage: any APIKeyGateway { keyStore } + /// Deletes the stored key and refreshes `hasAPIKey`. For a sign-in whose + /// connection ended (`AssemblyAIAccount`): that key was never the user's to + /// keep. Returns whether the delete succeeded. + @discardableResult + public func clear() -> Bool { + let cleared = keyStore.save(nil) + refreshStatus() + return cleared + } + /// A `@Sendable` snapshot of the readiness gate for `DictationSession`: a press /// with no key saved fails fast as `.failed(.apiKeyMissing)` before any capture. /// Captures the (Sendable) store, not this main-actor model, so it can cross diff --git a/Sources/BlurtEngine/Config/InstallReset.swift b/Sources/BlurtEngine/Config/InstallReset.swift index 19927547..83bb2ce3 100644 --- a/Sources/BlurtEngine/Config/InstallReset.swift +++ b/Sources/BlurtEngine/Config/InstallReset.swift @@ -2,7 +2,7 @@ import Foundation /// The in-app equivalent of `scripts/reset-install.sh`: everything Blurt has put /// on this machine, removed in one pass — the persisted settings, the Keychain -/// API key, the TCC grants (Accessibility, Microphone, Input Monitoring), and +/// API key and any "Sign in with AssemblyAI" session, the TCC grants (Accessibility, Microphone, Input Monitoring), and /// the developer-mode logs — so someone whose install is in a bad state can get /// back to preinstall without a terminal. /// @@ -45,7 +45,7 @@ public struct InstallReset { /// added later can't ship with an alert that forgets to name it. var failureAlert: AlertContent? { let survivors = [ - apiKeyCleared ? nil : "your AssemblyAI API key", + apiKeyCleared ? nil : "your AssemblyAI API key or sign-in", permissionsCleared ? nil : "Blurt’s microphone, accessibility and input-monitoring permissions", logsCleared ? nil : "the dictation logs", ].compactMap { $0 } @@ -68,7 +68,9 @@ public struct InstallReset { /// **running** one (`Bundle.main.bundleIdentifier`) for the reason spelled out /// on `PermissionsReset` — a dev build must never clear the released Blurt's /// grants. - public init(bundleID: String, keyStore: any APIKeyGateway) { + /// `signInStore` likewise: the account's own storage, so the session item goes + /// with the key (locally only — a reset is not a sign-out). + public init(bundleID: String, keyStore: any APIKeyGateway, signInStore: SignInStore) { self.init( clearSettings: { PersistedSettings.resetAll() @@ -79,7 +81,8 @@ public struct InstallReset { // would survive a reset. UserDefaults.standard.removePersistentDomain(forName: bundleID) }, - clearAPIKey: { keyStore.save(nil) }, + // Both always run: a failed key delete mustn't leave the session behind. + clearAPIKey: { [keyStore.save(nil), signInStore.save(nil)].allSatisfy { $0 } }, resetPermissions: { #if os(macOS) return PermissionsReset.resetAll(bundleID: bundleID) diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 4a994634..8635ca57 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -191,7 +191,7 @@ func transcribe( func warmUp() async // optional; no-op default ``` -`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`, in the `AssemblyAI` library with `HTTPTransport`, the wire types and `APIKeyValidator`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:enhanced:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`), and a `rewriteMode` closure choosing the rewrite's model (nil, the default, reads `RewriteModeStore`; Smart sends `config.rewrite_mode: "smart"`, Fast omits it, and nothing is sent while enhanced transcripts are off). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). +`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`, in the `AssemblyAI` library with `HTTPTransport`, the wire types and `APIKeyValidator`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:enhanced:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL` closure read at every request and warm-up (defaults to `defaultBaseURL`, production; a data-zone host goes here, and Blurt passes the signed-in key's `DictationOrigin`), an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`), and a `rewriteMode` closure choosing the rewrite's model (nil, the default, reads `RewriteModeStore`; Smart sends `config.rewrite_mode: "smart"`, Fast omits it, and nothing is sent while enhanced transcripts are off). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). The model's limits live in `SyncSTTLimits` (16 kHz sample rate, ~0.1 s–120 s audio, and the auto-release math — the sync STT model behind the dictation service) — the single source shared by the mic, the session, and the request so recorded and declared geometry can't drift. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index 902c86c7..e2e48097 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -43,11 +43,11 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// internal so `DictationUploadDelegate` in `DictationUploadMetrics.swift` /// writes the same category — one category, two files. static let log = HostIdentity.current.logger("Transcriber") + public static let defaultBaseURL = URL(staticString: "https://dictation.assemblyai.com") private let apiKeyProvider: @Sendable () -> String? - /// Internal, not private, for the same reason `DictationWireTypes`' nested - /// types are: `warmUp()` lives in `DictationWarmUp.swift` and needs both, and - /// Swift's `private` is file-scoped so it cannot cross that split. - let baseURL: URL + /// Read per request, so the host can change while the app runs. Internal for `warmUp()`. + var baseURL: URL { baseURLProvider() } + private let baseURLProvider: @Sendable () -> URL let transport: any HTTPTransport private let enhancedTranscriptsEnabled: @Sendable () -> Bool private let customStyle: @Sendable () -> String? @@ -85,14 +85,14 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// internal member. public init( apiKeyProvider: @escaping @Sendable () -> String? = { APIKeyStore.current }, - baseURL: URL = URL(staticString: "https://dictation.assemblyai.com"), + baseURL: @escaping @Sendable () -> URL = { AssemblyAITranscriber.defaultBaseURL }, transport: any HTTPTransport = URLSession.shared, enhancedTranscripts: (@Sendable () -> Bool)? = nil, customStyle: (@Sendable () -> String?)? = nil, rewriteMode: (@Sendable () -> RewriteMode)? = nil ) { self.apiKeyProvider = apiKeyProvider - self.baseURL = baseURL + self.baseURLProvider = baseURL self.transport = transport self.enhancedTranscriptsEnabled = enhancedTranscripts ?? { EnhancedTranscriptsStore().isEnabled } self.customStyle = customStyle ?? { StyleProfileStore().activeInstructions } diff --git a/Tests/BlurtEngineTests/APIKeyModelTests.swift b/Tests/BlurtEngineTests/APIKeyModelTests.swift index b5a98d5f..3ee1cbb2 100644 --- a/Tests/BlurtEngineTests/APIKeyModelTests.swift +++ b/Tests/BlurtEngineTests/APIKeyModelTests.swift @@ -42,4 +42,14 @@ struct APIKeyModelTests { #expect(model.current == "typed") #expect(model.storage.current == "typed") } + + @Test("clear deletes the stored key and drops the flag") + func clear() { + let store = InMemoryAPIKeyStore() + let model = APIKeyModel(keyStore: store) { _ in .valid } + model.save("key") + #expect(model.clear()) + #expect(!model.hasAPIKey) + #expect(store.current == nil) + } } diff --git a/Tests/BlurtEngineTests/AssemblyAIAccountTests.swift b/Tests/BlurtEngineTests/AssemblyAIAccountTests.swift new file mode 100644 index 00000000..74c72778 --- /dev/null +++ b/Tests/BlurtEngineTests/AssemblyAIAccountTests.swift @@ -0,0 +1,303 @@ +import Foundation +import Security +import Synchronization +import Testing + +@testable import AssemblyAI +@testable import BlurtEngine + +/// Answers the three sign-in routes from per-route statuses the test sets, and +/// records what was sent. +final class SignInServer: Sendable { + struct State { + var tokenStatus = 200 + var exchangeStatus = 200 + var revokeStatus = 204 + var codeGrantRefreshToken: String? = "rt-1" + var rotatedRefreshToken: String? = "rt-2" + var apiKey = "connection-key" + var expiresAt = "2026-11-01T00:00:00Z" + var rotatesAt: String? + var refreshTokensSeen: [String] = [] + var exchanges = 0 + var revokes = 0 + } + + let state = Mutex(State()) + + var transport: FakeHTTPTransport { + FakeHTTPTransport { [self] request in + state.withLock { state in + switch request.url { + case testOAuthConfiguration.tokenURL: + let body = String(bytes: request.httpBody ?? Data(), encoding: .utf8) ?? "" + if let pair = body.split(separator: "&").first(where: { $0.hasPrefix("refresh_token=") }) { + state.refreshTokensSeen.append(String(pair.dropFirst("refresh_token=".count))) + } + guard state.tokenStatus == 200 else { return (state.tokenStatus, json(["error": "invalid_grant"])) } + var fields = ["access_token": "at", "id_token": unsignedJWT(["email": "member@example.com"])] + if body.contains("authorization_code") { + fields["refresh_token"] = state.codeGrantRefreshToken + } else if let rotated = state.rotatedRefreshToken { + fields["refresh_token"] = rotated + } + return (200, json(fields)) + case testOAuthConfiguration.apiKeyURL: + state.exchanges += 1 + guard state.exchangeStatus == 200 else { return (state.exchangeStatus, Data()) } + var body = ["api_key": state.apiKey, "api_key_expires_at": state.expiresAt] + body["api_key_rotates_at"] = state.rotatesAt + return (200, json(body)) + case testOAuthConfiguration.revokeURL: + state.revokes += 1 + return (state.revokeStatus, Data()) + default: + return (404, Data()) + } + } + } + } +} + +/// A clock the test moves. +final class SignInTestClock: Sendable { + let date = Mutex(Date(timeIntervalSince1970: 1_790_000_000)) + var now: Date { date.withLock { $0 } } + func advance(_ interval: TimeInterval) { date.withLock { $0 += interval } } +} + +@Suite("AssemblyAI account (sign in with AssemblyAI)") +@MainActor +struct AssemblyAIAccountTests { + let server = SignInServer() + let clock = SignInTestClock() + let keyStore = InMemoryAPIKeyStore() + let sessions = SignInStore.inMemory() + + func account(configured: Bool = true) -> AssemblyAIAccount { + let clock = clock + return AssemblyAIAccount( + configuration: configured ? testOAuthConfiguration : nil, + apiKey: APIKeyModel(keyStore: keyStore) { _ in .valid }, + store: sessions, + transport: server.transport, + now: { clock.now }) + } + + func signedIn() async throws -> AssemblyAIAccount { + let account = account() + let auth = try #require(account.beginSignIn()) + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + #expect(await account.completeSignIn(auth, callback: callback) == .signedIn) + return account + } + + @Test("signing in stores the connection's key where a pasted one goes, plus the session") + func signIn() async throws { + let account = try await signedIn() + #expect(account.status == .signedIn(email: "member@example.com")) + #expect(keyStore.current == "connection-key") + let session = try #require(sessions.session) + #expect(session.refreshToken == "rt-1") + #expect(session.renewedAt == clock.now) + #expect(session.keyExpiresAt == Date(timeIntervalSince1970: 1_793_491_200)) + // A fresh model over the same stores reads the session back. + #expect(self.account().status == .signedIn(email: "member@example.com")) + } + + @Test("a decline is a quiet cancel; a bad redirect is a failure; neither stores anything") + func signInRefusedAtConsent() async throws { + keyStore.save("pasted") + let account = account() + let auth = try #require(account.beginSignIn()) + let declined = try #require( + URL(string: "com.assemblyai.blurt://oauth/callback?error=access_denied&state=\(auth.state)")) + #expect(await account.completeSignIn(auth, callback: declined) == .cancelled) + let forged = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=forged")) + #expect(await account.completeSignIn(auth, callback: forged) == .failed(.consent)) + #expect(keyStore.current == "pasted") + #expect(sessions.session == nil) + #expect(account.status == .signedOut) + } + + @Test("a refused exchange fails the sign-in and leaves a pasted key in place") + func signInExchangeRefused() async throws { + keyStore.save("pasted") + server.state.withLock { $0.exchangeStatus = 403 } + let account = account() + let auth = try #require(account.beginSignIn()) + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + #expect(await account.completeSignIn(auth, callback: callback) == .failed(.forbidden(nil))) + #expect(keyStore.current == "pasted") + #expect(sessions.session == nil) + } + + @Test("no refresh token (it couldn't be renewed) or an unreachable token endpoint fails the sign-in") + func signInTokenFailures() async throws { + let account = account() + server.state.withLock { $0.codeGrantRefreshToken = nil } + var auth = try #require(account.beginSignIn()) + var callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + #expect(await account.completeSignIn(auth, callback: callback) == .failed(.server)) + #expect(server.state.withLock { $0.exchanges } == 0) + + server.state.withLock { $0.tokenStatus = 503 } + auth = try #require(account.beginSignIn()) + callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + #expect(await account.completeSignIn(auth, callback: callback) == .failed(.server)) + #expect(sessions.session == nil) + } + + @Test("an unconfigured build offers no sign-in") + func unavailable() async throws { + let account = account(configured: false) + #expect(!account.isAvailable) + #expect(account.beginSignIn() == nil) + let auth = OAuthAuthorization(configuration: testOAuthConfiguration) + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + #expect(await account.completeSignIn(auth, callback: callback) == .failed(.unavailable)) + } + + @Test("renewal waits until the key nears expiry, then refreshes, keeps the rotated token and any new key") + func renewal() async throws { + let account = try await signedIn() + await account.renewIfDue() + #expect(server.state.withLock { $0.refreshTokensSeen }.isEmpty) + + advanceToRenewal() + server.state.withLock { $0.apiKey = "next-key" } + await account.renewIfDue() + #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1"]) + #expect(keyStore.current == "next-key") + let session = try #require(sessions.session) + #expect(session.refreshToken == "rt-2") + #expect(session.renewedAt == clock.now) + } + + @Test("a refresh that doesn't rotate keeps the token it has") + func renewalWithoutRotation() async throws { + let account = try await signedIn() + server.state.withLock { $0.rotatedRefreshToken = nil } + advanceToRenewal() + await account.renewIfDue() + #expect(sessions.session?.refreshToken == "rt-1") + } + + @Test("a refused refresh or exchange ends the connection here: key and session go") + func renewalRefused() async throws { + for refuseRefresh in [true, false] { + let server = SignInServer() + let keyStore = InMemoryAPIKeyStore() + let sessions = SignInStore.inMemory() + let clock = clock + let account = AssemblyAIAccount( + configuration: testOAuthConfiguration, apiKey: APIKeyModel(keyStore: keyStore) { _ in .valid }, + store: sessions, transport: server.transport, now: { clock.now }) + let auth = try #require(account.beginSignIn()) + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + _ = await account.completeSignIn(auth, callback: callback) + + server.state.withLock { state in + if refuseRefresh { state.tokenStatus = 400 } else { state.exchangeStatus = 401 } + } + keyStore.save(nil) // no key stored makes renewal due right away + await account.renewIfDue() + #expect(account.status == .signedOut) + #expect(sessions.session == nil) + #expect(keyStore.current == nil) + } + } + + @Test("an unreachable server during renewal changes nothing") + func renewalOffline() async throws { + let account = try await signedIn() + server.state.withLock { $0.exchangeStatus = 503 } + advanceToRenewal() + await account.renewIfDue() + #expect(account.status == .signedIn(email: "member@example.com")) + #expect(keyStore.current == "connection-key") + // The rotated refresh token was still kept: the old one is dead. + #expect(sessions.session?.refreshToken == "rt-2") + } + + @Test("sign-out ends the connection server-side, then forgets it here") + func signOut() async throws { + let account = try await signedIn() + #expect(await account.signOut() == nil) + #expect(server.state.withLock { $0.revokes } == 1) + #expect(account.status == .signedOut) + #expect(keyStore.current == nil) + #expect(sessions.session == nil) + } + + @Test("sign-out the server couldn't confirm keeps everything, so the user can retry") + func signOutUnconfirmed() async throws { + let account = try await signedIn() + server.state.withLock { $0.revokeStatus = 503 } + #expect(await account.signOut() == .inline(message: AssemblyAIAccount.signOutUnconfirmedMessage)) + #expect(account.status == .signedIn(email: "member@example.com")) + #expect(keyStore.current == "connection-key") + } + + @Test("sign-out with a refresh token the server already refuses is a sign-out") + func signOutAlreadyEnded() async throws { + let account = try await signedIn() + server.state.withLock { $0.tokenStatus = 400 } + #expect(await account.signOut() == nil) + #expect(server.state.withLock { $0.revokes } == 0) + #expect(account.status == .signedOut) + } + + @Test("sign-out with no session just clears; a build without sign-in still removes an inherited session") + func signOutEdges() async throws { + let fresh = account() + keyStore.save("pasted") + #expect(await fresh.signOut() == nil) + #expect(keyStore.current == nil) + + _ = try await signedIn() + let unconfigured = account(configured: false) + #expect(unconfigured.status == .signedIn(email: "member@example.com")) + #expect(await unconfigured.signOut() == nil) + #expect(unconfigured.status == .signedOut) + #expect(sessions.session == nil) + #expect(keyStore.current == nil) + #expect(server.state.withLock { $0.revokes } == 0) + } + + @Test("an unreadable session item is neither signed in nor signed out: sign-out refuses, nothing is deleted") + func unreadableSession() async { + keyStore.save("connection-key") + let locked = SignInStore(read: { .unavailable(errSecInteractionNotAllowed) }, write: { _ in true }) + let account = AssemblyAIAccount( + configuration: testOAuthConfiguration, apiKey: APIKeyModel(keyStore: keyStore) { _ in .valid }, + store: locked, transport: server.transport) + #expect(account.status == .signedOut) + #expect(await account.signOut() == .inline(message: AssemblyAIAccount.keychainUnreadableMessage)) + #expect(keyStore.current == "connection-key") + await account.renewIfDue() + #expect(server.state.withLock { $0.refreshTokensSeen }.isEmpty) + } + + @Test("refreshStatus follows the item, and leaves the status alone while it can't be read") + func refreshStatus() async throws { + let account = try await signedIn() + sessions.save(nil) + account.refreshStatus() + #expect(account.status == .signedOut) + } + + /// Moves the clock to the stored session's renewal date. + func advanceToRenewal() { + guard let due = sessions.session?.renewalDue else { return } + clock.advance(max(0, due.timeIntervalSince(clock.now))) + } +} + +extension SignInStore { + /// The stored session, or nil when there is none or it can't be read. + var session: SignInSession? { + if case .session(let session) = load { return session } + return nil + } +} diff --git a/Tests/BlurtEngineTests/DictationOriginTests.swift b/Tests/BlurtEngineTests/DictationOriginTests.swift new file mode 100644 index 00000000..c873461f --- /dev/null +++ b/Tests/BlurtEngineTests/DictationOriginTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Synchronization +import Testing + +@testable import AssemblyAI +@testable import BlurtEngine + +/// A signed-in key dictates against the host its sign-in configured; anything +/// else goes to production. +@Suite("Dictation origin") +struct DictationOriginTests { + private static let sandbox = URL(literal: "https://dictation.sandbox.example.com") + + /// Records the host of every request it answers. + private final class Hosts: Sendable { + let seen = Mutex<[String]>([]) + var transport: FakeHTTPTransport { + FakeHTTPTransport { [self] request in + seen.withLock { $0.append(request.url?.host() ?? "?") } + return (200, json(["text": "hi"])) + } + } + } + + @Test("the transcriber reads its host per request: the origin while it holds one, else the default") + func transcriberFollowsTheOrigin() async throws { + let hosts = Hosts() + let origin = DictationOrigin() + let transcriber = AssemblyAITranscriber( + apiKeyProvider: { "key" }, baseURL: { origin.current ?? AssemblyAITranscriber.defaultBaseURL }, + transport: hosts.transport, + enhancedTranscripts: { false }, customStyle: { nil }, rewriteMode: { .fast }) + + _ = try await collectTranscript(transcriber) + origin.set(Self.sandbox) + await transcriber.warmUp() + _ = try await collectTranscript(transcriber) + origin.set(nil) + _ = try await collectTranscript(transcriber) + + #expect( + hosts.seen.withLock { $0 } == [ + "dictation.assemblyai.com", "dictation.sandbox.example.com", "dictation.sandbox.example.com", + "dictation.assemblyai.com", + ]) + } + + @Test("the setting parses like the other sign-in URLs, and is optional") + func setting() { + let key = OAuthConfiguration.InfoKey.dictationOrigin + #expect( + OAuthConfiguration.blurtDictationOrigin(infoDictionary: [key: "https://dictation.sandbox.example.com"]) + == Self.sandbox) + #expect(OAuthConfiguration.blurtDictationOrigin(infoDictionary: [key: "http://localhost:8080"]) != nil) + for unset: Any in ["", " ", "http://dictation.example.com", 42] { + #expect(OAuthConfiguration.blurtDictationOrigin(infoDictionary: [key: unset]) == nil) + } + #expect(OAuthConfiguration.blurtDictationOrigin(infoDictionary: [:]) == nil) + } +} + +extension AssemblyAIAccountTests { + @Test("the account's dictation origin is its host while signed in, and nil once signed out") + func dictationOriginFollowsSignIn() async throws { + let host = URL(literal: "https://dictation.sandbox.example.com") + let clock = clock + func account() -> AssemblyAIAccount { + AssemblyAIAccount( + configuration: testOAuthConfiguration, dictationHost: host, + apiKey: APIKeyModel(keyStore: keyStore) { _ in .valid }, + store: sessions, transport: server.transport, now: { clock.now }) + } + let first = account() + #expect(first.dictationOrigin.current == nil) + + let auth = try #require(first.beginSignIn()) + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + #expect(await first.completeSignIn(auth, callback: callback) == .signedIn) + #expect(first.dictationOrigin.current == host) + + // A relaunch reads the stored session and starts on the sign-in's host. + #expect(account().dictationOrigin.current == host) + + #expect(await first.signOut() == nil) + #expect(first.dictationOrigin.current == nil) + } + + @Test("a sign-in that configures no dictation host leaves dictation on production") + func noDictationHost() async throws { + let account = try await signedIn() + #expect(account.status != .signedOut) + #expect(account.dictationOrigin.current == nil) + } +} diff --git a/Tests/BlurtEngineTests/KeyRejectionReporterTests.swift b/Tests/BlurtEngineTests/KeyRejectionReporterTests.swift new file mode 100644 index 00000000..e2abea13 --- /dev/null +++ b/Tests/BlurtEngineTests/KeyRejectionReporterTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Synchronization +import Testing + +@testable import BlurtEngine + +/// A dictation refused for its key is reported (so a signed-in account renews +/// at once) and still fails, with the error unchanged. +@Suite("Key rejection reporter") +struct KeyRejectionReporterTests { + /// Answers every dictation with `outcome`. + private struct Answering: TranscriberProtocol { + let outcome: Result + let warmUps: Counter + + func transcribe( + frames: AsyncStream, sampleRate: Int, context: TranscriptionContext? + ) async throws -> String { + try outcome.get() + } + + func warmUp() async { _ = warmUps.next() } + } + + private func run(_ outcome: Result) async -> (reported: Int, error: (any Error)?) { + let reports = Counter() + let reporter = KeyRejectionReporter(Answering(outcome: outcome, warmUps: Counter())) { _ = reports.next() } + do { + _ = try await reporter.transcribe(frames: AsyncStream { $0.finish() }, sampleRate: 16_000, context: nil) + return (reports.value, nil) + } catch { + return (reports.value, error) + } + } + + @Test("a 401 or the API's invalid-key 404 is reported and rethrown as is") + func rejected() async { + for status in [401, 404] { + let (reported, error) = await run(.failure(.http(status: status, message: "Invalid API key"))) + #expect(reported == 1, "status \(status)") + guard case .http(let thrown, let message)? = error as? AssemblyAIError else { + Issue.record("status \(status): \(String(describing: error))") + continue + } + #expect(thrown == status) + #expect(message == "Invalid API key") + } + } + + @Test("other failures and successes aren't reported") + func notRejected() async { + #expect(await run(.failure(.http(status: 500, message: nil))).reported == 0) + #expect(await run(.failure(.malformedResponse)).reported == 0) + let (reported, error) = await run(.success("hello")) + #expect(reported == 0) + #expect(error == nil) + } + + @Test("warm-up goes to the wrapped transcriber") + func warmUp() async { + let warmUps = Counter() + await KeyRejectionReporter(Answering(outcome: .success(""), warmUps: warmUps)) {}.warmUp() + #expect(warmUps.value == 1) + } +} diff --git a/Tests/BlurtEngineTests/OAuthClientTests.swift b/Tests/BlurtEngineTests/OAuthClientTests.swift new file mode 100644 index 00000000..89f4f849 --- /dev/null +++ b/Tests/BlurtEngineTests/OAuthClientTests.swift @@ -0,0 +1,244 @@ +import Foundation +import Testing + +@testable import AssemblyAI + +/// A configuration on placeholder hosts; nothing here reaches the network. +let testOAuthConfiguration = OAuthConfiguration( + clientID: "connected-app-test-client", + authorizeURL: URL(literal: "https://example.com/dashboard/oauth/authorize"), + tokenURL: URL(literal: "https://auth.example.com/v1/oauth2/token"), + apiKeyURL: URL(literal: "https://example.com/api/v2/oauth/api-key"), + revokeURL: URL(literal: "https://example.com/api/v2/oauth/api-key/revoke"), + redirectURI: URL(literal: "com.assemblyai.blurt://oauth/callback")) + +/// An unsigned JWT carrying `claims`, shaped like the ID token the token +/// endpoint returns. +func unsignedJWT(_ claims: [String: String]) -> String { + let payload = (try? JSONSerialization.data(withJSONObject: claims)) ?? Data() + return "eyJhbGciOiJub25lIn0.\(OAuthAuthorization.base64URL(Array(payload))).sig" +} + +@Suite("OAuth authorization (PKCE)") +struct OAuthAuthorizationTests { + /// RFC 7636 Appendix B: these 32 octets give this verifier and challenge. + static let rfcOctets: [UInt8] = [ + 116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173, 187, 186, + 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5, 88, 83, 132, 141, 121, + ] + + private func authorization() -> OAuthAuthorization { + OAuthAuthorization(configuration: testOAuthConfiguration) { count in Array(Self.rfcOctets.prefix(count)) } + } + + @Test("the verifier and S256 challenge match RFC 7636's worked example") + func rfcVector() { + let auth = authorization() + #expect(auth.codeVerifier == "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + #expect(auth.codeChallenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") + } + + @Test("the consent URL carries the client, redirect, scopes, state and challenge") + func consentURL() throws { + let auth = authorization() + let components = try #require(URLComponents(url: auth.url, resolvingAgainstBaseURL: false)) + let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) }) + #expect(components.path == "/dashboard/oauth/authorize") + #expect(query["client_id"] == "connected-app-test-client") + #expect(query["redirect_uri"] == "com.assemblyai.blurt://oauth/callback") + #expect(query["response_type"] == "code") + #expect(query["scope"] == "openid email offline_access aai:api") + #expect(query["state"] == auth.state) + #expect(query["code_challenge"] == auth.codeChallenge) + #expect(query["code_challenge_method"] == "S256") + } + + @Test("each attempt gets its own verifier and state") + func freshPerAttempt() { + let first = OAuthAuthorization(configuration: testOAuthConfiguration) + let second = OAuthAuthorization(configuration: testOAuthConfiguration) + #expect(first.codeVerifier != second.codeVerifier) + #expect(first.state != second.state) + #expect(first.codeVerifier.count == 43) + } + + @Test("a callback for this attempt yields its code") + func callbackCode() throws { + let auth = authorization() + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=abc&state=\(auth.state)")) + #expect(try auth.code(fromCallback: callback) == "abc") + } + + @Test("a callback is refused on the wrong state, a decline, an error, or no code") + func callbackRefusals() throws { + let auth = authorization() + let state = auth.state + let cases: [(String, OAuthAuthorization.CallbackError)] = [ + ("code=abc&state=other", .stateMismatch), + ("code=abc", .stateMismatch), + ("error=access_denied&state=\(state)", .denied), + ("error=server_error&error_description=boom&state=\(state)", .failed("boom")), + ("error=server_error&state=\(state)", .failed("server_error")), + ("state=\(state)", .failed(nil)), + ] + for (query, expected) in cases { + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?\(query)")) + #expect(throws: expected) { try auth.code(fromCallback: callback) } + } + } + + @Test("the callback scheme is the redirect's") + func callbackScheme() { + #expect(testOAuthConfiguration.callbackScheme == "com.assemblyai.blurt") + } +} + +@Suite("OAuth client") +struct OAuthClientTests { + private func client(_ transport: FakeHTTPTransport) -> OAuthClient { + OAuthClient(configuration: testOAuthConfiguration, transport: transport, userAgent: "Blurt/test") + } + + private static func form(_ request: URLRequest) -> [String: String] { + let body = String(bytes: request.httpBody ?? Data(), encoding: .utf8) ?? "" + var fields: [String: String] = [:] + for pair in body.split(separator: "&") { + let parts = pair.split(separator: "=", maxSplits: 1).map(String.init) + fields[parts[0]] = parts.count > 1 ? parts[1].removingPercentEncoding : "" + } + return fields + } + + @Test("the code exchange posts the code, verifier and redirect, and reads the tokens") + func codeExchange() async throws { + let auth = OAuthAuthorization(configuration: testOAuthConfiguration) + let idToken = unsignedJWT(["email": "member@example.com"]) + let transport = FakeHTTPTransport { request in + let form = Self.form(request) + guard request.url == testOAuthConfiguration.tokenURL, request.httpMethod == "POST", + request.value(forHTTPHeaderField: "Content-Type") == "application/x-www-form-urlencoded", + request.value(forHTTPHeaderField: "User-Agent") == "Blurt/test", + form["grant_type"] == "authorization_code", form["code"] == "the-code", + form["code_verifier"] == auth.codeVerifier, form["client_id"] == "connected-app-test-client", + form["redirect_uri"] == "com.assemblyai.blurt://oauth/callback" + else { return (400, Data()) } + return (200, json(["access_token": "at", "refresh_token": "rt", "id_token": idToken])) + } + let tokens = try await client(transport).tokens(for: auth, code: "the-code") + #expect(tokens == OAuthClient.Tokens(accessToken: "at", refreshToken: "rt", idToken: idToken)) + #expect(tokens.email == "member@example.com") + } + + @Test("a refresh posts the refresh token; a response without one keeps none") + func refresh() async throws { + let transport = FakeHTTPTransport { request in + let form = Self.form(request) + guard form["grant_type"] == "refresh_token", form["refresh_token"] == "r+t/=" + else { return (400, Data()) } + return (200, json(["access_token": "fresh"])) + } + let tokens = try await client(transport).refresh("r+t/=") + #expect(tokens == OAuthClient.Tokens(accessToken: "fresh", refreshToken: nil, idToken: nil)) + } + + @Test("the token endpoint's 400/401 is a refusal; 5xx, garbage and offline are unreachable") + func tokenFailures() async { + for (status, expected) in [(400, OAuthClient.Failure.rejected), (401, .rejected), (500, .unreachable)] { + let transport = FakeHTTPTransport { _ in (status, json(["error": "invalid_grant"])) } + await #expect(throws: expected) { try await client(transport).refresh("rt") } + } + let garbage = FakeHTTPTransport { _ in (200, Data("nope".utf8)) } + await #expect(throws: OAuthClient.Failure.unreachable) { try await client(garbage).refresh("rt") } + let offline = FakeHTTPTransport.failing(with: URLError(.notConnectedToInternet)) + await #expect(throws: OAuthClient.Failure.unreachable) { try await client(offline).refresh("rt") } + } + + @Test("the exchange sends the bearer token and reads the key and its expiry") + func exchange() async throws { + let transport = FakeHTTPTransport { request in + guard request.url == testOAuthConfiguration.apiKeyURL, request.httpMethod == "POST", + request.value(forHTTPHeaderField: "Authorization") == "Bearer at" + else { return (401, Data()) } + return (200, json(["api_key": "key-1", "api_key_expires_at": "2026-11-01T00:00:00Z"])) + } + let key = try await client(transport).apiKey(accessToken: "at") + #expect(key == OAuthClient.ConnectionKey(apiKey: "key-1", expiresAt: Date(timeIntervalSince1970: 1_793_491_200))) + #expect(key.rotatesAt == nil) + } + + @Test("the exchange reads when the key rotates, when the server says") + func exchangeRotatesAt() async throws { + let transport = FakeHTTPTransport { _ in + ( + 200, + json([ + "api_key": "key-1", "api_key_expires_at": "2026-11-01T00:00:00Z", + "api_key_rotates_at": "2026-10-30T00:00:00Z", + ]) + ) + } + let key = try await client(transport).apiKey(accessToken: "at") + #expect(key.rotatesAt == Date(timeIntervalSince1970: 1_793_491_200 - 2 * 86_400)) + } + + @Test("an expiry with fractional seconds parses; a missing or odd one is nil") + func expiryParsing() { + #expect(OAuthClient.date(from: "2026-11-01T00:00:00.500Z") == Date(timeIntervalSince1970: 1_793_491_200.5)) + #expect(OAuthClient.date(from: "soon") == nil) + } + + @Test("the exchange's 401 is a refusal, 403 carries its reason, anything else is unreachable") + func exchangeFailures() async { + let unauthorized = FakeHTTPTransport { _ in (401, Data()) } + await #expect(throws: OAuthClient.Failure.rejected) { try await client(unauthorized).apiKey(accessToken: "at") } + + let scope = FakeHTTPTransport { _ in + (403, json(["error": "insufficient_scope", "error_description": "missing aai:api"])) + } + await #expect(throws: OAuthClient.Failure.forbidden("missing aai:api")) { + try await client(scope).apiKey(accessToken: "at") + } + + let detail = FakeHTTPTransport { _ in (403, json(["detail": "Key disabled"])) } + await #expect(throws: OAuthClient.Failure.forbidden("Key disabled")) { + try await client(detail).apiKey(accessToken: "at") + } + + let bare = FakeHTTPTransport { _ in (403, Data()) } + await #expect(throws: OAuthClient.Failure.forbidden(nil)) { try await client(bare).apiKey(accessToken: "at") } + + let blankKey = FakeHTTPTransport { _ in (200, json(["api_key": " "])) } + await #expect(throws: OAuthClient.Failure.unreachable) { try await client(blankKey).apiKey(accessToken: "at") } + + let down = FakeHTTPTransport { _ in (503, Data()) } + await #expect(throws: OAuthClient.Failure.unreachable) { try await client(down).apiKey(accessToken: "at") } + } + + @Test("revoke succeeds on 2xx; 401 is a refusal; 503 (grant revoke failed) is unreachable") + func revoke() async throws { + let ok = FakeHTTPTransport { request in + guard request.url == testOAuthConfiguration.revokeURL, request.httpMethod == "POST", + request.value(forHTTPHeaderField: "Authorization") == "Bearer at" + else { return (404, Data()) } + return (204, Data()) + } + try await client(ok).revoke(accessToken: "at") + + let unauthorized = FakeHTTPTransport { _ in (401, Data()) } + await #expect(throws: OAuthClient.Failure.rejected) { try await client(unauthorized).revoke(accessToken: "at") } + let down = FakeHTTPTransport { _ in (503, Data()) } + await #expect(throws: OAuthClient.Failure.unreachable) { try await client(down).revoke(accessToken: "at") } + } + + @Test("the ID token's email is read when present, nil otherwise") + func email() { + func tokens(_ idToken: String?) -> OAuthClient.Tokens { + OAuthClient.Tokens(accessToken: "at", refreshToken: nil, idToken: idToken) + } + #expect(tokens(unsignedJWT(["email": "a@b.co"])).email == "a@b.co") + #expect(tokens(unsignedJWT(["sub": "member"])).email == nil) + #expect(tokens("not-a-jwt").email == nil) + #expect(tokens("a.%%%.c").email == nil) + #expect(tokens(nil).email == nil) + } +} diff --git a/Tests/BlurtEngineTests/RenewalScheduleTests.swift b/Tests/BlurtEngineTests/RenewalScheduleTests.swift new file mode 100644 index 00000000..526a9ffd --- /dev/null +++ b/Tests/BlurtEngineTests/RenewalScheduleTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Synchronization +import Testing + +@testable import AssemblyAI +@testable import BlurtEngine + +// When the account renews: on the server's rotation time, sleeping until then, +// at once when dictation refuses the key, and never in a loop. +extension AssemblyAIAccountTests { + @Test("runRenewal sleeps until the renewal is due, renews, and stops when cancelled") + func runRenewal() async throws { + let account = try await signedIn() + let testClock = TestClock() + let loop = Task { await account.runRenewal(clock: testClock) } + advanceToRenewal() + // The first check ran before the date moved to the renewal, or after: + // either way, advancing virtual time past the longest sleep drives one more. + for _ in 0..<50 where server.state.withLock({ $0.refreshTokensSeen }).isEmpty { + testClock.advance(by: .seconds(AssemblyAIAccount.longestRenewalSleep)) + await Task.yield() + } + #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1"]) + loop.cancel() + await loop.value + } + + @Test("the loop sleeps until the renewal is due, within the shortest and longest sleep") + func nextRenewalCheck() async throws { + let signedOut = account() + #expect(signedOut.nextRenewalCheck() == AssemblyAIAccount.longestRenewalSleep) + + let account = try await signedIn() + // The fake key expires weeks away: the longest sleep, then look again. + #expect(account.nextRenewalCheck() == AssemblyAIAccount.longestRenewalSleep) + let due = try #require(sessions.session).renewalDue + clock.advance(due.timeIntervalSince(clock.now) - 3_600) + #expect(account.nextRenewalCheck() == 3_600) + clock.advance(7_200) + #expect(account.nextRenewalCheck() == AssemblyAIAccount.shortestRenewalSleep) + } + + @Test("a rejected key renews at once, at most once per spacing, and only for a sign-in") + func keyWasRejected() async throws { + keyStore.save("pasted") + let pasted = account() + await pasted.keyWasRejected() + #expect(server.state.withLock { $0.refreshTokensSeen }.isEmpty) + + let account = try await signedIn() + await account.keyWasRejected() + #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1"]) + await account.keyWasRejected() + #expect(server.state.withLock { $0.refreshTokensSeen }.count == 1) + clock.advance(AssemblyAIAccount.rejectedKeyRenewalSpacing) + await account.keyWasRejected() + #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1", "rt-2"]) + } + + @Test("a rejected key whose connection was ended signs the account out") + func keyWasRejectedEndsTheConnection() async throws { + let account = try await signedIn() + server.state.withLock { $0.exchangeStatus = 403 } + await account.keyWasRejected() + #expect(account.status == .signedOut) + #expect(keyStore.current == nil) + } + + @Test("renewal is scheduled for when the server says the key rotates") + func schedulesFromRotatesAt() async throws { + server.state.withLock { $0.rotatesAt = "2026-10-25T00:00:00Z" } + _ = try await signedIn() + #expect(sessions.session?.renewalDue == Date(timeIntervalSince1970: 1_793_491_200 - 7 * 86_400)) + } + + @Test("a renewal that brings back the same key waits for its expiry, not a minute") + func loopGuard() async throws { + // The fake server never rotates early and doesn't say when it rotates. + let account = try await signedIn() + let expiry = Date(timeIntervalSince1970: 1_793_491_200) + advanceToRenewal() + await account.renewIfDue() + #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1"]) + #expect(sessions.session?.renewalDue == expiry) + #expect(account.nextRenewalCheck() == AssemblyAIAccount.longestRenewalSleep) + + // At expiry the same key again (a server that won't rotate at all): a day out. + clock.advance(expiry.timeIntervalSince(clock.now)) + await account.renewIfDue() + #expect(sessions.session?.renewalDue == clock.now.addingTimeInterval(SignInSession.unknownExpiryInterval)) + } +} diff --git a/Tests/BlurtEngineTests/SignInSessionTests.swift b/Tests/BlurtEngineTests/SignInSessionTests.swift new file mode 100644 index 00000000..7a30e552 --- /dev/null +++ b/Tests/BlurtEngineTests/SignInSessionTests.swift @@ -0,0 +1,242 @@ +import Foundation +import Security +import Synchronization +import Testing + +@testable import AssemblyAI +@testable import BlurtEngine + +/// Parks token-endpoint requests on `gate` while `armed`, to hold a renewal +/// mid-flight; everything else goes straight to `inner`. +private final class GatedTransport: HTTPTransport, Sendable { + let inner: FakeHTTPTransport + let gate: Gate + let armed = Mutex(false) + + init(inner: FakeHTTPTransport, gate: Gate) { + self.inner = inner + self.gate = gate + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let hold = armed.withLock { armed in + defer { armed = false } + return armed && request.url == testOAuthConfiguration.tokenURL + } + if hold { await gate.enter() } + return try await inner.data(for: request) + } + + func upload( + for request: URLRequest, streaming body: AsyncThrowingStream, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> (Data, URLResponse) { + try await inner.upload(for: request, streaming: body, delegate: delegate) + } +} + +// The renewal races: a sign-out or a forget landing while a renewal is parked +// on the network must win. +extension AssemblyAIAccountTests { + @Test("a sign-out during a renewal wins: the renewal wakes and writes nothing back") + func signOutDuringRenewal() async throws { + let parked = try await renewalParkedOnTheNetwork() + let (account, gate, renewal) = (parked.account, parked.gate, parked.renewal) + let signOut = Task { await account.signOut() } + gate.allowToFinish() + await renewal.value + #expect(await signOut.value == nil) + + #expect(account.status == .signedOut) + #expect(sessions.session == nil) + #expect(keyStore.current == nil) + // Sign-out waited for the renewal and refreshed with the rotated token. + #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1", "rt-2"]) + } + + /// A signed-in account whose renewal is parked at the token endpoint until + /// the test releases `gate`. + private struct ParkedRenewal { + let account: AssemblyAIAccount + let gate: Gate + let renewal: Task + } + + private func renewalParkedOnTheNetwork() async throws -> ParkedRenewal { + let gate = Gate() + let gated = GatedTransport(inner: server.transport, gate: gate) + let clock = clock + let account = AssemblyAIAccount( + configuration: testOAuthConfiguration, apiKey: APIKeyModel(keyStore: keyStore) { _ in .valid }, + store: sessions, transport: gated, now: { clock.now }) + let auth = try #require(account.beginSignIn()) + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + #expect(await account.completeSignIn(auth, callback: callback) == .signedIn) + + advanceToRenewal() + gated.armed.withLock { $0 = true } + let renewal = Task { await account.renewIfDue() } + await gate.waitUntilEntered() + return ParkedRenewal(account: account, gate: gate, renewal: renewal) + } + + @Test("a forget during a renewal (the reset, an ended connection) also wins") + func forgetDuringRenewal() async throws { + let parked = try await renewalParkedOnTheNetwork() + let (account, gate, renewal) = (parked.account, parked.gate, parked.renewal) + account.forget() + gate.allowToFinish() + await renewal.value + #expect(account.status == .signedOut) + #expect(sessions.session == nil) + } +} + +@Suite("Sign-in session") +struct SignInSessionTests { + private let start = Date(timeIntervalSince1970: 1_790_000_000) + + @Test("renewal is due two days before the key expires, or with no key stored, and not daily") + func renewalDue() { + let expires = start.addingTimeInterval(14 * 86_400) + let session = SignInSession(refreshToken: "rt", email: nil, keyExpiresAt: expires, renewedAt: start) + #expect(session.renewalDue == expires.addingTimeInterval(-SignInSession.expiryMargin)) + #expect(!session.isRenewalDue(now: start.addingTimeInterval(86_400), hasKey: true)) + #expect(!session.isRenewalDue(now: start.addingTimeInterval(11 * 86_400), hasKey: true)) + #expect(session.isRenewalDue(now: start.addingTimeInterval(12 * 86_400), hasKey: true)) + #expect(session.isRenewalDue(now: start.addingTimeInterval(3_600), hasKey: false)) + } + + @Test("the server's rotation time wins over the expiry margin") + func rotatesAtWins() { + let rotates = start.addingTimeInterval(10 * 86_400) + let session = SignInSession( + refreshToken: "rt", email: nil, keyExpiresAt: start.addingTimeInterval(14 * 86_400), + keyRotatesAt: rotates, renewedAt: start) + #expect(session.renewalDue == rotates) + } + + @Test("a session saved before keyRotatesAt existed still reads") + func oldSessionDecodes() throws { + let json = #"{"refreshToken":"rt","keyExpiresAt":"2026-11-01T00:00:00Z","renewedAt":"2026-10-01T00:00:00Z"}"# + let store = SignInStore(read: { .value(json) }, write: { _ in true }) + let session = try #require(store.session) + #expect(session.keyRotatesAt == nil) + #expect(session.renewalDue == Date(timeIntervalSince1970: 1_793_491_200 - SignInSession.expiryMargin)) + } + + @Test("an exchange that didn't say when the key expires renews a day after the last renewal") + func unknownExpiry() { + let session = SignInSession(refreshToken: "rt", email: nil, keyExpiresAt: nil, renewedAt: start) + #expect(session.renewalDue == start.addingTimeInterval(SignInSession.unknownExpiryInterval)) + } + + @Test("the store round-trips a session and deletes on nil; unreadable JSON reads as none") + func store() { + let store = SignInStore.inMemory() + let session = SignInSession(refreshToken: "rt", email: "a@b.co", keyExpiresAt: start, renewedAt: start) + #expect(store.save(session)) + #expect(store.session == session) + #expect(store.save(nil)) + #expect(store.session == nil) + + let garbage = SignInStore(read: { .value("{not json") }, write: { _ in true }) + #expect(garbage.load == .none) + let locked = SignInStore(read: { .unavailable(errSecInteractionNotAllowed) }, write: { _ in true }) + #expect(locked.load == .unavailable) + } + + @Test("the account row names the member when the ID token did") + func statusText() { + #expect(AssemblyAIAccount.Status.signedIn(email: "a@b.co").statusText == "Signed in as a@b.co") + #expect(AssemblyAIAccount.Status.signedIn(email: nil).statusText == "Signed in") + #expect(AssemblyAIAccount.Status.signedOut.statusText == nil) + } + + @Test("failure wording: the server's reason when given, a generic line otherwise") + func failureWording() { + #expect(SignInFailure.forbidden("missing aai:api").message.contains("missing aai:api")) + #expect(SignInFailure.forbidden(nil).message.contains("admin")) + #expect( + SignInFailure.keychain.failureReport + == .alert(title: "Couldn’t Save Your Sign-In", message: SignInFailure.keychain.message)) + for failure in [SignInFailure.unavailable, .consent, .rejected, .server, .forbidden(nil)] { + #expect(failure.failureReport == .inline(message: failure.message)) + #expect(!failure.message.isEmpty) + } + } +} + +@Suite("OAuth configuration from Info.plist") +struct OAuthConfigurationInfoTests { + private let info: [String: Any] = [ + OAuthConfiguration.InfoKey.clientID: "client", + OAuthConfiguration.InfoKey.tokenURL: "https://auth.example.com/v1/oauth2/token", + OAuthConfiguration.InfoKey.authorizeURL: "https://dashboard.example.com/dashboard/oauth/authorize", + OAuthConfiguration.InfoKey.apiOrigin: "https://www.example.com", + ] + + @Test("all four keys set: the consent page as given, the key routes under the API origin") + func configured() throws { + let config = try #require(OAuthConfiguration.blurt(infoDictionary: info)) + #expect(config.clientID == "client") + #expect(config.authorizeURL.absoluteString == "https://dashboard.example.com/dashboard/oauth/authorize") + #expect(config.apiKeyURL.absoluteString == "https://www.example.com/api/v2/oauth/api-key") + #expect(config.revokeURL.absoluteString == "https://www.example.com/api/v2/oauth/api-key/revoke") + #expect(config.redirectURI.absoluteString == "com.assemblyai.blurt://oauth/callback") + } + + @Test("a missing, blank or non-https value turns sign-in off") + func unconfigured() { + #expect(OAuthConfiguration.blurt(infoDictionary: [:]) == nil) + for key in info.keys { + var blank = info + blank[key] = " " + #expect(OAuthConfiguration.blurt(infoDictionary: blank) == nil) + } + for key in info.keys where key != OAuthConfiguration.InfoKey.clientID { + var http = info + http[key] = "http://www.example.com" + #expect(OAuthConfiguration.blurt(infoDictionary: http) == nil) + } + } + + @Test("plain http is allowed to this machine only, for a dev build on a local dashboard or site") + func loopback() { + for host in ["localhost:3000", "127.0.0.1:3000", "[::1]:3000"] { + var local = info + local[OAuthConfiguration.InfoKey.authorizeURL] = "http://\(host)/dashboard/oauth/authorize" + #expect(OAuthConfiguration.blurt(infoDictionary: local) != nil, "\(host)") + } + var lookalike = info + lookalike[OAuthConfiguration.InfoKey.apiOrigin] = "http://localhost.example.com" + #expect(OAuthConfiguration.blurt(infoDictionary: lookalike) == nil) + } +} + +@Suite("Credential rows") +struct CredentialRowsTests { + @Test("signed out with sign-in configured: account row first and filled, key row below") + func signedOutWithSignIn() { + let rows = CredentialRows.resolve(isKeyStored: false, isSignInAvailable: true, status: .signedOut) + #expect(rows == CredentialRows(showsAccountRow: true, showsKeyRow: true, primary: .signIn)) + } + + @Test("no sign-in configured: just the key row, Connect filled until a key is stored") + func withoutSignIn() { + #expect( + CredentialRows.resolve(isKeyStored: false, isSignInAvailable: false, status: .signedOut) + == CredentialRows(showsAccountRow: false, showsKeyRow: true, primary: .connect)) + #expect( + CredentialRows.resolve(isKeyStored: true, isSignInAvailable: false, status: .signedOut).primary == nil) + } + + @Test("signed in: the account row replaces the key row, even where sign-in isn't configured") + func signedIn() { + for available in [true, false] { + #expect( + CredentialRows.resolve(isKeyStored: true, isSignInAvailable: available, status: .signedIn(email: nil)) + == CredentialRows(showsAccountRow: true, showsKeyRow: false, primary: nil)) + } + } +}