Remember native accounts and return from system browser sign-in
11 files changed,
+393
−25
ACCOUNTS-SIGNIN.md+30 −0Sources/RastrilloApple/NativeAccount.swift+10 −0Sources/RastrilloApple/NativeAccountsView.swift+55 −0Sources/RastrilloApple/NativeBrowserSignIn.swift+46 −0Sources/RastrilloApple/NativeConnection.swift+110 −10Sources/RastrilloApple/NativeHTTP.swift+1 −0Sources/RastrilloApple/NativeSignInView.swift+4 −8Tests/RastrilloAppleTests/NativeAccountsTests.swift+46 −0server/auth.go+23 −6server/callback_test.go+67 −0tools/tests/test_bundle_mac.py+1 −1
CI failed — run details
CI log
=== test === swift test Fetching https://github.com/sparkle-project/Sparkle from cache Fetched https://github.com/sparkle-project/Sparkle from cache (0.65s) Computing version for https://github.com/sparkle-project/Sparkle Computed https://github.com/sparkle-project/Sparkle at 2.9.6 (0.87s) Creating working copy for https://github.com/sparkle-project/Sparkle Working copy of https://github.com/sparkle-project/Sparkle resolved at 2.9.6 Fetching binary artifact https://github.com/sparkle-project/Sparkle/releases/download/2.9.6/Sparkle-for-Swift-Package-Manager.zip from cache error: failed validating archive from 'https://github.com/sparkle-project/Sparkle/releases/download/2.9.6/Sparkle-for-Swift-Package-Manager.zip' which is required by binary target 'Sparkle': could not find executable for 'unzip' error: fatalError make: *** [Makefile:4: test] Error 1 amadan: step test failed: exit status 2 amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/ACCOUNTS-SIGNIN.md b/ACCOUNTS-SIGNIN.md| new file mode 100644 |
| index 0000000..b402fad |
| --- /dev/null |
| +++ b/ACCOUNTS-SIGNIN.md |
| @@ -0,0 +1,30 @@ |
| +# Native accounts and browser return |
| + |
| +User direction, 2026-09-13: all Oficina apps use the Eleven account model. Open |
| +the most recent account; Back returns to saved accounts; adding an account keeps |
| +existing sessions. Explicit Sign out revokes only the selected native session. |
| +Store opaque credentials in per-app Keychain entries keyed by account UUID; |
| +UserDefaults stores only display names, server origins and the most recent ID. |
| +Migrate the existing origin-keyed credential only after its new entry is saved. |
| +Account switches recreate workspace models so content cannot cross accounts. |
| + |
| +Use ASWebAuthenticationSession for system browser authentication and normal |
| +browser session sharing, allowing existing Home sessions when the server enables |
| +Home. Each app registers works.oficina.<app> as its custom URL scheme. The server |
| +accepts only the exact callback for its app; after explicit browser approval it |
| +returns a random request ID, never a credential. Only the originating app's |
| +verifier can exchange approval for a session. Reject foreign/stale callbacks. |
| +Old servers may ignore callbacks; in-app polling still dismisses the authentication |
| +session after a successful proof exchange. Cancellation invalidates pending work. |
| + |
| +Sheets library: shape-only native thumbnail rows; top-right More menu for Import, |
| +appearance and Sign out; native bottom search and New toolbar; Accounts back |
| +button is disabled while edits or writes need completion. Editor-to-library Back |
| +keeps the editor model alive, preserving existing draft protection. |
| + |
| +Meet retains its existing meeting/invitation-bound Go sign-in protocol. Its verified |
| +result is adopted through NativeConnection.rememberSession, which validates the |
| +canonical origin and cookie format before saving. Its browser uses the same system |
| +authentication presenter. Saved account sessions are reused only for meeting links |
| +on the same origin; invitation redirects are resolved without forwarding cookies |
| +to another origin. Adding another Meet account starts a new explicit browser proof. |
diff --git a/Sources/RastrilloApple/NativeAccount.swift b/Sources/RastrilloApple/NativeAccount.swift| new file mode 100644 |
| index 0000000..fce237d |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeAccount.swift |
| @@ -0,0 +1,10 @@ |
| +import Foundation |
| + |
| +public struct NativeAccount: Codable, Identifiable, Hashable, Sendable { |
| + public let id: String |
| + public let origin: URL |
| + public let name: String |
| + // The Keychain lookup binds the UUID to its canonical origin. Altering the |
| + // display metadata cannot redirect an existing credential to another server. |
| + var credentialKey: String { "account:\(id)|\(origin.absoluteString)" } |
| +} |
diff --git a/Sources/RastrilloApple/NativeAccountsView.swift b/Sources/RastrilloApple/NativeAccountsView.swift| new file mode 100644 |
| index 0000000..c395b58 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeAccountsView.swift |
| @@ -0,0 +1,55 @@ |
| +import SwiftUI |
| + |
| +@MainActor public struct NativeAccountsView: View { |
| + @ObservedObject private var connection: NativeConnection |
| + private let name: String |
| + private let symbol: String |
| + private let addAccount: (() -> Void)? |
| + @State private var adding = false |
| + |
| + public init(_ name: String, systemImage: String, connection: NativeConnection, addAccount: (() -> Void)? = nil) { |
| + self.addAccount = addAccount |
| + self.name = name |
| + self.symbol = systemImage |
| + self.connection = connection |
| + } |
| + |
| + public var body: some View { |
| + NavigationStack { |
| + List { |
| + ForEach(connection.accounts) { account in |
| + Button { |
| + connection.selectAccount(account) |
| + } label: { |
| + Label { |
| + VStack(alignment: .leading) { |
| + Text(verbatim: account.name).foregroundStyle(.primary) |
| + Text(verbatim: account.origin.host ?? account.origin.absoluteString) |
| + .font(.caption).foregroundStyle(.secondary) |
| + } |
| + } icon: { Image(systemName: "person.crop.circle") } |
| + } |
| + } |
| + Button("Add account", systemImage: "plus") { |
| + connection.accountName = "" |
| + connection.error = "" |
| + if let addAccount { addAccount() } else { adding = true } |
| + } |
| + if !connection.error.isEmpty { |
| + Text(connection.error).foregroundStyle(.red) |
| + } |
| + } |
| + .navigationTitle("Accounts") |
| + .sheet(isPresented: $adding, onDismiss: { connection.cancel() }) { |
| + NavigationStack { |
| + NativeSignInView(name, systemImage: symbol, connection: connection) |
| + .toolbar { |
| + ToolbarItem(placement: .cancellationAction) { |
| + Button("Cancel", role: .cancel) { adding = false } |
| + } |
| + } |
| + } |
| + } |
| + } |
| + } |
| +} |
diff --git a/Sources/RastrilloApple/NativeBrowserSignIn.swift b/Sources/RastrilloApple/NativeBrowserSignIn.swift| new file mode 100644 |
| index 0000000..e253967 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeBrowserSignIn.swift |
| @@ -0,0 +1,46 @@ |
| +import AuthenticationServices |
| +import Foundation |
| +#if os(iOS) |
| +import UIKit |
| +#elseif os(macOS) |
| +import AppKit |
| +#endif |
| + |
| +/// Keeps browser authentication attached to the app, including servers that still |
| +/// finish through the proof-bound exchange instead of issuing a callback. |
| +@MainActor public final class NativeBrowserSignIn: NSObject, ASWebAuthenticationPresentationContextProviding { |
| + private var session: ASWebAuthenticationSession? |
| + private var generation = UUID() |
| + public override init() { super.init() } |
| + |
| + public func start(url: URL, scheme: String, completion: @escaping @MainActor (URL?) -> Void) -> Bool { |
| + cancel() |
| + let generation = self.generation |
| + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { url, _ in |
| + Task { @MainActor [weak self] in |
| + guard self?.generation == generation else { return } |
| + completion(url) |
| + } |
| + } |
| + session.presentationContextProvider = self |
| + session.prefersEphemeralWebBrowserSession = false |
| + self.session = session |
| + return session.start() |
| + } |
| + |
| + public func cancel() { |
| + generation = UUID() |
| + session?.cancel() |
| + session = nil |
| + } |
| + |
| + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { |
| + #if os(iOS) |
| + return UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } |
| + .filter { $0.activationState == .foregroundActive } |
| + .flatMap(\.windows).first(where: \.isKeyWindow) ?? ASPresentationAnchor() |
| + #else |
| + return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor() |
| + #endif |
| + } |
| +} |
diff --git a/Sources/RastrilloApple/NativeConnection.swift b/Sources/RastrilloApple/NativeConnection.swift| index c6836c4..95a87bf 100644 |
| --- a/Sources/RastrilloApple/NativeConnection.swift |
| +++ b/Sources/RastrilloApple/NativeConnection.swift |
| @@ -10,13 +10,102 @@ import Security |
| @Published public private(set) var code = "" |
| @Published public private(set) var browserURL: URL? |
| @Published public var error = "" |
| + @Published public private(set) var accounts: [NativeAccount] = [] |
| + @Published public private(set) var activeAccountID: String? |
| + @Published public var accountName = "" |
| + public let callbackScheme: String |
| + private let browser = NativeBrowserSignIn() |
| + private var pendingID: String? |
| private var task: Task<Void, Never>? |
| + private let accountsKey = "native.accounts" |
| + private let defaults: UserDefaults |
| - public init(defaultServer: String) { |
| - serverAddress = UserDefaults.standard.string(forKey: "native.server") ?? defaultServer |
| - if let origin = try? NativeHTTP.origin(serverAddress), |
| + private func persistAccounts() { |
| + if let data = try? JSONEncoder().encode(accounts) { |
| + defaults.set(data, forKey: accountsKey) |
| + } |
| + } |
| + |
| + public func showAccounts() { |
| + cancel() |
| + client = nil |
| + activeAccountID = nil |
| + } |
| + |
| + public func selectAccount(_ account: NativeAccount) { |
| + cancel() |
| + guard accounts.contains(account), (try? NativeHTTP.origin(account.origin.absoluteString)) == account.origin, |
| + let credential = NativeCredentials.read(account.credentialKey) else { |
| + error = "This account needs to sign in again." |
| + return |
| + } |
| + accounts.removeAll { $0.id == account.id } |
| + accounts.insert(account, at: 0) |
| + persistAccounts() |
| + serverAddress = account.origin.absoluteString |
| + activeAccountID = account.id |
| + defaults.set(account.id, forKey: "native.activeAccount") |
| + client = NativeHTTP(origin: account.origin, credential: credential) |
| + } |
| + |
| + /// Adopts a session returned by a consumer's proof-bound sign-in protocol. |
| + public func rememberSession(_ credential: NativeSession, origin: URL, name: String = "") throws { |
| + guard (try? NativeHTTP.origin(origin.absoluteString)) == origin, |
| + credential.name == (origin.scheme == "https" ? "__Host-rastrillo_session" : "rastrillo_session"), |
| + Self.valid(credential.value) else { throw NativeClientError.message("Invalid sign-in response.") } |
| + let label = name.trimmingCharacters(in: .whitespacesAndNewlines) |
| + let account = NativeAccount(id: UUID().uuidString, origin: origin, |
| + name: label.isEmpty ? (origin.host ?? origin.absoluteString) : label) |
| + try NativeCredentials.save(credential, origin: account.credentialKey) |
| + accounts.insert(account, at: 0) |
| + persistAccounts() |
| + activeAccountID = account.id |
| + defaults.set(account.id, forKey: "native.activeAccount") |
| + defaults.set(origin.absoluteString, forKey: "native.server") |
| + serverAddress = origin.absoluteString |
| + client = NativeHTTP(origin: origin, credential: credential) |
| + } |
| + |
| + public func handleOpenURL(_ url: URL) { |
| + guard let pendingID, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
| + components.scheme == callbackScheme, components.host == "signin", |
| + components.path.isEmpty, components.user == nil, components.password == nil, |
| + components.port == nil, components.fragment == nil, |
| + components.queryItems == [URLQueryItem(name: "state", value: pendingID)] else { return } |
| + // The URL carries no credential. Only the original verifier can complete |
| + // the running exchange; a forged callback cannot sign the app in. |
| + browser.cancel() |
| + } |
| + |
| + public func signIn() { |
| + signIn { [weak self] url in |
| + guard let self else { return false } |
| + let id = self.pendingID |
| + return self.browser.start(url: url, scheme: self.callbackScheme) { [weak self] callback in |
| + guard let self, self.pendingID == id else { return } |
| + if let callback { self.handleOpenURL(callback) } else { self.cancel() } |
| + } |
| + } |
| + } |
| + |
| + public init(defaultServer: String, callbackScheme: String? = nil, defaults: UserDefaults = .standard) { |
| + self.defaults = defaults |
| + self.callbackScheme = callbackScheme ?? "works.oficina.\(URL(string: defaultServer)?.host?.components(separatedBy: ".").first ?? "app")" |
| + serverAddress = defaults.string(forKey: "native.server") ?? defaultServer |
| + if let data = defaults.data(forKey: accountsKey), |
| + let saved = try? JSONDecoder().decode([NativeAccount].self, from: data) { accounts = saved } |
| + if accounts.isEmpty, let origin = try? NativeHTTP.origin(serverAddress), |
| let credential = NativeCredentials.read(origin.absoluteString) { |
| - client = NativeHTTP(origin: origin, credential: credential) |
| + let account = NativeAccount(id: UUID().uuidString, origin: origin, name: origin.host ?? origin.absoluteString) |
| + if (try? NativeCredentials.save(credential, origin: account.credentialKey)) != nil { |
| + accounts = [account] |
| + persistAccounts() |
| + NativeCredentials.remove(origin.absoluteString) |
| + } |
| + } |
| + let savedID = defaults.string(forKey: "native.activeAccount") |
| + if let account = accounts.first(where: { $0.id == savedID }) ?? accounts.first { |
| + selectAccount(account) |
| } |
| } |
| @@ -36,9 +125,10 @@ import Security |
| let challenge = Self.token(Data(SHA256.hash(data: Data(verifier.utf8)))) |
| struct Begin: Decodable { let id: String } |
| let begin: Begin = try await http.request("/native/request", method: "POST", |
| - body: JSONEncoder().encode(["challenge": challenge])) |
| + body: JSONEncoder().encode(["challenge": challenge, "callback": "\(callbackScheme)://signin"])) |
| guard Self.valid(begin.id) else { throw NativeClientError.message("Invalid sign-in response.") } |
| try Task.checkCancellation() |
| + pendingID = begin.id |
| let hex = SHA256.hash(data: Data(begin.id.utf8)).prefix(4).map { String(format: "%02X", $0) }.joined() |
| code = String(hex.prefix(4)) + "-" + String(hex.suffix(4)) |
| let url = origin.appendingPathComponent("native/start").appending(queryItems: [URLQueryItem(name: "id", value: begin.id)]) |
| @@ -54,9 +144,9 @@ import Security |
| credential.name == (origin.scheme == "https" ? "__Host-rastrillo_session" : "rastrillo_session"), |
| Self.valid(credential.value) else { throw NativeClientError.message("Sign-in expired or was refused. Please try again.") } |
| try Task.checkCancellation() |
| - try NativeCredentials.save(credential, origin: origin.absoluteString) |
| - UserDefaults.standard.set(origin.absoluteString, forKey: "native.server") |
| - client = NativeHTTP(origin: origin, credential: credential) |
| + try rememberSession(credential, origin: origin, name: accountName) |
| + pendingID = nil |
| + browser.cancel() |
| signingIn = false |
| browserURL = nil |
| return |
| @@ -65,19 +155,29 @@ import Security |
| } catch is CancellationError { return } |
| catch { |
| guard !Task.isCancelled else { return } |
| + pendingID = nil |
| + browser.cancel() |
| self.error = error.localizedDescription |
| signingIn = false |
| browserURL = nil |
| } |
| } |
| } |
| - public func cancel() { task?.cancel(); task = nil; signingIn = false; code = ""; browserURL = nil } |
| + public func cancel() { pendingID = nil; task?.cancel(); task = nil; browser.cancel(); signingIn = false; code = ""; browserURL = nil } |
| public func signOut() { |
| cancel() |
| if let client { |
| - NativeCredentials.remove(client.origin.absoluteString) |
| + if let activeAccountID { |
| + if let account = accounts.first(where: { $0.id == activeAccountID }) { |
| + NativeCredentials.remove(account.credentialKey) |
| + } |
| + accounts.removeAll { $0.id == activeAccountID } |
| + persistAccounts() |
| + } |
| + defaults.removeObject(forKey: "native.activeAccount") |
| Task { _ = try? await client.response("/signout", method: "POST", body: Data()) } |
| } |
| + activeAccountID = nil |
| client = nil |
| } |
| private static func token(_ data: Data) -> String { |
diff --git a/Sources/RastrilloApple/NativeHTTP.swift b/Sources/RastrilloApple/NativeHTTP.swift| index 2f1c278..f9422e2 100644 |
| --- a/Sources/RastrilloApple/NativeHTTP.swift |
| +++ b/Sources/RastrilloApple/NativeHTTP.swift |
| @@ -3,6 +3,7 @@ import Foundation |
| public struct NativeSession: Codable, Sendable { |
| public let name: String |
| public let value: String |
| + public init(name: String, value: String) { self.name = name; self.value = value } |
| } |
| public enum NativeClientError: LocalizedError { |
diff --git a/Sources/RastrilloApple/NativeSignInView.swift b/Sources/RastrilloApple/NativeSignInView.swift| index 8ef2997..1a06694 100644 |
| --- a/Sources/RastrilloApple/NativeSignInView.swift |
| +++ b/Sources/RastrilloApple/NativeSignInView.swift |
| @@ -24,7 +24,7 @@ import SwiftUI |
| Text(connection.code).font(.title.monospaced().bold()).textSelection(.enabled) |
| Text("Compare this code with the one in your browser before approving sign-in.") |
| .foregroundStyle(.secondary) |
| - if let url = connection.browserURL { Link("Open browser again", destination: url) } |
| + |
| Button("Cancel") { connection.cancel() } |
| } else { |
| VStack(alignment: .leading, spacing: 8) { |
| @@ -36,6 +36,8 @@ import SwiftUI |
| #endif |
| .onSubmit(signIn) |
| } |
| + TextField("Account name (optional)", text: $connection.accountName) |
| + .textFieldStyle(.roundedBorder) |
| Button("Sign in", systemImage: "arrow.right", action: signIn) |
| .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| } |
| @@ -47,11 +49,5 @@ import SwiftUI |
| .frame(maxWidth: .infinity) |
| } |
| } |
| - private func signIn() { |
| - connection.signIn { url in |
| - await withCheckedContinuation { continuation in |
| - openURL(url) { accepted in continuation.resume(returning: accepted) } |
| - } |
| - } |
| - } |
| + private func signIn() { connection.signIn() } |
| } |
diff --git a/Tests/RastrilloAppleTests/NativeAccountsTests.swift b/Tests/RastrilloAppleTests/NativeAccountsTests.swift| new file mode 100644 |
| index 0000000..9355d31 |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/NativeAccountsTests.swift |
| @@ -0,0 +1,46 @@ |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +final class NativeAccountsTests: XCTestCase { |
| + @MainActor func testChangingSavedOriginCannotRedirectCredential() throws { |
| + let suite = "OficinaAccountsTest.\(UUID().uuidString)" |
| + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) |
| + defer { defaults.removePersistentDomain(forName: suite) } |
| + let original = NativeAccount(id: UUID().uuidString, origin: URL(string: "https://trusted.test")!, name: "Work") |
| + try NativeCredentials.save(NativeSession(name: "__Host-rastrillo_session", value: "secret"), origin: original.credentialKey) |
| + defer { NativeCredentials.remove(original.credentialKey) } |
| + for origin in ["https://other.test", "http://trusted.test", "https://trusted.test/path"] { |
| + let modified = NativeAccount(id: original.id, origin: URL(string: origin)!, name: original.name) |
| + defaults.set(try JSONEncoder().encode([modified]), forKey: "native.accounts") |
| + defaults.set(original.id, forKey: "native.activeAccount") |
| + let connection = NativeConnection(defaultServer: "https://trusted.test", defaults: defaults) |
| + XCTAssertNil(connection.client) |
| + XCTAssertNil(connection.activeAccountID) |
| + } |
| + } |
| + |
| + @MainActor func testBackPreservesSessionsAndRestoresMostRecentAccount() throws { |
| + let suite = "OficinaAccountsTest.\(UUID().uuidString)" |
| + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) |
| + defer { defaults.removePersistentDomain(forName: suite) } |
| + let origin = URL(string: "https://trusted.test")! |
| + let first = NativeAccount(id: UUID().uuidString, origin: origin, name: "Work") |
| + let second = NativeAccount(id: UUID().uuidString, origin: origin, name: "Personal") |
| + for (account, token) in [(first,"first"), (second,"second")] { |
| + try NativeCredentials.save(NativeSession(name: "__Host-rastrillo_session", value: token), origin: account.credentialKey) |
| + } |
| + defer { for account in [first,second] { NativeCredentials.remove(account.credentialKey) } } |
| + defaults.set(try JSONEncoder().encode([first,second]), forKey: "native.accounts") |
| + let connection = NativeConnection(defaultServer: origin.absoluteString, defaults: defaults) |
| + connection.selectAccount(second) |
| + XCTAssertEqual(connection.client?.credential?.value, "second") |
| + connection.showAccounts() |
| + XCTAssertNil(connection.client) |
| + XCTAssertEqual(connection.accounts.count, 2) |
| + let restored = NativeConnection(defaultServer: origin.absoluteString, defaults: defaults) |
| + XCTAssertEqual(restored.activeAccountID, second.id) |
| + XCTAssertEqual(restored.client?.credential?.value, "second") |
| + restored.selectAccount(first) |
| + XCTAssertEqual(restored.client?.credential?.value, "first") |
| + } |
| +} |
diff --git a/server/auth.go b/server/auth.go| index a4c9fe1..47a8b3e 100644 |
| --- a/server/auth.go |
| +++ b/server/auth.go |
| @@ -29,8 +29,8 @@ type Auth struct { |
| origin, name string |
| } |
| type request struct { |
| - challenge, cookie string |
| - expires time.Time |
| + challenge, cookie, callback string |
| + expires time.Time |
| } |
| func New(s *sessions.Sessions, origin, name string, require func(http.Handler) http.Handler) *Auth { |
| @@ -128,11 +128,12 @@ func jsonReply(w http.ResponseWriter, value any) { |
| func (a *Auth) begin(w http.ResponseWriter, r *http.Request) { |
| var in struct { |
| Challenge string `json:"challenge"` |
| + Callback string `json:"callback"` |
| } |
| if !decode(w, r, &in) { |
| return |
| } |
| - if !valid(in.Challenge) { |
| + if !valid(in.Challenge) || (in.Callback != "" && in.Callback != a.callbackURL()) { |
| http.Error(w, "Invalid proof", 400) |
| return |
| } |
| @@ -144,7 +145,7 @@ func (a *Auth) begin(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| id := token() |
| - a.pending[id] = &request{challenge: in.Challenge, expires: time.Now().Add(5 * time.Minute)} |
| + a.pending[id] = &request{challenge: in.Challenge, callback: in.Callback, expires: time.Now().Add(5 * time.Minute)} |
| jsonReply(w, map[string]string{"id": id}) |
| } |
| func (a *Auth) authorize(w http.ResponseWriter, r *http.Request) { |
| @@ -158,6 +159,7 @@ func (a *Auth) authorize(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| done := false |
| + callback := "" |
| if r.Method == "POST" { |
| c, e := r.Cookie(a.sessions.CookieName()) |
| if e != nil { |
| @@ -172,6 +174,9 @@ func (a *Auth) authorize(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| p.cookie = c.Value |
| + if p.callback != "" { |
| + callback = p.callback + "?state=" + id |
| + } |
| a.mu.Unlock() |
| done = true |
| } |
| @@ -179,7 +184,8 @@ func (a *Auth) authorize(w http.ResponseWriter, r *http.Request) { |
| _ = approval.Execute(w, struct { |
| Name, Code, ID string |
| Done bool |
| - }{a.name, code(id), id, done}) |
| + Callback template.URL |
| + }{a.name, code(id), id, done, template.URL(callback)}) |
| } |
| func (a *Auth) exchange(w http.ResponseWriter, r *http.Request) { |
| var in struct { |
| @@ -239,4 +245,15 @@ func (discardResponse) Header() http.Header { return make(http.Header) } |
| func (discardResponse) Write(b []byte) (int, error) { return len(b), nil } |
| func (discardResponse) WriteHeader(int) {} |
| -var approval = template.Must(template.New("approval").Parse(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Sign in to {{.Name}}</title><style>:root{color-scheme:light dark}body{font:17px/1.5 system-ui,sans-serif;margin:0;background:light-dark(#f5f4f8,#19181d);color:light-dark(#24212c,#f7f4fa)}main{max-width:28rem;margin:12vh auto;padding:2rem;border-radius:1.5rem;background:light-dark(white,#28252e);box-shadow:0 8px 40px #0001}h1{font-size:1.7rem;line-height:1.2}h2{font:600 2rem ui-monospace,monospace;letter-spacing:.1em}button{font:600 1rem system-ui;border:0;border-radius:.8rem;padding:.9rem 1.2rem;background:#7052a2;color:white;cursor:pointer}button:focus-visible{outline:3px solid #ad8ddc;outline-offset:4px}@media(max-width:36rem){main{margin:2rem 1rem}}</style><body><main><h1>{{if .Done}}{{.Name}} is signed in{{else}}Sign in to {{.Name}}{{end}}</h1>{{if .Done}}<p>Return to the app to continue.</p>{{else}}<p>Check that the app shows this same code before continuing.</p><h2>{{.Code}}</h2><form method="post" action="/native/authorize?id={{.ID}}"><button>Sign in to {{.Name}} app</button></form>{{end}}</main></body></html>`)) |
| +var approval = template.Must(template.New("approval").Parse(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Sign in to {{.Name}}</title>{{if .Callback}}<meta http-equiv="refresh" content="0;url={{.Callback}}">{{end}}<style>:root{color-scheme:light dark}body{font:17px/1.5 system-ui,sans-serif;margin:0;background:light-dark(#f5f4f8,#19181d);color:light-dark(#24212c,#f7f4fa)}main{max-width:28rem;margin:12vh auto;padding:2rem;border-radius:1.5rem;background:light-dark(white,#28252e);box-shadow:0 8px 40px #0001}h1{font-size:1.7rem;line-height:1.2}h2{font:600 2rem ui-monospace,monospace;letter-spacing:.1em}button{font:600 1rem system-ui;border:0;border-radius:.8rem;padding:.9rem 1.2rem;background:#7052a2;color:white;cursor:pointer}button:focus-visible{outline:3px solid #ad8ddc;outline-offset:4px}@media(max-width:36rem){main{margin:2rem 1rem}}</style><body><main><h1>{{if .Done}}{{.Name}} is signed in{{else}}Sign in to {{.Name}}{{end}}</h1>{{if .Done}}{{if .Callback}}<p><a href="{{.Callback}}">Return to {{.Name}}</a></p>{{else}}<p>Return to the app to continue.</p>{{end}}{{else}}<p>Check that the app shows this same code before continuing.</p><h2>{{.Code}}</h2><form method="post" action="/native/authorize?id={{.ID}}"><button>Sign in to {{.Name}} app</button></form>{{end}}</main></body></html>`)) |
| + |
| +// A fixed app-specific callback prevents this endpoint becoming an open redirect. |
| +// The callback only wakes the app: session exchange still requires its verifier. |
| +func (a *Auth) callbackURL() string { |
| + switch a.name { |
| + case "Docs", "Sheets", "Calendar", "Memoria", "Meet": |
| + return "works.oficina." + strings.ToLower(a.name) + "://signin" |
| + default: |
| + return "" |
| + } |
| +} |
diff --git a/server/callback_test.go b/server/callback_test.go| new file mode 100644 |
| index 0000000..48bf3f8 |
| --- /dev/null |
| +++ b/server/callback_test.go |
| @@ -0,0 +1,67 @@ |
| +package nativeauth |
| + |
| +import ( |
| + "encoding/json" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestCallbackIsFixedAndContainsNoSession(t *testing.T) { |
| + d, err := db.Open(filepath.Join(t.TempDir(), "sessions.db"), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + if _, err = migrate.Apply(t.Context(), d, sessions.Schema); err != nil { |
| + t.Fatal(err) |
| + } |
| + s, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: "https://app.test"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a := New(s, "https://app.test", "Docs", s.Require) |
| + for _, callback := range []string{"https://evil.test", "works.oficina.sheets://signin", "works.oficina.docs://signin?state=evil", "works.oficina.docs://signin/", "javascript:alert(1)"} { |
| + data, _ := json.Marshal(map[string]string{"challenge": token(), "callback": callback}) |
| + req := httptest.NewRequest("POST", "https://app.test/native/request", strings.NewReader(string(data))) |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Origin", "https://app.test") |
| + w := httptest.NewRecorder() |
| + a.ServeHTTP(w, req) |
| + if w.Code != 400 { |
| + t.Fatalf("callback %q status%d", callback, w.Code) |
| + } |
| + } |
| + for _, callback := range []string{"", "works.oficina.docs://signin"} { |
| + id := token() |
| + a.pending[id] = &request{challenge: token(), callback: callback, expires: time.Now().Add(time.Minute)} |
| + cookie, err := s.Mint(sessions.Session{Subject: "alice"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req := httptest.NewRequest("POST", "https://app.test/native/authorize?id="+id, nil) |
| + req.Header.Set("Origin", "https://app.test") |
| + req.AddCookie(&http.Cookie{Name: s.CookieName(), Value: cookie}) |
| + w := httptest.NewRecorder() |
| + s.Middleware(a).ServeHTTP(w, req) |
| + if w.Code != 200 { |
| + t.Fatal(w.Code) |
| + } |
| + body := w.Body.String() |
| + if strings.Contains(body, cookie) || strings.Contains(body, "#ZgotmplZ") { |
| + t.Fatal("unsafe or broken callback") |
| + } |
| + if callback != "" && !strings.Contains(body, `href="works.oficina.docs://signin?state=`+id+`"`) { |
| + t.Fatal("missing bound return link") |
| + } |
| + if callback == "" && strings.Contains(body, `http-equiv="refresh"`) { |
| + t.Fatal("legacy flow redirected") |
| + } |
| + } |
| +} |
diff --git a/tools/tests/test_bundle_mac.py b/tools/tests/test_bundle_mac.py| index a5a6365..140fb09 100644 |
| --- a/tools/tests/test_bundle_mac.py |
| +++ b/tools/tests/test_bundle_mac.py |
| @@ -21,7 +21,7 @@ class BundleMacTests(unittest.TestCase): |
| import PackageDescription |
| let package = Package(name: "Probe", platforms: [.macOS("26.0")], |
| products: [.executable(name: "ProbeMac", targets: ["Probe"])], |
| - dependencies: [.package(path: SHARED_NATIVE_PATH)], |
| + dependencies: [.package(name: "native", path: SHARED_NATIVE_PATH)], |
| targets: [.executableTarget(name: "Probe", dependencies: [.product(name: "RastrilloMacUpdates", package: "native")], resources: [.process("Resources")])]) |
| '''.replace('SHARED_NATIVE_PATH', json.dumps(str(tool.parents[1])))) |
| (source / "main.swift").write_text('''import Foundation |