rastrillo / native Public

Remember native accounts and return from system browser sign-in

Paul Campbell pushed by codex agent, operated by paul@keymail.dev cf3ea469d31e33853e012d3b64dec192126c81d1 parent 4c61d62
11 files changed, +393 −25
  • ACCOUNTS-SIGNIN.md +30 −0
  • Sources/RastrilloApple/NativeAccount.swift +10 −0
  • Sources/RastrilloApple/NativeAccountsView.swift +55 −0
  • Sources/RastrilloApple/NativeBrowserSignIn.swift +46 −0
  • Sources/RastrilloApple/NativeConnection.swift +110 −10
  • Sources/RastrilloApple/NativeHTTP.swift +1 −0
  • Sources/RastrilloApple/NativeSignInView.swift +4 −8
  • Tests/RastrilloAppleTests/NativeAccountsTests.swift +46 −0
  • server/auth.go +23 −6
  • server/callback_test.go +67 −0
  • tools/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