eleven / messenger Public

ios+mac: Google / Microsoft / GitHub sign-in doors (#1278)

* ios+mac: Google / Microsoft / GitHub sign-in doors (server legs were live)

The native halves of the three web-flow doors: an
ASWebAuthenticationSession over the provider's authorize URL, home's
/oidc/return trampoline bouncing to elevenmessenger://home-oidc (handed
back in-process — no Launch Services, no Info.plist change), then the
provider verify and the same anchor landing as Apple/email. Shared pure
half (URL builders + callback parser) in LChatCore's HomeOIDCAuth,
pinned by tests against home's Go trampoline tests.

home's /api/features gains a doors block — google/microsoft liveness
plus GitHub's per-instance client id, the one value natives can't
compile in (prod and dev are separate GitHub OAuth apps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: guard a failed ASWebAuthenticationSession start — never wedge the doors

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 27e47ae6329e17eae059c589b60eebd645cc0e7d parent 15a182c
20 files changed, +637 −9
  • home/provision.go +14 −0
  • home/provision_test.go +44 −0
  • ios/LChat/Assets.xcassets/DoorGitHub.imageset/Contents.json +16 −0
  • ios/LChat/Assets.xcassets/DoorGitHub.imageset/github.svg +1 −0
  • ios/LChat/Assets.xcassets/DoorGoogle.imageset/Contents.json +15 −0
  • ios/LChat/Assets.xcassets/DoorGoogle.imageset/google.svg +1 −0
  • ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/Contents.json +15 −0
  • ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg +1 −0
  • ios/LChat/Views/SignInWithEleven.swift +130 −5
  • ios/LChatCore/Package.resolved +9 −0
  • ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift +37 −0
  • ios/LChatCore/Sources/LChatCore/HomeOIDCAuth.swift +115 −0
  • ios/LChatCore/Tests/LChatCoreTests/HomeOIDCAuthTests.swift +102 −0
  • ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/Contents.json +16 −0
  • ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/github.svg +1 −0
  • ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/Contents.json +15 −0
  • ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/google.svg +1 −0
  • ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/Contents.json +15 −0
  • ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg +1 −0
  • ios/LChatMac/MacHomeSignIn.swift +88 −4
diff --git a/home/provision.go b/home/provision.go
index 9e80661..73fe29c 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -168,6 +168,20 @@ func (s *Server) featuresHandler(w http.ResponseWriter, r *http.Request) {
"spaces": s.cloud != nil,
"demo": s.demo != nil && s.cloud != nil,
}
+ // Which of the web-flow sign-in doors are lit — for the NATIVE apps,
+ // which can't read index.html's meta tag or the web's registry. Booleans
+ // plus GitHub's client id, the one value natives can't compile in (prod
+ // and dev are separate GitHub OAuth apps); a client id is a public
+ // identifier, not a secret — Google's and Microsoft's ship in client
+ // code on every platform.
+ doors := map[string]any{
+ "google": s.oidc["google"] != nil,
+ "microsoft": s.oidc["microsoft"] != nil,
+ }
+ if s.github != nil {
+ doors["github_client_id"] = s.github.clientID
+ }
+ out["doors"] = doors
// When home provisions per country, advertise the regions — code + name
// only, never a url or token — and the default, so the create UI can offer
// the choice. Absent → the client shows no region control (single-region).
diff --git a/home/provision_test.go b/home/provision_test.go
index d65080f..8d6576f 100644
--- a/home/provision_test.go
+++ b/home/provision_test.go
@@ -79,6 +79,50 @@ func TestFeaturesReflectsProvisioner(t *testing.T) {
}
}
+// The doors block tells native clients which web-flow doors are lit — and
+// carries GitHub's per-instance client id, the one value they can't compile
+// in. Dark doors read false / absent; never an error.
+func TestFeaturesAdvertisesSigninDoors(t *testing.T) {
+ ts, s := newTestServer(t)
+
+ get := func() map[string]any {
+ t.Helper()
+ resp, err := http.Get(ts.URL + "/api/features")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var f struct {
+ Doors map[string]any `json:"doors"`
+ }
+ json.NewDecoder(resp.Body).Decode(&f)
+ if f.Doors == nil {
+ t.Fatal("features: no doors block")
+ }
+ return f.Doors
+ }
+
+ dark := get()
+ if dark["google"] != false || dark["microsoft"] != false {
+ t.Errorf("dark: google=%v microsoft=%v, want false/false", dark["google"], dark["microsoft"])
+ }
+ if _, ok := dark["github_client_id"]; ok {
+ t.Errorf("dark: github_client_id present, want absent")
+ }
+
+ s.oidc = map[string]*oidcProvider{
+ "google": newOIDCProvider("google", []string{"cid"}, []byte("pepper")),
+ }
+ s.github = &githubDoor{clientID: "Ov23liTESTID", secret: "sec", pepper: []byte("p")}
+ lit := get()
+ if lit["google"] != true || lit["microsoft"] != false {
+ t.Errorf("lit: google=%v microsoft=%v, want true/false", lit["google"], lit["microsoft"])
+ }
+ if lit["github_client_id"] != "Ov23liTESTID" {
+ t.Errorf("lit: github_client_id = %v, want Ov23liTESTID", lit["github_client_id"])
+ }
+}
+
func TestCreateSpaceRequiresAuth(t *testing.T) {
ts, _ := newTestServer(t)
if resp := postSpaces(t, ts, ""); resp.StatusCode != http.StatusUnauthorized {
diff --git a/ios/LChat/Assets.xcassets/DoorGitHub.imageset/Contents.json b/ios/LChat/Assets.xcassets/DoorGitHub.imageset/Contents.json
new file mode 100644
index 0000000..fded408
--- /dev/null
+++ b/ios/LChat/Assets.xcassets/DoorGitHub.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images": [
+ {
+ "filename": "github.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true,
+ "template-rendering-intent": "template"
+ }
+}
diff --git a/ios/LChat/Assets.xcassets/DoorGitHub.imageset/github.svg b/ios/LChat/Assets.xcassets/DoorGitHub.imageset/github.svg
new file mode 100644
index 0000000..83d087c
--- /dev/null
+++ b/ios/LChat/Assets.xcassets/DoorGitHub.imageset/github.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="#000000"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
diff --git a/ios/LChat/Assets.xcassets/DoorGoogle.imageset/Contents.json b/ios/LChat/Assets.xcassets/DoorGoogle.imageset/Contents.json
new file mode 100644
index 0000000..d1e944a
--- /dev/null
+++ b/ios/LChat/Assets.xcassets/DoorGoogle.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "google.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/ios/LChat/Assets.xcassets/DoorGoogle.imageset/google.svg b/ios/LChat/Assets.xcassets/DoorGoogle.imageset/google.svg
new file mode 100644
index 0000000..104fda5
--- /dev/null
+++ b/ios/LChat/Assets.xcassets/DoorGoogle.imageset/google.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>
diff --git a/ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/Contents.json b/ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/Contents.json
new file mode 100644
index 0000000..4f0f9f3
--- /dev/null
+++ b/ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "microsoft.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg b/ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg
new file mode 100644
index 0000000..d0127a7
--- /dev/null
+++ b/ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="21" height="21"><rect x="1" y="1" width="9" height="9" fill="#F25022"/><rect x="11" y="1" width="9" height="9" fill="#7FBA00"/><rect x="1" y="11" width="9" height="9" fill="#00A4EF"/><rect x="11" y="11" width="9" height="9" fill="#FFB900"/></svg>
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
index 0acb3f2..5df0c7a 100644
--- a/ios/LChat/Views/SignInWithEleven.swift
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -52,7 +52,7 @@ final class HomeSignInModel: ObservableObject {
/// under the web's key name so the rule reads across: the last-used
/// method leads the doors next time (a future "Sign-in options" surface
/// reads the same marker).
- enum SignInMethod: String { case passkey, apple, email }
+ enum SignInMethod: String { case passkey, apple, email, google, microsoft, github }
static let lastSignInKey = "home_last_signin"
static var lastSignIn: SignInMethod? {
UserDefaults.standard.string(forKey: lastSignInKey).flatMap(SignInMethod.init(rawValue:))
@@ -285,6 +285,90 @@ final class HomeSignInModel: ObservableObject {
}
}
+ // ---- the web-flow doors: Google / Microsoft / GitHub (home/oidc.go) -----
+
+ /// Which of the web-flow doors this home server has lit, plus GitHub's
+ /// per-instance client id — fetched once when the doors show. Nil (not
+ /// yet fetched / older server) just means those doors don't render.
+ @Published private(set) var oidcDoors: HomeClient.SigninDoors?
+
+ /// The in-flight browser session — retained on the model, or the system
+ /// tears it down mid-flow.
+ private var webAuth: ASWebAuthenticationSession?
+ private let webAuthPresenter = WebAuthPresenter()
+
+ func refreshDoors() async {
+ oidcDoors = await HomeClient().signinDoors()
+ }
+
+ /// One browser round trip: authorize URL → home's /oidc/return
+ /// trampoline → elevenmessenger://home-oidc, handed back in-process by
+ /// the session (no Launch Services, no onOpenURL) → verify → the same
+ /// anchor landing as Apple and email. The state must not be
+ /// "web."-prefixed — that routes the trampoline to the web page.
+ func signInWithOidc(_ door: OIDCDoor, into app: AppState) async {
+ phase = .working
+ let state = "ios." + HomeAnchorAuth.mintNonce()
+ let nonce = HomeAnchorAuth.mintNonce()
+ guard let url = HomeOIDCAuth.authorizeURL(door, base: HomeBase.url, state: state,
+ nonce: nonce,
+ githubClientID: oidcDoors?.githubClientID) else {
+ phase = .failed("\(door.display) sign-in isn't available right now.")
+ return
+ }
+ do {
+ let cb = try await runWebAuth(url: url)
+ guard let parsed = HomeOIDCAuth.callback(cb, state: state) else {
+ phase = .failed("That sign-in didn't complete — try again.")
+ return
+ }
+ let fin: HomeClient.AnchorFinish
+ if door == .github, let code = parsed.code {
+ fin = try await HomeClient().githubVerify(code: code)
+ } else if let idToken = parsed.idToken {
+ fin = try await HomeClient().oidcVerify(provider: door.rawValue,
+ identityToken: idToken, nonce: nonce)
+ } else {
+ phase = .failed("That sign-in didn't complete — try again.")
+ return
+ }
+ phase = await finishAnchor(fin, method: SignInMethod(rawValue: door.rawValue) ?? .email)
+ } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin {
+ // Closing the browser sheet is a choice, not an error — the same
+ // quiet return as Apple's cancel.
+ phase = .idle
+ } catch {
+ phase = .failed(Self.anchorMessage(error))
+ }
+ }
+
+ private func runWebAuth(url: URL) async throws -> URL {
+ defer { webAuth = nil }
+ return try await withCheckedThrowingContinuation { cont in
+ let session = ASWebAuthenticationSession(
+ url: url, callbackURLScheme: HomeOIDCAuth.callbackScheme) { cb, err in
+ if let cb {
+ cont.resume(returning: cb)
+ } else {
+ cont.resume(throwing: err ?? HomeClientError.network)
+ }
+ }
+ // Share the system browser's cookies: an already-signed-in
+ // Google/Microsoft/GitHub user gets a one-tap consent — the same
+ // choice the Mac SiwA web flow makes.
+ session.prefersEphemeralWebBrowserSession = false
+ session.presentationContextProvider = webAuthPresenter
+ webAuth = session
+ // A failed start never calls the completion handler — resuming
+ // here keeps the continuation (and the whole doors screen, which
+ // gates on phase.busy) from wedging forever. Same guard as the
+ // Mac's runWebAuth.
+ if !session.start() {
+ cont.resume(throwing: ASWebAuthenticationSessionError(.presentationContextInvalid))
+ }
+ }
+ }
+
/// An anchor's landing: adopt the token (no DEK — an Apple/email sign-in
/// proves who you are but can never decrypt; the backup's key lives
/// behind a passkey's PRF), then report what the account holds: fresh or
@@ -409,7 +493,7 @@ final class HomeSignInModel: ObservableObject {
}
// The key window backs the ASAuthorizationController's presentation anchor.
- private static func keyWindow() -> UIWindow? {
+ fileprivate static func keyWindow() -> UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
@@ -417,6 +501,14 @@ final class HomeSignInModel: ObservableObject {
}
}
+// The web-auth sheet's presentation anchor — the app's first ASWebAuthentication-
+// Session (the Mac has its own in MacHomeSignIn).
+private final class WebAuthPresenter: NSObject, ASWebAuthenticationPresentationContextProviding {
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
+ HomeSignInModel.keyWindow() ?? ASPresentationAnchor()
+ }
+}
+
// The first-run screen: the doors. Apple leads on iOS (the platform default),
// email beneath it, and the passkey path demoted to a quiet text button — with
// the last-used method promoted to lead (the web's #1112 rule, mirrored
@@ -499,15 +591,24 @@ struct SignInFirstRun: View {
code = ""
}
}
+ // Which web-flow doors this home server has lit (and GitHub's
+ // client id) — fetched once per screen; until it lands the three
+ // fixed doors stand alone, same as an older server.
+ .task { await model.refreshDoors() }
}
// ---- the doors ----------------------------------------------------------
- /// Apple, email, passkey — with the last-used method promoted to lead.
- /// After a sealed anchor sign-in the passkey leads regardless: it is the
- /// one door that actually opens the backup.
+ /// Apple, email, passkey — plus whichever web-flow doors the home server
+ /// has lit (Google/Microsoft/GitHub, in the web's fixed brand order) —
+ /// with the last-used method promoted to lead. After a sealed anchor
+ /// sign-in the passkey leads regardless: it is the one door that
+ /// actually opens the backup.
private var doorOrder: [HomeSignInModel.SignInMethod] {
var order: [HomeSignInModel.SignInMethod] = [.apple, .email, .passkey]
+ if model.oidcDoors?.githubClientID?.isEmpty == false { order.append(.github) }
+ if model.oidcDoors?.microsoft == true { order.append(.microsoft) }
+ if model.oidcDoors?.google == true { order.append(.google) }
let lead: HomeSignInModel.SignInMethod? =
model.phase == .sealed ? .passkey : HomeSignInModel.lastSignIn
if let lead, let i = order.firstIndex(of: lead) {
@@ -621,6 +722,30 @@ struct SignInFirstRun: View {
}
.buttonStyle(DoorButtonStyle(prominent: leading))
.disabled(model.phase.busy)
+ case .google, .microsoft, .github:
+ // The web-flow doors: a browser sheet, not a native one — same
+ // row geometry, the provider's mark carrying the brand.
+ let door = OIDCDoor(rawValue: method.rawValue) ?? .google
+ Button {
+ tapped = method
+ Task { await model.signInWithOidc(door, into: app) }
+ } label: {
+ HStack(spacing: 8) {
+ if model.phase == .working && tapped == method {
+ ProgressView()
+ .controlSize(.small)
+ .tint(leading ? .white : nil)
+ } else {
+ Image("Door\(door.display)")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 18, height: 18)
+ }
+ Text("Continue with \(door.display)")
+ }
+ }
+ .buttonStyle(DoorButtonStyle(prominent: leading))
+ .disabled(model.phase.busy)
}
}
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
index d82f91f..fe25d1c 100644
--- a/ios/LChatCore/Package.resolved
+++ b/ios/LChatCore/Package.resolved
@@ -1,5 +1,14 @@
{
"pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
+ "version" : "2.9.4"
+ }
+ },
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
diff --git a/ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift b/ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift
index 1956af8..1796476 100644
--- a/ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift
+++ b/ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift
@@ -42,6 +42,43 @@ extension HomeClient {
try await postJSON("/api/email/verify", ["email": email, "code": code])
}
+ /// POST /api/oidc/{provider}/verify — a Google/Microsoft identity token
+ /// in, a session out (oidc.go answers the same contract as SiwA). The
+ /// nonce is the very string the authorize URL carried; home checks the
+ /// token's nonce claim against it verbatim.
+ public func oidcVerify(provider: String, identityToken: String, nonce: String) async throws -> AnchorFinish {
+ try await postJSON("/api/oidc/\(provider)/verify",
+ ["identity_token": identityToken, "nonce": nonce])
+ }
+
+ /// POST /api/oidc/github/verify — GitHub's single-use code in, a session
+ /// out; the server holds the client secret and does the exchange
+ /// (github.go), so no token or nonce ever reaches the client.
+ public func githubVerify(code: String) async throws -> AnchorFinish {
+ try await postJSON("/api/oidc/github/verify", ["code": code])
+ }
+
+ /// GET /api/features' doors block — which web-flow doors this home
+ /// server has lit, plus GitHub's per-instance client id. Nil on any
+ /// error or an older server: callers simply don't show those doors.
+ public struct SigninDoors: Decodable, Equatable, Sendable {
+ public let google: Bool?
+ public let microsoft: Bool?
+ public let githubClientID: String?
+ enum CodingKeys: String, CodingKey {
+ case google, microsoft
+ case githubClientID = "github_client_id"
+ }
+ }
+
+ public func signinDoors() async -> SigninDoors? {
+ struct R: Decodable { let doors: SigninDoors? }
+ guard let (data, resp) = try? await URLSession.shared.data(from: base.appending(path: "/api/features")),
+ (resp as? HTTPURLResponse)?.statusCode == 200
+ else { return nil }
+ return (try? JSONDecoder().decode(R.self, from: data))?.doors
+ }
+
private func postJSON<T: Decodable>(_ path: String, _ body: [String: String]) async throws -> T {
var req = URLRequest(url: base.appending(path: path))
req.httpMethod = "POST"
diff --git a/ios/LChatCore/Sources/LChatCore/HomeOIDCAuth.swift b/ios/LChatCore/Sources/LChatCore/HomeOIDCAuth.swift
new file mode 100644
index 0000000..c24f2e4
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HomeOIDCAuth.swift
@@ -0,0 +1,115 @@
+import Foundation
+
+// 🤖 The web-flow doors — Google, Microsoft, GitHub — as native sign-ins
+// (the client half of home/oidc.go + github.go; the server legs shipped
+// first). Unlike Apple, these have no native sheet on any platform: each
+// app runs an ASWebAuthenticationSession over the provider's authorize URL,
+// home's /oidc/return trampoline bounces the result to
+// elevenmessenger://home-oidc, and the session hands that callback straight
+// back IN-PROCESS — the scheme match is the session's own, not Launch
+// Services', so alt bundles with suffixed schemes work unchanged and no
+// Info.plist or onOpenURL change is needed.
+//
+// This file is the pure half — URL builders and the callback parser —
+// shared by both platforms and pinned by HomeOIDCAuthTests against home's
+// Go trampoline tests (oidc_test.go / github_test.go).
+public enum OIDCDoor: String, CaseIterable, Sendable {
+ case google, microsoft, github
+
+ public var display: String {
+ switch self {
+ case .google: return "Google"
+ case .microsoft: return "Microsoft"
+ case .github: return "GitHub"
+ }
+ }
+}
+
+public enum HomeOIDCAuth {
+ /// A provider's client id is a public identifier and lives here in code,
+ /// mirroring the web (home.js) verbatim. GitHub's is per-instance (prod
+ /// and dev are separate GitHub OAuth apps) and rides /api/features'
+ /// doors block instead — see HomeClient.signinDoors().
+ public static let googleClientID =
+ "981276018948-b4cr3ctqi6cr9e8rs2fmqouip1hmd9iq.apps.googleusercontent.com"
+ public static let microsoftClientID = "25874cf3-213c-4124-9641-65de8a513787"
+
+ /// ASWebAuthenticationSession's callbackURLScheme — the scheme half of
+ /// home's oidcReturnScheme (elevenmessenger://home-oidc). Deliberately
+ /// NOT the bundle's (possibly alt-suffixed) registered scheme: the
+ /// session matches this string in-process.
+ public static let callbackScheme = "elevenmessenger"
+
+ /// The authorize URL a browser session opens. `state` must NOT start
+ /// with "web." — that prefix routes home's trampoline back to the web
+ /// page; anything else bounces to the native scheme. Google/Microsoft
+ /// run the OIDC id_token form_post flow (nonce required — send the SAME
+ /// string in the verify body so home checks the round trip); GitHub is
+ /// the code-exchange flow, no nonce — the server holds the secret and
+ /// swaps the code itself. Nil only for GitHub without a client id (the
+ /// door is dark on that home server).
+ public static func authorizeURL(_ door: OIDCDoor, base: URL, state: String,
+ nonce: String, githubClientID: String? = nil) -> URL? {
+ let redirect = base.appending(path: "/oidc/return").absoluteString
+ var c: URLComponents
+ switch door {
+ case .google:
+ c = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
+ c.queryItems = idTokenQuery(clientID: googleClientID, redirect: redirect,
+ state: state, nonce: nonce)
+ case .microsoft:
+ // The "common" endpoint admits any tenant plus personal
+ // accounts — oidc.go's checkIssuer handles the per-tenant iss.
+ c = URLComponents(string: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize")!
+ c.queryItems = idTokenQuery(clientID: microsoftClientID, redirect: redirect,
+ state: state, nonce: nonce)
+ case .github:
+ guard let id = githubClientID, !id.isEmpty else { return nil }
+ c = URLComponents(string: "https://github.com/login/oauth/authorize")!
+ c.queryItems = [
+ URLQueryItem(name: "client_id", value: id),
+ URLQueryItem(name: "redirect_uri", value: redirect),
+ URLQueryItem(name: "state", value: state),
+ ]
+ }
+ return c.url
+ }
+
+ private static func idTokenQuery(clientID: String, redirect: String,
+ state: String, nonce: String) -> [URLQueryItem] {
+ [
+ URLQueryItem(name: "client_id", value: clientID),
+ URLQueryItem(name: "redirect_uri", value: redirect),
+ URLQueryItem(name: "response_type", value: "id_token"),
+ URLQueryItem(name: "response_mode", value: "form_post"),
+ URLQueryItem(name: "scope", value: "openid"),
+ URLQueryItem(name: "nonce", value: nonce),
+ URLQueryItem(name: "state", value: state),
+ ]
+ }
+
+ /// What the trampoline bounced back: an id_token (Google/Microsoft) or a
+ /// single-use code (GitHub) — both ride the URL FRAGMENT, kept out of
+ /// logs, parsed with query semantics exactly like the Mac SiwA callback.
+ public struct Callback: Equatable {
+ public let idToken: String?
+ public let code: String?
+ }
+
+ /// Nil when the URL isn't ours, the state doesn't round-trip, or no
+ /// credential rode along — every nil is "start over", never a crash.
+ public static func callback(_ url: URL, state expected: String) -> Callback? {
+ guard url.host == "home-oidc",
+ let raw = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedFragment
+ else { return nil }
+ var frag = URLComponents()
+ frag.percentEncodedQuery = raw
+ let items = frag.queryItems ?? []
+ func value(_ name: String) -> String? { items.first { $0.name == name }?.value }
+ guard value("state") == expected else { return nil }
+ let idToken = value("id_token")
+ let code = value("code")
+ guard idToken != nil || code != nil else { return nil }
+ return Callback(idToken: idToken, code: code)
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/HomeOIDCAuthTests.swift b/ios/LChatCore/Tests/LChatCoreTests/HomeOIDCAuthTests.swift
new file mode 100644
index 0000000..8cbf1fc
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/HomeOIDCAuthTests.swift
@@ -0,0 +1,102 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Pins the native OIDC-door contract against home's Go trampoline tests
+// (home/oidc_test.go, home/github_test.go): the callback fragment shapes the
+// server emits are reproduced here verbatim, so client parsing and server
+// emission can't drift apart silently.
+final class HomeOIDCAuthTests: XCTestCase {
+ private let base = URL(string: "https://home.example.com")!
+
+ // ---- authorize URLs -----------------------------------------------------
+
+ func testGoogleAuthorizeURL() throws {
+ let url = try XCTUnwrap(HomeOIDCAuth.authorizeURL(
+ .google, base: base, state: "ios.abc", nonce: "n0nce"))
+ XCTAssertEqual(url.host, "accounts.google.com")
+ XCTAssertEqual(url.path, "/o/oauth2/v2/auth")
+ let q = try queryDict(url)
+ XCTAssertEqual(q["client_id"], HomeOIDCAuth.googleClientID)
+ XCTAssertEqual(q["redirect_uri"], "https://home.example.com/oidc/return")
+ XCTAssertEqual(q["response_type"], "id_token")
+ XCTAssertEqual(q["response_mode"], "form_post")
+ XCTAssertEqual(q["scope"], "openid")
+ XCTAssertEqual(q["nonce"], "n0nce")
+ XCTAssertEqual(q["state"], "ios.abc")
+ }
+
+ func testMicrosoftAuthorizeURL() throws {
+ let url = try XCTUnwrap(HomeOIDCAuth.authorizeURL(
+ .microsoft, base: base, state: "mac.xyz", nonce: "n1"))
+ XCTAssertEqual(url.host, "login.microsoftonline.com")
+ XCTAssertEqual(url.path, "/common/oauth2/v2.0/authorize")
+ let q = try queryDict(url)
+ XCTAssertEqual(q["client_id"], HomeOIDCAuth.microsoftClientID)
+ XCTAssertEqual(q["state"], "mac.xyz")
+ }
+
+ func testGitHubAuthorizeURLNeedsClientID() throws {
+ XCTAssertNil(HomeOIDCAuth.authorizeURL(.github, base: base, state: "s", nonce: "n"))
+ XCTAssertNil(HomeOIDCAuth.authorizeURL(.github, base: base, state: "s", nonce: "n",
+ githubClientID: ""))
+ let url = try XCTUnwrap(HomeOIDCAuth.authorizeURL(
+ .github, base: base, state: "ios.s1", nonce: "ignored",
+ githubClientID: "Ov23liTESTID"))
+ XCTAssertEqual(url.host, "github.com")
+ let q = try queryDict(url)
+ XCTAssertEqual(q["client_id"], "Ov23liTESTID")
+ XCTAssertEqual(q["redirect_uri"], "https://home.example.com/oidc/return")
+ XCTAssertEqual(q["state"], "ios.s1")
+ // Code flow: no OIDC params ride along.
+ XCTAssertNil(q["nonce"])
+ XCTAssertNil(q["response_type"])
+ }
+
+ /// The web trampoline routes on this prefix — a native state must never
+ /// carry it, or the flow lands on the web page instead of the app.
+ func testNativeStatesNeverWebPrefixed() {
+ for state in ["ios." + HomeAnchorAuth.mintNonce(), "mac." + HomeAnchorAuth.mintNonce()] {
+ XCTAssertFalse(state.hasPrefix("web."))
+ }
+ }
+
+ // ---- callback parsing (fragments verbatim from the Go tests) ------------
+
+ func testCallbackParsesIDToken() throws {
+ // oidc_test.go: elevenmessenger://home-oidc#state=...&id_token=tok.en.value
+ let url = URL(string: "elevenmessenger://home-oidc#state=ios.abc&id_token=tok.en.value")!
+ let cb = try XCTUnwrap(HomeOIDCAuth.callback(url, state: "ios.abc"))
+ XCTAssertEqual(cb.idToken, "tok.en.value")
+ XCTAssertNil(cb.code)
+ }
+
+ func testCallbackParsesCode() throws {
+ // github_test.go: elevenmessenger://home-oidc#state=mac.r2&code=abc
+ let url = URL(string: "elevenmessenger://home-oidc#state=mac.r2&code=abc")!
+ let cb = try XCTUnwrap(HomeOIDCAuth.callback(url, state: "mac.r2"))
+ XCTAssertEqual(cb.code, "abc")
+ XCTAssertNil(cb.idToken)
+ }
+
+ func testCallbackRejectsWrongStateHostOrEmpty() {
+ let ok = URL(string: "elevenmessenger://home-oidc#state=ios.a&id_token=t")!
+ XCTAssertNil(HomeOIDCAuth.callback(ok, state: "ios.b"), "state must round-trip")
+ let wrongHost = URL(string: "elevenmessenger://home-siwa#state=ios.a&id_token=t")!
+ XCTAssertNil(HomeOIDCAuth.callback(wrongHost, state: "ios.a"))
+ let bare = URL(string: "elevenmessenger://home-oidc#state=ios.a")!
+ XCTAssertNil(HomeOIDCAuth.callback(bare, state: "ios.a"), "no credential → start over")
+ }
+
+ func testCallbackDecodesPercentEncoding() throws {
+ // The trampoline url.QueryEscapes both fields; percent-decoding must
+ // happen exactly once.
+ let url = URL(string: "elevenmessenger://home-oidc#state=ios.a&id_token=a%2Fb%3Dc")!
+ let cb = try XCTUnwrap(HomeOIDCAuth.callback(url, state: "ios.a"))
+ XCTAssertEqual(cb.idToken, "a/b=c")
+ }
+
+ private func queryDict(_ url: URL) throws -> [String: String] {
+ let items = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
+ return Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value ?? "") })
+ }
+}
diff --git a/ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/Contents.json b/ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/Contents.json
new file mode 100644
index 0000000..fded408
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images": [
+ {
+ "filename": "github.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true,
+ "template-rendering-intent": "template"
+ }
+}
diff --git a/ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/github.svg b/ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/github.svg
new file mode 100644
index 0000000..83d087c
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/github.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="#000000"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
diff --git a/ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/Contents.json b/ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/Contents.json
new file mode 100644
index 0000000..d1e944a
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "google.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/google.svg b/ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/google.svg
new file mode 100644
index 0000000..104fda5
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/google.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>
diff --git a/ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/Contents.json b/ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/Contents.json
new file mode 100644
index 0000000..4f0f9f3
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "microsoft.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg b/ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg
new file mode 100644
index 0000000..d0127a7
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="21" height="21"><rect x="1" y="1" width="9" height="9" fill="#F25022"/><rect x="11" y="1" width="9" height="9" fill="#7FBA00"/><rect x="1" y="11" width="9" height="9" fill="#00A4EF"/><rect x="11" y="11" width="9" height="9" fill="#FFB900"/></svg>
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift
index e04ba28..875654d 100644
--- a/ios/LChatMac/MacHomeSignIn.swift
+++ b/ios/LChatMac/MacHomeSignIn.swift
@@ -47,7 +47,7 @@ final class MacHomeSignIn: ObservableObject {
/// Which door signed in — the #1112 last-used marker, stored under the
/// web's key name so the rule reads across platforms (iOS mirrors it the
/// same way): the last-used method leads the doors next time.
- enum SignInMethod: String { case passkey, apple, email }
+ enum SignInMethod: String { case passkey, apple, email, google, microsoft, github }
static let lastSignInKey = "home_last_signin"
static var lastSignIn: SignInMethod? {
UserDefaults.standard.string(forKey: lastSignInKey).flatMap(SignInMethod.init(rawValue:))
@@ -345,6 +345,58 @@ final class MacHomeSignIn: ObservableObject {
return token
}
+ // ---- the web-flow doors: Google / Microsoft / GitHub (home/oidc.go) ----
+
+ /// Which of the web-flow doors this home server has lit, plus GitHub's
+ /// per-instance client id — fetched once when the doors show. Nil (not
+ /// yet fetched / older server) just means those doors don't render.
+ @Published private(set) var oidcDoors: HomeClient.SigninDoors?
+
+ func refreshDoors() async {
+ oidcDoors = await HomeClient().signinDoors()
+ }
+
+ /// One browser round trip, the SiwA web flow's exact shape: authorize
+ /// URL -> home's /oidc/return trampoline -> elevenmessenger://home-oidc,
+ /// intercepted in-process by the session -> verify -> the same anchor
+ /// landing as Apple and email. Google/Microsoft carry a nonce the verify
+ /// round-trips; GitHub is the code exchange (the server holds the
+ /// secret).
+ func signInWithOidc(_ door: OIDCDoor, into app: MacAppState) async {
+ let state = "mac." + HomeAnchorAuth.mintNonce()
+ let nonce = HomeAnchorAuth.mintNonce()
+ guard let url = HomeOIDCAuth.authorizeURL(door, base: HomeBase.url, state: state,
+ nonce: nonce,
+ githubClientID: oidcDoors?.githubClientID) else {
+ phase = .failed("\(door.display) sign-in isn't available right now.")
+ return
+ }
+ phase = .working
+ defer { webAuthSession = nil }
+ do {
+ let callback = try await runWebAuth(url: url)
+ guard let parsed = HomeOIDCAuth.callback(callback, state: state) else {
+ phase = .failed("That sign-in didn't complete — try again.")
+ return
+ }
+ let fin: HomeClient.AnchorFinish
+ if door == .github, let code = parsed.code {
+ fin = try await HomeClient().githubVerify(code: code)
+ } else if let idToken = parsed.idToken {
+ fin = try await HomeClient().oidcVerify(provider: door.rawValue,
+ identityToken: idToken, nonce: nonce)
+ } else {
+ phase = .failed("That sign-in didn't complete — try again.")
+ return
+ }
+ phase = await finishAnchor(fin, method: SignInMethod(rawValue: door.rawValue) ?? .email)
+ } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin {
+ phase = .idle // closing the provider's window is a choice, not an error
+ } catch {
+ phase = .failed(Self.anchorMessage(error))
+ }
+ }
+
// ---- the email door (issue #1091; server: home/email.go) ---------------
/// Step one: send the code. True advances the door to the code step. The
@@ -620,11 +672,16 @@ struct MacFirstRun: View {
// ---- the doors ---------------------------------------------------------
- /// Apple, email, passkey — the last-used method promoted to lead. After
- /// a sealed anchor sign-in the passkey leads regardless: it is the one
- /// door that actually opens the backup.
+ /// Apple, email, passkey — plus whichever web-flow doors the home server
+ /// has lit (Google/Microsoft/GitHub, in the web's fixed brand order) —
+ /// the last-used method promoted to lead. After a sealed anchor sign-in
+ /// the passkey leads regardless: it is the one door that actually opens
+ /// the backup.
private var doorOrder: [MacHomeSignIn.SignInMethod] {
var order: [MacHomeSignIn.SignInMethod] = [.apple, .email, .passkey]
+ if model.oidcDoors?.githubClientID?.isEmpty == false { order.append(.github) }
+ if model.oidcDoors?.microsoft == true { order.append(.microsoft) }
+ if model.oidcDoors?.google == true { order.append(.google) }
let lead: MacHomeSignIn.SignInMethod? =
model.phase == .sealed ? .passkey : MacHomeSignIn.lastSignIn
if let lead, let i = order.firstIndex(of: lead) {
@@ -672,6 +729,10 @@ struct MacFirstRun: View {
}
.frame(width: 280)
.padding(.bottom, 48)
+ // Which web-flow doors this home server has lit (and GitHub's client
+ // id) — fetched when the doors show; until it lands the three fixed
+ // doors stand alone, same as an older server.
+ .task { await model.refreshDoors() }
}
@ViewBuilder private func door(_ method: MacHomeSignIn.SignInMethod, leading: Bool) -> some View {
@@ -751,6 +812,29 @@ struct MacFirstRun: View {
.buttonStyle(MacDoorButtonStyle())
.disabled(model.phase.busy)
}
+ case .google, .microsoft, .github:
+ // The web-flow doors: a browser sheet over the same session seam
+ // — same row geometry, the provider's mark carrying the brand.
+ let door = OIDCDoor(rawValue: method.rawValue) ?? .google
+ Button {
+ tapped = method
+ Task { await model.signInWithOidc(door, into: app) }
+ } label: {
+ HStack(spacing: 6) {
+ if model.phase == .working && tapped == method {
+ ProgressView().controlSize(.small)
+ .tint(leading ? .white : nil)
+ } else {
+ Image("Door\(door.display)")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 14, height: 14)
+ }
+ Text("Continue with \(door.display)")
+ }
+ }
+ .buttonStyle(MacDoorButtonStyle(prominent: leading))
+ .disabled(model.phase.busy)
}
}