import AuthenticationServices import CryptoKit import Foundation import Security import SwiftUI /// The session token, in the keychain. Read by the client on any thread. enum SessionStore { private static var query: [String: Any] { [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "dev.knotless.app", kSecAttrAccount as String: "session", ] } static var token: String? { var ask = query ask[kSecReturnData as String] = true var found: CFTypeRef? guard SecItemCopyMatching(ask as CFDictionary, &found) == errSecSuccess, let data = found as? Data else { return nil } return String(data: data, encoding: .utf8) } static func save(_ token: String) { clear() var item = query item[kSecValueData as String] = Data(token.utf8) item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly SecItemAdd(item as CFDictionary, nil) } static func clear() { SecItemDelete(query as CFDictionary) } } /// A PKCE pair: the verifier stays here, and its challenge goes to the browser. struct PKCE { let verifier: String let challenge: String init() { var bytes = [UInt8](repeating: 0, count: 32) _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) verifier = Self.encoded(Data(bytes)) challenge = Self.encoded(Data(SHA256.hash(data: Data(verifier.utf8)))) } private static func encoded(_ data: Data) -> String { data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } } /// Who the app is signed in as, for the screens to watch. @MainActor @Observable final class Account { private(set) var person: Person? private(set) var checking = true /// The session asked for less than the app asks for now, so a write it /// wasn't granted would fail: signing in again grants the rest. private(set) var reauth = false var failure: String? /// Moved by following or unfollowing, so every profile is read again: the /// one followed, for its followers, and your own, for who you follow. var follows = 0 private var web: ASWebAuthenticationSession? private let anchor = BrowserAnchor() var signedIn: Bool { person != nil } /// Finds who the kept session names, or that it no longer holds. func restore() async { defer { checking = false } guard SessionStore.token != nil || DevAccount.signedIn else { return } await loadViewer() } private func loadViewer() async { do { let viewer: Viewer = try await Client.shared.query("getViewer") hold(viewer) } catch { // Offline isn't signed out: the token stays for next time. failure = error.localizedDescription } } private func hold(_ viewer: Viewer) { person = viewer.actor reauth = viewer.reauth == true if person == nil { SessionStore.clear() } } /// Reads the session again as the app comes to the front, for what changed /// while it was away. Nothing to say if that fails, and nothing to do if the /// account signed in or out meanwhile, as this answer is of the session before. func refresh() async { let asked = SessionStore.token guard !checking, person != nil else { return } guard let viewer: Viewer = try? await Client.shared.query("getViewer"), SessionStore.token == asked else { return } hold(viewer) } /// Logs in through the browser, as `handle`. func signIn(handle: String) async { failure = nil let pkce = PKCE() var parts = URLComponents(url: Client.site.appendingPathComponent("oauth/app-login"), resolvingAgainstBaseURL: false)! parts.queryItems = [URLQueryItem(name: "challenge", value: pkce.challenge), URLQueryItem(name: "handle", value: handle)] let opened = Date() do { let back = try await browse(parts.url!) let items = URLComponents(url: back, resolvingAgainstBaseURL: false)?.queryItems ?? [] guard let code = items.first(where: { $0.name == "code" })?.value else { failure = "Sign-in wasn't approved." return } let token = try await Client.shared.trade(code: code, verifier: pkce.verifier) SessionStore.save(token) await loadViewer() } catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin { // They closed it, with nothing to say. Too soon for anyone to have, // iOS refused it, as it does an app it can't match to the site. if Date().timeIntervalSince(opened) < 1 { failure = "This device couldn't open sign-in for this app." } } catch { failure = error.localizedDescription } } private func browse(_ url: URL) async throws -> URL { try await withCheckedThrowingContinuation { done in let session = ASWebAuthenticationSession(url: url, callback: .https(host: Client.site.host()!, path: "/oauth/app-return")) { back, error in if let back { done.resume(returning: back) } else { done.resume(throwing: error ?? URLError(.unknown)) } } session.presentationContextProvider = anchor web = session session.start() } } /// Logs out here and on the server, which logs out the pages too. Gone /// from here whatever the server answers. func signOut() async { await Pushes.shared.stop() await Client.shared.logOut() SessionStore.clear() person = nil } /// Signed out here, once the server has deleted the account's data and with /// it the session and the device, so there's nothing to tell it. func forgotten() { Pushes.shared.forget() SessionStore.clear() person = nil } } private final class BrowserAnchor: NSObject, ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { MainActor.assumeIsolated { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } return scenes.flatMap(\.windows).first(where: \.isKeyWindow) ?? ASPresentationAnchor() } } } /// Says over every tab that the session is behind what the app asks for now, as /// the pages' note does, with a button that signs in again as the same account. struct ReauthNote: View { @Environment(Account.self) private var account @State private var busy = false var body: some View { VStack(alignment: .leading, spacing: Space.s2_5) { Text("Knotless can do more now than when you signed in. Sign in again to allow it.") .font(.subheadline) .fixedSize(horizontal: false, vertical: true) if let failure = account.failure { Text(failure).font(.footnote).foregroundStyle(Color.danger) } Button { guard let did = account.person?.did else { return } busy = true Task { await account.signIn(handle: did) busy = false } } label: { Text(busy ? "Signing in…" : "Sign in again") .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.page) .padding(.horizontal, Space.s4) .padding(.vertical, Space.s2) .background(Color.fg.opacity(busy ? 0.4 : 1), in: Capsule()) .contentShape(Capsule()) } .buttonStyle(.plain) .disabled(busy) } .padding(Space.s4) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.accentSoft) } } /// An account the typeahead service matched. struct FoundActor: Decodable, Identifiable, Sendable { let did: String let handle: String var displayName: String? var avatar: String? var id: String { did } } /// The tilde over a handle to sign in with, which goes to the browser. Whoever /// matches what's typed takes the tilde's place, right above the field, as the /// keyboard covers what's under it. struct SignInForm: View { @Environment(Account.self) private var account @State private var handle = "" @State private var busy = false @State private var found: [FoundActor] = [] /// The handle just picked, which needs no more suggestions. @State private var picked: String? @FocusState private var typing: Bool var why: String private var name: String { handle.trimmingCharacters(in: .whitespaces) } private var ready: Bool { !name.isEmpty && !busy } private var suggesting: Bool { typing && !busy && !found.isEmpty && handle != picked } var body: some View { VStack(spacing: Space.s3) { Spacer(minLength: Space.s4) if suggesting { suggestions } else { Image("mark").renderingMode(.template).resizable().scaledToFit() .frame(width: 112) .foregroundStyle(.primary) .accessibilityLabel("knotless") Spacer(minLength: Space.s4) } TextField("alice.bsky.social", text: $handle) .textContentType(.username) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) .submitLabel(.go) .focused($typing) .onSubmit(go) .padding(.horizontal, Space.s5) .frame(minHeight: Size.signIn) .background(Color.raised, in: Capsule()) .accessibilityLabel("Your handle") Button(action: go) { Group { if busy { ProgressView().tint(Color.page) } else { Text("Sign in").font(.body.weight(.semibold)) } } .foregroundStyle(ready || busy ? Color.page : Color.mute) .frame(maxWidth: .infinity, minHeight: Size.signIn) .background(ready || busy ? Color.fg : Color.fill, in: Capsule()) .contentShape(Capsule()) } .buttonStyle(.plain) .disabled(!ready) Group { if let failure = account.failure { Text(failure).foregroundStyle(Color.danger) } else { Text(why).foregroundStyle(.secondary) } } .font(.footnote) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) Text(Self.terms) .font(.footnote) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .tint(Color.accent) .padding(.top, Space.s5) .padding(.bottom, Space.s4) } .padding(.horizontal, Space.s6) .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) .onTapGesture { typing = false } // Asked a moment after typing stops; one overtaken by more typing is let go. .task(id: handle) { guard handle != picked else { return } try? await Task.sleep(for: .milliseconds(150)) guard !Task.isCancelled else { return } let actors = await Client.shared.findActors(handle) if !Task.isCancelled { found = actors } } } private var suggestions: some View { ScrollView { VStack(spacing: 0) { ForEach(found) { actor in Button { pick(actor) } label: { HStack(spacing: Space.s3) { Avatar(url: actor.avatar, name: actor.displayName ?? actor.handle, did: actor.did, size: Size.Avatar.regular) VStack(alignment: .leading, spacing: 0) { if let name = actor.displayName, !name.isEmpty { Text(name).font(.subheadline.weight(.semibold)).lineLimit(1) } Text("@\(actor.handle)").font(.footnote).foregroundStyle(.secondary).lineLimit(1) } Spacer(minLength: 0) } .padding(.horizontal, Space.s4) .frame(minHeight: Size.row) .contentShape(Rectangle()) } .buttonStyle(.plain) } } } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: Size.row * CGFloat(found.count)) .background(Color.raised, in: RoundedRectangle(cornerRadius: Radius.card)) .clipShape(RoundedRectangle(cornerRadius: Radius.card)) // It takes the room it asks for before the space above it does. .layoutPriority(1) } private static let terms: AttributedString = { let terms = Client.site.appendingPathComponent("terms").absoluteString let privacy = Client.site.appendingPathComponent("privacy").absoluteString let line = "By signing in you accept our [Terms of use](\(terms)) and [Privacy policy](\(privacy))." return (try? AttributedString(markdown: line)) ?? AttributedString(line) }() /// Fills it in and goes on, as the button would. private func pick(_ actor: FoundActor) { picked = actor.handle handle = actor.handle go() } private func go() { guard ready else { return } typing = false busy = true Task { await account.signIn(handle: name) busy = false } } }