rastrillo / native Public

Add shared Apple browser sign-in and development app bundling

Co-Authored-By: Codex <codex~paul@keymail.dev>
codex agent, operated by paul@keymail.dev pushed by paul@keymail.dev 33ffe994173438bbb45e88d16061eba2ac4f6d4c parent e6520f1
15 files changed, +1211 −2
  • APPLE.md +29 −0
  • Makefile +3 −1
  • Package.swift +4 −1
  • Sources/RastrilloApple/NativeConnection.swift +93 −0
  • Sources/RastrilloApple/NativeCredentials.swift +32 −0
  • Sources/RastrilloApple/NativeHTTP.swift +83 −0
  • Sources/RastrilloApple/NativeSignInView.swift +57 −0
  • Tests/RastrilloAppleTests/OriginTests.swift +18 −0
  • server/LICENSE +374 −0
  • server/auth.go +242 −0
  • server/auth_test.go +123 −0
  • server/go.mod +23 −0
  • server/go.sum +73 −0
  • tools/bundle-mac.py +34 −0
  • tools/mac-icon.swift +23 −0

CI failed — run details

CI log
=== test ===
swift test
Building for debugging...
[0/17] Write sources
[4/17] Write swift-version--28C01585A1F674EC.txt
[6/24] Compiling RastrilloApple NativeHTTP.swift
/home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__oficina-clients/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine'
 1 | import Combine
   |        `- error: no such module 'Combine'
 2 | import CryptoKit
 3 | import Foundation
[7/24] Compiling RastrilloApple NativeCredentials.swift
/home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__oficina-clients/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine'
 1 | import Combine
   |        `- error: no such module 'Combine'
 2 | import CryptoKit
 3 | import Foundation
[8/24] Compiling RastrilloApple NativeSignInView.swift
/home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__oficina-clients/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine'
 1 | import Combine
   |        `- error: no such module 'Combine'
 2 | import CryptoKit
 3 | import Foundation
[9/24] Emitting module RastrilloNative
[10/24] Compiling RastrilloNative CoalescedRunner.swift
error: emit-module command failed with exit code 1 (use -v to see invocation)
[12/25] Emitting module RastrilloApple
/home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__oficina-clients/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine'
 1 | import Combine
   |        `- error: no such module 'Combine'
 2 | import CryptoKit
 3 | import Foundation
[13/25] Compiling RastrilloApple NativeConnection.swift
/home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__oficina-clients/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine'
 1 | import Combine
   |        `- error: no such module 'Combine'
 2 | import CryptoKit
 3 | import Foundation
[13/25] Wrapping AST for RastrilloNative for debugging
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/APPLE.md b/APPLE.md
new file mode 100644
index 0000000..32c1319
--- /dev/null
+++ b/APPLE.md
@@ -0,0 +1,29 @@
+# Apple clients · 2026-09-12
+
+The `RastrilloApple` product adds browser approval, ephemeral same-origin HTTP,
+Keychain sessions and a SwiftUI sign-in view for macOS 14+ and iOS 17+.
+The platform-independent RastrilloNative product remains separate.
+Calendar and Memoria use these components in their first native clients.
+
+`server/` is a separate Go module, `amadan.net/rastrillo/native/server`.
+Applications supply their existing session and admission middleware. Browser
+approval binds a five-minute, single-use request to a 256-bit verifier hash.
+The app and browser display the same code. Exchange authenticates the approved
+browser cookie from a fresh context and rechecks admission before minting a
+separate ordinary session. Resource authorization stays in each application.
+There is no cross-origin cookie jar, HTTP cache or external redirect following.
+
+`tools/bundle-mac.py <native-directory> <Name>` builds a SwiftPM executable
+`<Name>Mac`, derives a padded rounded Mac icon from the iOS master, bundles the
+Lucide notice, and signs the development app ad hoc. It does not notarize it.
+
+Run `make ci` for Swift unit tests, the existing Mac/iOS companion builds,
+and Go authentication tests with the race detector.
+
+Independent review: `/root/native_review`, dispatched by this Codex session,
+found an inherited-session-context vulnerability in exchange. Fixed using a
+fresh cancelable context. Its regression presents Bob's session while exchanging
+Alice's approval after Alice loses membership. Independently selected mutations
+removing proof checking, expiration, one-time consumption, approval admission,
+exchange admission and fresh-context handling all failed the regression suite
+on 2026-09-12; all mutations were reverted. Review did not approve a merge.
diff --git a/Makefile b/Makefile
index 8f9c720..2dd95f8 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,9 @@
.PHONY: ci test companion
-ci: test companion
+ci: test companion server-test
test:
swift test
+server-test:
+ cd server && go test -race ./...
companion:
cd examples/companion && xcodegen generate
xcodebuild -project examples/companion/Companion.xcodeproj -scheme CompanionMac -destination 'platform=macOS' -derivedDataPath examples/companion/build CODE_SIGNING_ALLOWED=NO build
diff --git a/Package.swift b/Package.swift
index dab2708..5b1cd72 100644
--- a/Package.swift
+++ b/Package.swift
@@ -4,9 +4,12 @@ import PackageDescription
let package = Package(
name: "RastrilloNative",
platforms: [.iOS(.v17), .macOS(.v14)],
- products: [.library(name: "RastrilloNative", targets: ["RastrilloNative"])],
+ products: [.library(name: "RastrilloNative", targets: ["RastrilloNative"]),
+ .library(name: "RastrilloApple", targets: ["RastrilloApple"])],
targets: [
.target(name: "RastrilloNative"),
+ .target(name: "RastrilloApple"),
+ .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple"]),
.testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative"]),
]
)
diff --git a/Sources/RastrilloApple/NativeConnection.swift b/Sources/RastrilloApple/NativeConnection.swift
new file mode 100644
index 0000000..c6836c4
--- /dev/null
+++ b/Sources/RastrilloApple/NativeConnection.swift
@@ -0,0 +1,93 @@
+import Combine
+import CryptoKit
+import Foundation
+import Security
+
+@MainActor public final class NativeConnection: ObservableObject {
+ @Published public var serverAddress: String
+ @Published public private(set) var client: NativeHTTP?
+ @Published public private(set) var signingIn = false
+ @Published public private(set) var code = ""
+ @Published public private(set) var browserURL: URL?
+ @Published public var error = ""
+ private var task: Task<Void, Never>?
+
+ public init(defaultServer: String) {
+ serverAddress = UserDefaults.standard.string(forKey: "native.server") ?? defaultServer
+ if let origin = try? NativeHTTP.origin(serverAddress),
+ let credential = NativeCredentials.read(origin.absoluteString) {
+ client = NativeHTTP(origin: origin, credential: credential)
+ }
+ }
+
+ public func signIn(open: @escaping @MainActor (URL) async -> Bool) {
+ cancel()
+ error = ""
+ signingIn = true
+ task = Task {
+ do {
+ let origin = try NativeHTTP.origin(serverAddress)
+ let http = NativeHTTP(origin: origin)
+ var random = [UInt8](repeating: 0, count: 32)
+ guard SecRandomCopyBytes(kSecRandomDefault, random.count, &random) == errSecSuccess else {
+ throw NativeClientError.message("Could not start secure sign-in.")
+ }
+ let verifier = Self.token(Data(random))
+ 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]))
+ guard Self.valid(begin.id) else { throw NativeClientError.message("Invalid sign-in response.") }
+ try Task.checkCancellation()
+ 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)])
+ browserURL = url
+ guard await open(url) else { throw NativeClientError.message("Could not open your browser.") }
+ let deadline = Date().addingTimeInterval(300)
+ while Date() < deadline {
+ try await Task.sleep(for: .seconds(2))
+ let (data, status) = try await http.response("/native/exchange", method: "POST",
+ body: JSONEncoder().encode(["id": begin.id, "verifier": verifier]))
+ if status == 202 { continue }
+ guard status == 200, let credential = try? JSONDecoder().decode(NativeSession.self, from: data),
+ 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)
+ signingIn = false
+ browserURL = nil
+ return
+ }
+ throw NativeClientError.message("Sign-in expired. Please try again.")
+ } catch is CancellationError { return }
+ catch {
+ guard !Task.isCancelled else { return }
+ self.error = error.localizedDescription
+ signingIn = false
+ browserURL = nil
+ }
+ }
+ }
+ public func cancel() { task?.cancel(); task = nil; signingIn = false; code = ""; browserURL = nil }
+ public func signOut() {
+ cancel()
+ if let client {
+ NativeCredentials.remove(client.origin.absoluteString)
+ Task { _ = try? await client.response("/signout", method: "POST", body: Data()) }
+ }
+ client = nil
+ }
+ private static func token(_ data: Data) -> String {
+ data.base64EncodedString().replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
+ }
+ private static func valid(_ text: String) -> Bool {
+ guard text.count == 43 else { return false }
+ let raw = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + "="
+ guard let data = Data(base64Encoded: raw), data.count == 32 else { return false }
+ return token(data) == text
+ }
+}
diff --git a/Sources/RastrilloApple/NativeCredentials.swift b/Sources/RastrilloApple/NativeCredentials.swift
new file mode 100644
index 0000000..d969bb3
--- /dev/null
+++ b/Sources/RastrilloApple/NativeCredentials.swift
@@ -0,0 +1,32 @@
+import Foundation
+import Security
+
+enum NativeCredentials {
+ private static var service: String { (Bundle.main.bundleIdentifier ?? "oficina.native") + ".session" }
+ private static func query(_ origin: String) -> [String: Any] {
+ [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,
+ kSecAttrAccount as String: origin]
+ }
+ static func read(_ origin: String) -> NativeSession? {
+ var q = query(origin)
+ q[kSecReturnData as String] = true
+ q[kSecMatchLimit as String] = kSecMatchLimitOne
+ var result: CFTypeRef?
+ guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data else { return nil }
+ return try? JSONDecoder().decode(NativeSession.self, from: data)
+ }
+ static func save(_ session: NativeSession, origin: String) throws {
+ let data = try JSONEncoder().encode(session)
+ let updated = SecItemUpdate(query(origin) as CFDictionary, [kSecValueData as String: data] as CFDictionary)
+ if updated == errSecSuccess { return }
+ guard updated == errSecItemNotFound else { throw NativeClientError.message("Could not update this session in Keychain.") }
+ var q = query(origin)
+ q[kSecValueData as String] = data
+ q[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ guard SecItemAdd(q as CFDictionary, nil) == errSecSuccess else {
+ throw NativeClientError.message("Could not save this session in Keychain.")
+ }
+ }
+ static func remove(_ origin: String) { SecItemDelete(query(origin) as CFDictionary) }
+}
diff --git a/Sources/RastrilloApple/NativeHTTP.swift b/Sources/RastrilloApple/NativeHTTP.swift
new file mode 100644
index 0000000..9298843
--- /dev/null
+++ b/Sources/RastrilloApple/NativeHTTP.swift
@@ -0,0 +1,83 @@
+import Foundation
+
+public struct NativeSession: Codable, Sendable {
+ public let name: String
+ public let value: String
+}
+
+public enum NativeClientError: LocalizedError {
+ case message(String)
+ public var errorDescription: String? { if case .message(let value) = self { return value }; return nil }
+}
+
+private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
+ func urlSession(_ session: URLSession, task: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
+}
+
+/// Ephemeral transport: no HTTP cache, shared cookie jar, or off-origin redirects.
+public final class NativeHTTP: @unchecked Sendable {
+ public let origin: URL
+ public let credential: NativeSession?
+ private let session: URLSession
+
+ public init(origin: URL, credential: NativeSession? = nil) {
+ self.origin = origin
+ self.credential = credential
+ let config = URLSessionConfiguration.ephemeral
+ config.httpShouldSetCookies = false
+ config.urlCache = nil
+ config.timeoutIntervalForRequest = 30
+ session = URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil)
+ }
+
+ public static func origin(_ text: String) throws -> URL {
+ guard let c = URLComponents(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
+ let host = c.host, !host.isEmpty, c.user == nil, c.password == nil,
+ c.query == nil, c.fragment == nil, c.path.isEmpty || c.path == "/",
+ c.scheme == "https" || (c.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)),
+ let url = c.url else {
+ throw NativeClientError.message("Enter your server’s HTTPS address, without a page path.")
+ }
+ var clean = c
+ clean.path = ""
+ return clean.url ?? url
+ }
+
+ public func response(_ path: String, method: String = "GET", body: Data? = nil,
+ contentType: String = "application/json") async throws -> (Data, Int) {
+ guard path.hasPrefix("/"), !path.hasPrefix("//"),
+ let url = URL(string: path, relativeTo: origin)?.absoluteURL,
+ url.scheme == origin.scheme, url.host == origin.host, url.port == origin.port else {
+ throw NativeClientError.message("Invalid server path.")
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ request.httpBody = body
+ request.setValue(origin.absoluteString, forHTTPHeaderField: "Origin")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ if body != nil { request.setValue(contentType, forHTTPHeaderField: "Content-Type") }
+ if let credential { request.setValue("\(credential.name)=\(credential.value)", forHTTPHeaderField: "Cookie") }
+ let (bytes, response) = try await session.bytes(for: request)
+ guard let response = response as? HTTPURLResponse else { throw NativeClientError.message("Invalid server response.") }
+ var data = Data()
+ for try await byte in bytes {
+ guard data.count < 4 * 1024 * 1024 else { throw NativeClientError.message("The server response is too large.") }
+ data.append(byte)
+ }
+ return (data, response.statusCode)
+ }
+
+ public func request<T: Decodable>(_ path: String, method: String = "GET", body: Data? = nil,
+ contentType: String = "application/json") async throws -> T {
+ let (data, status) = try await response(path, method: method, body: body, contentType: contentType)
+ guard (200..<300).contains(status) else {
+ if status == 401 || status == 303 { throw NativeClientError.message("Your session has ended. Sign out and sign in again.") }
+ if status == 404 { throw NativeClientError.message("This server does not support this native feature yet.") }
+ throw NativeClientError.message("The request could not be completed (\(status)). Your changes have not been confirmed.")
+ }
+ do { return try JSONDecoder().decode(T.self, from: data) }
+ catch { throw NativeClientError.message("The server returned an unsupported response.") }
+ }
+}
diff --git a/Sources/RastrilloApple/NativeSignInView.swift b/Sources/RastrilloApple/NativeSignInView.swift
new file mode 100644
index 0000000..8ef2997
--- /dev/null
+++ b/Sources/RastrilloApple/NativeSignInView.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+
+@MainActor public struct NativeSignInView: View {
+ @ObservedObject private var connection: NativeConnection
+ private let name: String
+ private let symbol: String
+ @Environment(\.openURL) private var openURL
+
+ public init(_ name: String, systemImage: String, connection: NativeConnection) {
+ self.name = name
+ symbol = systemImage
+ self.connection = connection
+ }
+ public var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ Image(systemName: symbol).font(.largeTitle).foregroundStyle(.tint)
+ .padding(18).background(.tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 20))
+ .accessibilityHidden(true)
+ Text(name).font(.largeTitle.bold())
+ Text("Your team, wherever you work.").foregroundStyle(.secondary)
+ if connection.signingIn {
+ ProgressView("Finish signing in in your browser")
+ 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) {
+ Text("Server address").font(.headline)
+ TextField("https://your-server", text: $connection.serverAddress)
+ .textFieldStyle(.roundedBorder).accessibilityIdentifier("serverAddress")
+ #if os(iOS)
+ .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
+ #endif
+ .onSubmit(signIn)
+ }
+ Button("Sign in", systemImage: "arrow.right", action: signIn)
+ .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ }
+ if !connection.error.isEmpty {
+ Label(connection.error, systemImage: "exclamationmark.circle")
+ .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true)
+ }
+ }.controlSize(.large).frame(maxWidth: 440, alignment: .leading).padding(32)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ private func signIn() {
+ connection.signIn { url in
+ await withCheckedContinuation { continuation in
+ openURL(url) { accepted in continuation.resume(returning: accepted) }
+ }
+ }
+ }
+}
diff --git a/Tests/RastrilloAppleTests/OriginTests.swift b/Tests/RastrilloAppleTests/OriginTests.swift
new file mode 100644
index 0000000..78a7bd5
--- /dev/null
+++ b/Tests/RastrilloAppleTests/OriginTests.swift
@@ -0,0 +1,18 @@
+import XCTest
+@testable import RastrilloApple
+final class OriginTests: XCTestCase {
+ func testOriginRejectsCredentialAndCrossOriginShapes() throws {
+ for value in ["http://example.com", "https://user:password@example.com", "https://example.com/path", "https://example.com?secret=1", "https://example.com#token", "file:///etc/passwd"] {
+ XCTAssertThrowsError(try NativeHTTP.origin(value))
+ }
+ XCTAssertEqual(try NativeHTTP.origin(" https://example.com/ ").absoluteString, "https://example.com")
+ XCTAssertEqual(try NativeHTTP.origin("http://127.0.0.1:8080").host, "127.0.0.1")
+ }
+ func testRequestCannotOverrideOrigin() async throws {
+ let client = NativeHTTP(origin: try NativeHTTP.origin("https://example.com"))
+ for path in ["//evil.test/x", "https://evil.test", "relative"] {
+ do { _ = try await client.response(path); XCTFail("accepted unsafe path") }
+ catch { XCTAssertEqual(error.localizedDescription, "Invalid server path.") }
+ }
+ }
+}
diff --git a/server/LICENSE b/server/LICENSE
new file mode 100644
index 0000000..570135f
--- /dev/null
+++ b/server/LICENSE
@@ -0,0 +1,374 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
+
diff --git a/server/auth.go b/server/auth.go
new file mode 100644
index 0000000..a4c9fe1
--- /dev/null
+++ b/server/auth.go
@@ -0,0 +1,242 @@
+// Package nativeauth provides proof-bound browser approval for native clients.
+// Apps retain their existing session, admission and resource authorization rules.
+package nativeauth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "html/template"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/rastrillo/csrf"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+type Auth struct {
+ mu sync.Mutex
+ pending map[string]*request
+ sessions *sessions.Sessions
+ require func(http.Handler) http.Handler
+ origin, name string
+}
+type request struct {
+ challenge, cookie string
+ expires time.Time
+}
+
+func New(s *sessions.Sessions, origin, name string, require func(http.Handler) http.Handler) *Auth {
+ return &Auth{pending: map[string]*request{}, sessions: s, origin: origin, name: name, require: require}
+}
+func token() string {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+func valid(s string) bool {
+ b, e := base64.RawURLEncoding.DecodeString(s)
+ return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+func code(id string) string {
+ b := sha256.Sum256([]byte(id))
+ s := strings.ToUpper(hex.EncodeToString(b[:4]))
+ return s[:4] + "-" + s[4:]
+}
+func (a *Auth) find(id string) *request {
+ for key, p := range a.pending {
+ if !time.Now().Before(p.expires) {
+ delete(a.pending, key)
+ }
+ }
+ return a.pending[id]
+}
+func (a *Auth) pendingName() string {
+ if strings.HasPrefix(a.origin, "https:") {
+ return "__Host-native_pending"
+ }
+ return "native_pending"
+}
+func (a *Auth) Resume(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "GET" && r.URL.Path == "/" {
+ if c, e := r.Cookie(a.pendingName()); e == nil && valid(c.Value) {
+ if _, ok := a.sessions.From(r); ok {
+ http.SetCookie(w, &http.Cookie{Name: a.pendingName(), Path: "/", MaxAge: -1, Secure: strings.HasPrefix(a.origin, "https:"), HttpOnly: true, SameSite: http.SameSiteLaxMode})
+ http.Redirect(w, r, "/native/authorize?id="+c.Value, 303)
+ return
+ }
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ csrf.Protect(a.origin)(http.HandlerFunc(a.route)).ServeHTTP(w, r)
+}
+func (a *Auth) route(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == "POST" && r.URL.Path == "/native/request":
+ a.begin(w, r)
+ case r.Method == "POST" && r.URL.Path == "/native/exchange":
+ a.exchange(w, r)
+ case r.Method == "GET" && r.URL.Path == "/native/start":
+ id := r.URL.Query().Get("id")
+ a.mu.Lock()
+ ok := a.find(id) != nil
+ a.mu.Unlock()
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{Name: a.pendingName(), Value: id, Path: "/", MaxAge: 300, Secure: strings.HasPrefix(a.origin, "https:"), HttpOnly: true, SameSite: http.SameSiteLaxMode})
+ http.Redirect(w, r, "/", 303)
+ case r.URL.Path == "/native/authorize" && (r.Method == "GET" || r.Method == "POST"):
+ a.require(http.HandlerFunc(a.authorize)).ServeHTTP(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+}
+func decode(w http.ResponseWriter, r *http.Request, out any) bool {
+ if r.Header.Get("Content-Type") != "application/json" {
+ http.Error(w, "Expected JSON", 415)
+ return false
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 2048)
+ d := json.NewDecoder(r.Body)
+ if d.Decode(out) != nil || d.Decode(new(any)) != io.EOF {
+ http.Error(w, "Invalid request", 400)
+ return false
+ }
+ return true
+}
+func jsonReply(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(value)
+}
+func (a *Auth) begin(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ Challenge string `json:"challenge"`
+ }
+ if !decode(w, r, &in) {
+ return
+ }
+ if !valid(in.Challenge) {
+ http.Error(w, "Invalid proof", 400)
+ return
+ }
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ a.find("")
+ if len(a.pending) >= 256 {
+ http.Error(w, "Try again later", 429)
+ return
+ }
+ id := token()
+ a.pending[id] = &request{challenge: in.Challenge, expires: time.Now().Add(5 * time.Minute)}
+ jsonReply(w, map[string]string{"id": id})
+}
+func (a *Auth) authorize(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ a.mu.Lock()
+ p := a.find(id)
+ ok := p != nil && p.cookie == ""
+ a.mu.Unlock()
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ done := false
+ if r.Method == "POST" {
+ c, e := r.Cookie(a.sessions.CookieName())
+ if e != nil {
+ http.Error(w, "Sign in again", 401)
+ return
+ }
+ a.mu.Lock()
+ p = a.find(id)
+ if p == nil || p.cookie != "" {
+ a.mu.Unlock()
+ http.NotFound(w, r)
+ return
+ }
+ p.cookie = c.Value
+ a.mu.Unlock()
+ done = true
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = approval.Execute(w, struct {
+ Name, Code, ID string
+ Done bool
+ }{a.name, code(id), id, done})
+}
+func (a *Auth) exchange(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ ID string `json:"id"`
+ Verifier string `json:"verifier"`
+ }
+ if !decode(w, r, &in) {
+ return
+ }
+ if !valid(in.ID) || !valid(in.Verifier) {
+ http.Error(w, "Invalid proof", 400)
+ return
+ }
+ hash := sha256.Sum256([]byte(in.Verifier))
+ challenge := base64.RawURLEncoding.EncodeToString(hash[:])
+ a.mu.Lock()
+ p := a.find(in.ID)
+ if p == nil || subtle.ConstantTimeCompare([]byte(p.challenge), []byte(challenge)) != 1 {
+ a.mu.Unlock()
+ http.NotFound(w, r)
+ return
+ }
+ if p.cookie == "" {
+ a.mu.Unlock()
+ w.WriteHeader(202)
+ return
+ }
+ cookie := p.cookie
+ delete(a.pending, in.ID)
+ a.mu.Unlock()
+ // Authenticate the approved cookie from scratch. The outer router may have
+ // attached a different browser session to the exchange request's context.
+ ctx, cancel := context.WithCancel(context.Background())
+ stop := context.AfterFunc(r.Context(), cancel)
+ defer func() { stop(); cancel() }()
+ check := r.Clone(ctx)
+ check.Header = make(http.Header)
+ check.AddCookie(&http.Cookie{Name: a.sessions.CookieName(), Value: cookie})
+ allowed := false
+ a.require(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { allowed = true })).ServeHTTP(discardResponse{}, check)
+ session, ok := a.sessions.From(check)
+ if !allowed || !ok {
+ http.Error(w, "Sign in again", 401)
+ return
+ }
+ value, err := a.sessions.Mint(session)
+ if err != nil {
+ http.Error(w, "Cannot sign in", 500)
+ return
+ }
+ jsonReply(w, map[string]string{"name": a.sessions.CookieName(), "value": value})
+}
+
+type discardResponse struct{}
+
+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>`))
diff --git a/server/auth_test.go b/server/auth_test.go
new file mode 100644
index 0000000..55f2bcd
--- /dev/null
+++ b/server/auth_test.go
@@ -0,0 +1,123 @@
+package nativeauth
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "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"
+)
+
+// Negative cases independently selected by native_review on 2026-09-12.
+// Mutation-checked that day: removing proof, expiry, consume, approval admission,
+// exchange admission or fresh-context handling each fails; all reverted.
+func TestProofApprovalAndRevocation(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)
+ }
+ admitted := map[string]bool{"alice": true, "bob": true}
+ require := func(next http.Handler) http.Handler {
+ return s.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ v, _ := sessions.Current(r)
+ if !admitted[v.Subject] {
+ http.Error(w, "denied", 403)
+ return
+ }
+ next.ServeHTTP(w, r)
+ }))
+ }
+ a := New(s, "https://app.test", "Test", require)
+ alice, _ := s.Mint(sessions.Session{Subject: "alice"})
+ bob, _ := s.Mint(sessions.Session{Subject: "bob"})
+ drive := func(method, path, body, cookie, origin string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, "https://app.test"+path, strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("Origin", origin)
+ if cookie != "" {
+ r.AddCookie(&http.Cookie{Name: s.CookieName(), Value: cookie})
+ }
+ w := httptest.NewRecorder()
+ s.Middleware(a).ServeHTTP(w, r)
+ return w
+ }
+ begin := func() (string, string) {
+ v := token()
+ hash := sha256.Sum256([]byte(v))
+ w := drive("POST", "/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`, "", "https://app.test")
+ if w.Code != 200 {
+ t.Fatalf("begin: %d", w.Code)
+ }
+ var out map[string]string
+ json.Unmarshal(w.Body.Bytes(), &out)
+ return out["id"], v
+ }
+ exchange := func(id, v, cookie string) *httptest.ResponseRecorder {
+ return drive("POST", "/native/exchange", `{"id":"`+id+`","verifier":"`+v+`"}`, cookie, "https://app.test")
+ }
+ approve := func(id, cookie string) *httptest.ResponseRecorder {
+ return drive("POST", "/native/authorize?id="+id, "", cookie, "https://app.test")
+ }
+ id, v := begin()
+ if w := exchange(id, v, ""); w.Code != 202 {
+ t.Fatalf("pending %d", w.Code)
+ }
+ if w := drive("POST", "/native/authorize?id="+id, "", alice, "https://evil.test"); w.Code < 400 {
+ t.Fatal("cross-origin approval accepted")
+ }
+ if w := approve(id, alice); w.Code != 200 {
+ t.Fatalf("approve %d", w.Code)
+ }
+ if w := exchange(id, token(), ""); w.Code != 404 {
+ t.Fatal("wrong proof accepted")
+ }
+ w := exchange(id, v, "")
+ if w.Code != 200 {
+ t.Fatalf("exchange %d", w.Code)
+ }
+ var out map[string]string
+ json.Unmarshal(w.Body.Bytes(), &out)
+ r := httptest.NewRequest("GET", "https://app.test/", nil)
+ r.AddCookie(&http.Cookie{Name: out["name"], Value: out["value"]})
+ got, ok := s.From(r)
+ if !ok || got.Subject != "alice" || out["value"] == alice {
+ t.Fatal("exchange did not mint separate approved identity")
+ }
+ if exchange(id, v, "").Code != 404 {
+ t.Fatal("replay accepted")
+ }
+ id, v = begin()
+ a.pending[id].expires = time.Now().Add(-time.Second)
+ if exchange(id, v, "").Code != 404 {
+ t.Fatal("expired proof accepted")
+ }
+ id, v = begin()
+ if approve(id, alice).Code != 200 {
+ t.Fatal("approval failed")
+ }
+ admitted["alice"] = false
+ // An authenticated exchange caller must never substitute its admission for
+ // the identity approved in the browser (the review's concrete regression).
+ if exchange(id, v, bob).Code != 401 {
+ t.Fatal("revoked approval accepted through another session's context")
+ }
+ id, _ = begin()
+ if approve(id, alice).Code != 403 {
+ t.Fatal("removed member could approve")
+ }
+}
diff --git a/server/go.mod b/server/go.mod
new file mode 100644
index 0000000..1a7ae7e
--- /dev/null
+++ b/server/go.mod
@@ -0,0 +1,23 @@
+module amadan.net/rastrillo/native/server
+
+go 1.25.0
+
+require github.com/carlosframework/rastrillo v0.23.0
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.20.0 // indirect
+ gorm.io/gorm v1.31.2 // indirect
+ gorm.io/plugin/dbresolver v1.6.2 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
diff --git a/server/go.sum b/server/go.sum
new file mode 100644
index 0000000..6715d56
--- /dev/null
+++ b/server/go.sum
@@ -0,0 +1,73 @@
+github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
+github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q=
+github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/tools/bundle-mac.py b/tools/bundle-mac.py
new file mode 100644
index 0000000..8150826
--- /dev/null
+++ b/tools/bundle-mac.py
@@ -0,0 +1,34 @@
+"""Bundle a SwiftPM app with the suite's padded macOS icon silhouette."""
+from pathlib import Path
+import argparse, plistlib, shutil, subprocess
+parser = argparse.ArgumentParser()
+parser.add_argument("native", type=Path)
+parser.add_argument("name")
+args = parser.parse_args()
+root = args.native.resolve()
+name = args.name
+subprocess.run(["swift", "build", "--package-path", str(root / "apple")], check=True)
+bundle = root / f"build/Oficina {name}.app/Contents"
+for directory in ["MacOS", "Resources"]:
+ (bundle / directory).mkdir(parents=True, exist_ok=True)
+shutil.copy2(root / f"apple/.build/debug/{name}Mac", bundle / f"MacOS/{name}Mac")
+shutil.copy2(root / "LICENSE.lucide", bundle / "Resources/LICENSE.lucide")
+icon = root / "build/MacIcon.png"
+subprocess.run(["swift", str(Path(__file__).with_name("mac-icon.swift")), str(root / "ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png"), str(icon)], check=True)
+iconset = root / "build/AppIcon.iconset"
+iconset.mkdir(exist_ok=True)
+for size in [16, 32, 128, 256, 512]:
+ for scale in [1, 2]:
+ filename = f"icon_{size}x{size}" + ("@2x" if scale == 2 else "") + ".png"
+ subprocess.run(["sips", "-z", str(size * scale), str(size * scale), str(icon), "--out", str(iconset / filename)], check=True, stdout=subprocess.DEVNULL)
+subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(bundle / "Resources/AppIcon.icns")], check=True)
+(bundle / "Info.plist").write_bytes(plistlib.dumps({
+ "CFBundleName": f"Oficina {name}", "CFBundleDisplayName": f"Oficina {name}",
+ "CFBundleIdentifier": f"net.amadan.oficina.{name.lower()}",
+ "CFBundleExecutable": f"{name}Mac", "CFBundlePackageType": "APPL",
+ "CFBundleIconFile": "AppIcon.icns", "CFBundleShortVersionString": "0.1.0",
+ "CFBundleVersion": "1", "LSMinimumSystemVersion": "14.0",
+ "NSHighResolutionCapable": True,
+}))
+subprocess.run(["codesign", "--force", "--deep", "--sign", "-", str(bundle.parent)], check=True)
+print(bundle.parent)
diff --git a/tools/mac-icon.swift b/tools/mac-icon.swift
new file mode 100644
index 0000000..27ef20f
--- /dev/null
+++ b/tools/mac-icon.swift
@@ -0,0 +1,23 @@
+import AppKit
+
+// macOS displays the supplied silhouette; unlike iOS it does not mask an opaque master.
+// Keep the approved square master intact and derive a padded Mac icon from it.
+let sourceURL = URL(fileURLWithPath: CommandLine.arguments[1])
+let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
+guard let source = NSImage(contentsOf: sourceURL),
+ let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
+ let context = NSGraphicsContext(bitmapImageRep: bitmap)
+else { fatalError("Cannot load or render the app’s icon") }
+NSGraphicsContext.saveGraphicsState()
+NSGraphicsContext.current = context
+context.imageInterpolation = .high
+let tile = NSRect(x: 100, y: 100, width: 824, height: 824)
+NSBezierPath(roundedRect: tile, xRadius: 184, yRadius: 184).addClip()
+source.draw(in: tile, from: .zero, operation: .copy, fraction: 1)
+NSGraphicsContext.restoreGraphicsState()
+guard let png = bitmap.representation(using: .png, properties: [:]) else {
+ fatalError("Cannot encode the app’s icon")
+}
+try png.write(to: outputURL)