Add shared Apple browser sign-in and development app bundling
Co-Authored-By: Codex <codex~paul@keymail.dev>
15 files changed,
+1211
−2
APPLE.md+29 −0Makefile+3 −1Package.swift+4 −1Sources/RastrilloApple/NativeConnection.swift+93 −0Sources/RastrilloApple/NativeCredentials.swift+32 −0Sources/RastrilloApple/NativeHTTP.swift+83 −0Sources/RastrilloApple/NativeSignInView.swift+57 −0Tests/RastrilloAppleTests/OriginTests.swift+18 −0server/LICENSE+374 −0server/auth.go+242 −0server/auth_test.go+123 −0server/go.mod+23 −0server/go.sum+73 −0tools/bundle-mac.py+34 −0tools/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) |