diff --git a/AGENTS.md b/AGENTS.md index 30f3a422..3adf8d2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1142,6 +1142,36 @@ 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) calls `renewIfDue()` hourly, which refreshes and re-exchanges at most daily or within two days +of the key's expiry, keeping 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_*` in `App/Blurt/project.yml`, 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 `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..7711bd1e 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 */; }; @@ -860,6 +862,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 +872,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 = ""; }; @@ -1486,6 +1490,8 @@ F61744A7BF2B24E0C8078DE6 /* Steps */ = { isa = PBXGroup; children = ( + E75844EDF6969530EFB56CF4 /* AccountRowView.swift */, + EAC096B44D037E7BB119609A /* AccountSignIn.swift */, 3DA3A304528FD1C7FE6C2B94 /* APIKeyStepView.swift */, 50FFCDBD1FB2CF0A81A68D18 /* HotkeyStepView.swift */, C59092F9220BBE7465068325 /* KeyTermsStepView.swift */, @@ -2034,6 +2040,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 */, @@ -2255,6 +2263,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BLURT_APP_NAME = Blurt; + BLURT_OAUTH_API_ORIGIN = ""; + BLURT_OAUTH_AUTHORIZE_URL = ""; + BLURT_OAUTH_CLIENT_ID = ""; + BLURT_OAUTH_DICTATION_ORIGIN = ""; + BLURT_OAUTH_TOKEN_URL = ""; CODE_SIGN_ENTITLEMENTS = Blurt/Blurt.entitlements; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Blurt/Info.plist; @@ -2369,6 +2382,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BLURT_APP_NAME = "Blurt Dev"; + BLURT_OAUTH_API_ORIGIN = "https://explore-git-staging-assemblyai.vercel.app"; + BLURT_OAUTH_AUTHORIZE_URL = "https://dashboard-assemblyai.vercel.app/dashboard/oauth/authorize"; + BLURT_OAUTH_CLIENT_ID = "connected-app-test-2030e449-137e-47bc-a09c-2edbf6aa86ff"; + BLURT_OAUTH_DICTATION_ORIGIN = "https://dictation.sandbox000.assemblyai-labs.com"; + BLURT_OAUTH_TOKEN_URL = "https://snapdragon-coyote-7054.customers.stytch.dev/v1/oauth2/token"; CODE_SIGN_ENTITLEMENTS = Blurt/Blurt.entitlements; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Blurt/Info.plist; @@ -2389,6 +2407,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BLURT_APP_NAME = "Blurt Dev"; + BLURT_OAUTH_API_ORIGIN = "https://explore-git-staging-assemblyai.vercel.app"; + BLURT_OAUTH_AUTHORIZE_URL = "https://dashboard-assemblyai.vercel.app/dashboard/oauth/authorize"; + BLURT_OAUTH_CLIENT_ID = "connected-app-test-2030e449-137e-47bc-a09c-2edbf6aa86ff"; + BLURT_OAUTH_DICTATION_ORIGIN = "https://dictation.sandbox000.assemblyai-labs.com"; + BLURT_OAUTH_TOKEN_URL = "https://snapdragon-coyote-7054.customers.stytch.dev/v1/oauth2/token"; CODE_SIGN_ENTITLEMENTS = Blurt/Blurt.entitlements; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Blurt/Info.plist; diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index aa323d39..e260024e 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,29 @@ 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) // 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..ab9d64c9 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 at most once a day; the + // cadence and the rule are 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..2d1da77c 100644 --- a/App/Blurt/Blurt/DictationComposition.swift +++ b/App/Blurt/Blurt/DictationComposition.swift @@ -15,10 +15,12 @@ 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. + static func production(dictationOrigin: DictationOrigin? = nil) -> DictationComponents { DictationComponents( mic: MicCapture(), - transcriber: AssemblyAITranscriber(), + transcriber: AssemblyAITranscriber(origin: dictationOrigin), 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/project.yml b/App/Blurt/project.yml index 2f30cb2d..41fcbc60 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -120,6 +120,28 @@ targets: # isolation, and genuinely off-main app code (the CGEventTap C callback, # the UI-test pipeline stubs) opts out with `nonisolated`. SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor + # "Sign in with AssemblyAI" (`AssemblyAIAccount`), baked into Info.plist + # (see `info:` below). Any one left empty turns sign-in off and the setup + # screen offers only the paste-a-key path, which is where every + # configuration stays until its Connected App is registered: the Debug + # ones point at the Stytch test project and the staging deployments, Release at + # the live project and www.assemblyai.com. All four are public: the + # client is a public PKCE client with no secret. + # CLIENT_ID the Connected App's client id + # TOKEN_URL the Stytch project's token endpoint (…/v1/oauth2/token) + # AUTHORIZE_URL the dashboard's consent page (…/dashboard/oauth/authorize) + # API_ORIGIN the web origin serving /api/v2/oauth/api-key[/revoke]; + # the consent page's host in production, a separate + # deployment on staging + # DICTATION_ORIGIN (optional) the dictation host the signed-in key + # belongs to: a sandbox key only works against its + # sandbox's host. Empty means production. A pasted key + # always dictates against production. + BLURT_OAUTH_CLIENT_ID: "" + BLURT_OAUTH_TOKEN_URL: "" + BLURT_OAUTH_AUTHORIZE_URL: "" + BLURT_OAUTH_API_ORIGIN: "" + BLURT_OAUTH_DICTATION_ORIGIN: "" configs: # The XCUITest harness + leak/hotkey test seams compile only when # UITEST_HOOKS is set (see the `#if UITEST_HOOKS` blocks). It's on for the @@ -128,10 +150,24 @@ targets: # for a clean local install build with the test scaffolding excluded. # (DEBUG is listed explicitly since setting this key overrides the value # XcodeGen would otherwise derive for a debug config.) + # Dev builds sign in against the Stytch test project and the staging + # dashboard and site (whose /api routes proxy to the sandbox AMS). Release keeps the + # empty base values, so sign-in stays off there until a production + # Connected App exists. Debug: SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG UITEST_HOOKS + BLURT_OAUTH_CLIENT_ID: connected-app-test-2030e449-137e-47bc-a09c-2edbf6aa86ff + BLURT_OAUTH_TOKEN_URL: https://snapdragon-coyote-7054.customers.stytch.dev/v1/oauth2/token + BLURT_OAUTH_AUTHORIZE_URL: https://dashboard-assemblyai.vercel.app/dashboard/oauth/authorize + BLURT_OAUTH_API_ORIGIN: https://explore-git-staging-assemblyai.vercel.app + BLURT_OAUTH_DICTATION_ORIGIN: https://dictation.sandbox000.assemblyai-labs.com Debug-Local: SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + BLURT_OAUTH_CLIENT_ID: connected-app-test-2030e449-137e-47bc-a09c-2edbf6aa86ff + BLURT_OAUTH_TOKEN_URL: https://snapdragon-coyote-7054.customers.stytch.dev/v1/oauth2/token + BLURT_OAUTH_AUTHORIZE_URL: https://dashboard-assemblyai.vercel.app/dashboard/oauth/authorize + BLURT_OAUTH_API_ORIGIN: https://explore-git-staging-assemblyai.vercel.app + BLURT_OAUTH_DICTATION_ORIGIN: https://dictation.sandbox000.assemblyai-labs.com # The shipping identity, opted into by the one configuration that ships. Release: PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt @@ -158,6 +194,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; empty values turn it off (see the + # BLURT_OAUTH_* settings 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..ae83b22f --- /dev/null +++ b/Sources/AssemblyAI/OAuth/OAuthClient.swift @@ -0,0 +1,245 @@ +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? + + public init(apiKey: String, expiresAt: Date?) { + self.apiKey = apiKey + self.expiresAt = expiresAt + } + } + + 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:))) + 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? + + enum CodingKeys: String, CodingKey { + case apiKey = "api_key" + case apiKeyExpiresAt = "api_key_expires_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..e7186904 --- /dev/null +++ b/Sources/BlurtEngine/Account/AssemblyAIAccount.swift @@ -0,0 +1,318 @@ +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") + + /// How often the running app asks `renewIfDue()`. Blurt runs for days from + /// the trigger key without coming forward, so launch alone isn't enough; a + /// check that isn't due costs a Keychain read. + public static let renewalCheckInterval: Duration = .seconds(60 * 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? + /// 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, 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)) + } + } + + /// Asks `renewIfDue()` now and then every `interval` until cancelled. The + /// shell starts it once at launch. + public func runRenewal>( + every interval: Duration = AssemblyAIAccount.renewalCheckInterval, clock: C = ContinuousClock() + ) async { + while !Task.isCancelled { + await renewIfDue() + do { try await clock.sleep(for: interval) } catch { return } + } + } + + /// 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 { + if let renewal { return await renewal.value } + guard case .session(let session) = store.load, + 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.renewedAt = now() + 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..a7bd5f06 --- /dev/null +++ b/Sources/BlurtEngine/Account/DictationOrigin.swift @@ -0,0 +1,24 @@ +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 transcriber +/// 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/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..8e9539e2 --- /dev/null +++ b/Sources/BlurtEngine/Account/SignInSession.swift @@ -0,0 +1,115 @@ +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? + /// The last successful refresh and exchange. + public var renewedAt: Date + + public init(refreshToken: String, email: String?, keyExpiresAt: Date?, renewedAt: Date) { + self.refreshToken = refreshToken + self.email = email + self.keyExpiresAt = keyExpiresAt + self.renewedAt = renewedAt + } + + /// At most one renewal a day. Each one also slides the refresh token's + /// lifetime, so a Mac that opens Blurt now and then stays signed in. + public static let renewalInterval: TimeInterval = 24 * 60 * 60 + /// Renew this far ahead of the key's expiry, so the next key is in place + /// before the old one stops working mid-dictation. + public static let expiryMargin: TimeInterval = 2 * 24 * 60 * 60 + + /// Whether a renewal check should refresh and exchange now: a day since the last renewal, the key close to expiring, or no key + /// stored at all. + public func isRenewalDue(now: Date, hasKey: Bool) -> Bool { + if !hasKey { return true } + if now.timeIntervalSince(renewedAt) >= Self.renewalInterval { return true } + if let keyExpiresAt, keyExpiresAt.timeIntervalSince(now) <= Self.expiryMargin { return true } + return false + } +} + +/// 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/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index 902c86c7..4a2b4e0f 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -44,10 +44,9 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// writes the same category — one category, two files. static let log = HostIdentity.current.logger("Transcriber") 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 + /// Per request: a signed-in key's host (`DictationOrigin`) can change. 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? @@ -86,13 +85,14 @@ public struct AssemblyAITranscriber: TranscriberProtocol { public init( apiKeyProvider: @escaping @Sendable () -> String? = { APIKeyStore.current }, baseURL: URL = URL(staticString: "https://dictation.assemblyai.com"), + origin: DictationOrigin? = nil, 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 = { origin?.current ?? 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..26534963 --- /dev/null +++ b/Tests/BlurtEngineTests/AssemblyAIAccountTests.swift @@ -0,0 +1,311 @@ +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 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()) } + return (200, json(["api_key": state.apiKey, "api_key_expires_at": state.expiresAt])) + 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() + + private 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 a day, 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) + + clock.advance(SignInSession.renewalInterval) + 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 } + clock.advance(SignInSession.renewalInterval) + 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 } + clock.advance(SignInSession.renewalInterval) + 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) + } + + @Test("runRenewal checks now, then again after each interval, and stops when cancelled") + func runRenewal() async throws { + let account = try await signedIn() + let testClock = TestClock() + let loop = Task { await account.runRenewal(every: .seconds(10), clock: testClock) } + clock.advance(SignInSession.renewalInterval) + // The first check ran before the clock moved past the interval, or after: + // either way, advancing virtual time drives one more. + for _ in 0..<50 where server.state.withLock({ $0.refreshTokensSeen }).isEmpty { + testClock.advance(by: .seconds(10)) + await Task.yield() + } + #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1"]) + loop.cancel() + await loop.value + } +} + +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..c4fb252e --- /dev/null +++ b/Tests/BlurtEngineTests/DictationOriginTests.swift @@ -0,0 +1,93 @@ +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 dictates and warms up against the origin while it holds one") + func transcriberFollowsTheOrigin() async throws { + let hosts = Hosts() + let origin = DictationOrigin() + let transcriber = AssemblyAITranscriber( + apiKeyProvider: { "key" }, origin: origin, 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/OAuthClientTests.swift b/Tests/BlurtEngineTests/OAuthClientTests.swift new file mode 100644 index 00000000..45e3aa28 --- /dev/null +++ b/Tests/BlurtEngineTests/OAuthClientTests.swift @@ -0,0 +1,228 @@ +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))) + } + + @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/SignInSessionTests.swift b/Tests/BlurtEngineTests/SignInSessionTests.swift new file mode 100644 index 00000000..bb03fd78 --- /dev/null +++ b/Tests/BlurtEngineTests/SignInSessionTests.swift @@ -0,0 +1,218 @@ +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 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) + + clock.advance(SignInSession.renewalInterval) + gated.armed.withLock { $0 = true } + let renewal = Task { await account.renewIfDue() } + await gate.waitUntilEntered() + 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"]) + } + + @Test("a forget during a renewal (the reset, an ended connection) also wins") + func forgetDuringRenewal() async throws { + 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)")) + _ = await account.completeSignIn(auth, callback: callback) + + clock.advance(SignInSession.renewalInterval) + gated.armed.withLock { $0 = true } + let renewal = Task { await account.renewIfDue() } + await gate.waitUntilEntered() + 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 after a day, near the key's expiry, or with no key stored") + func renewalDue() { + let session = SignInSession( + refreshToken: "rt", email: nil, keyExpiresAt: start.addingTimeInterval(30 * 86_400), renewedAt: start) + #expect(!session.isRenewalDue(now: start.addingTimeInterval(3_600), hasKey: true)) + #expect(session.isRenewalDue(now: start.addingTimeInterval(3_600), hasKey: false)) + #expect(session.isRenewalDue(now: start.addingTimeInterval(SignInSession.renewalInterval), hasKey: true)) + + let expiring = SignInSession( + refreshToken: "rt", email: nil, keyExpiresAt: start.addingTimeInterval(86_400), renewedAt: start) + #expect(expiring.isRenewalDue(now: start.addingTimeInterval(60), hasKey: true)) + } + + @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)) + } + } +}