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>
20 files changed,
+637
−9
home/provision.go+14 −0home/provision_test.go+44 −0ios/LChat/Assets.xcassets/DoorGitHub.imageset/Contents.json+16 −0ios/LChat/Assets.xcassets/DoorGitHub.imageset/github.svg+1 −0ios/LChat/Assets.xcassets/DoorGoogle.imageset/Contents.json+15 −0ios/LChat/Assets.xcassets/DoorGoogle.imageset/google.svg+1 −0ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/Contents.json+15 −0ios/LChat/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg+1 −0ios/LChat/Views/SignInWithEleven.swift+130 −5ios/LChatCore/Package.resolved+9 −0ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift+37 −0ios/LChatCore/Sources/LChatCore/HomeOIDCAuth.swift+115 −0ios/LChatCore/Tests/LChatCoreTests/HomeOIDCAuthTests.swift+102 −0ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/Contents.json+16 −0ios/LChatMac/Assets.xcassets/DoorGitHub.imageset/github.svg+1 −0ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/Contents.json+15 −0ios/LChatMac/Assets.xcassets/DoorGoogle.imageset/google.svg+1 −0ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/Contents.json+15 −0ios/LChatMac/Assets.xcassets/DoorMicrosoft.imageset/microsoft.svg+1 −0ios/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) |
| } |
| } |