| 1 | import Foundation |
| 2 | |
| 3 | // π€ The web-flow doors β Google, Microsoft, GitHub β as native sign-ins |
| 4 | // (the client half of home/oidc.go + github.go; the server legs shipped |
| 5 | // first). Unlike Apple, these have no native sheet on any platform: each |
| 6 | // app runs an ASWebAuthenticationSession over the provider's authorize URL, |
| 7 | // home's /oidc/return trampoline bounces the result to |
| 8 | // elevenmessenger://home-oidc, and the session hands that callback straight |
| 9 | // back IN-PROCESS β the scheme match is the session's own, not Launch |
| 10 | // Services', so alt bundles with suffixed schemes work unchanged and no |
| 11 | // Info.plist or onOpenURL change is needed. |
| 12 | // |
| 13 | // This file is the pure half β URL builders and the callback parser β |
| 14 | // shared by both platforms and pinned by HomeOIDCAuthTests against home's |
| 15 | // Go trampoline tests (oidc_test.go / github_test.go). |
| 16 | public enum OIDCDoor: String, CaseIterable, Sendable { |
| 17 | case google, microsoft, github |
| 18 | |
| 19 | public var display: String { |
| 20 | switch self { |
| 21 | case .google: return "Google" |
| 22 | case .microsoft: return "Microsoft" |
| 23 | case .github: return "GitHub" |
| 24 | } |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | public enum HomeOIDCAuth { |
| 29 | /// A provider's client id is a public identifier and lives here in code, |
| 30 | /// mirroring the web (home.js) verbatim. GitHub's is per-instance (prod |
| 31 | /// and dev are separate GitHub OAuth apps) and rides /api/features' |
| 32 | /// doors block instead β see HomeClient.signinDoors(). |
| 33 | public static let googleClientID = |
| 34 | "981276018948-b4cr3ctqi6cr9e8rs2fmqouip1hmd9iq.apps.googleusercontent.com" |
| 35 | public static let microsoftClientID = "25874cf3-213c-4124-9641-65de8a513787" |
| 36 | |
| 37 | /// ASWebAuthenticationSession's callbackURLScheme β the scheme half of |
| 38 | /// home's oidcReturnScheme (elevenmessenger://home-oidc). Deliberately |
| 39 | /// NOT the bundle's (possibly alt-suffixed) registered scheme: the |
| 40 | /// session matches this string in-process. |
| 41 | public static let callbackScheme = "elevenmessenger" |
| 42 | |
| 43 | /// The authorize URL a browser session opens. `state` must NOT start |
| 44 | /// with "web." β that prefix routes home's trampoline back to the web |
| 45 | /// page; anything else bounces to the native scheme. Google/Microsoft |
| 46 | /// run the OIDC id_token form_post flow (nonce required β send the SAME |
| 47 | /// string in the verify body so home checks the round trip); GitHub is |
| 48 | /// the code-exchange flow, no nonce β the server holds the secret and |
| 49 | /// swaps the code itself. Nil only for GitHub without a client id (the |
| 50 | /// door is dark on that home server). |
| 51 | public static func authorizeURL(_ door: OIDCDoor, base: URL, state: String, |
| 52 | nonce: String, githubClientID: String? = nil) -> URL? { |
| 53 | let redirect = base.appending(path: "/oidc/return").absoluteString |
| 54 | var c: URLComponents |
| 55 | switch door { |
| 56 | case .google: |
| 57 | c = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")! |
| 58 | c.queryItems = idTokenQuery(clientID: googleClientID, redirect: redirect, |
| 59 | state: state, nonce: nonce) |
| 60 | case .microsoft: |
| 61 | // The "common" endpoint admits any tenant plus personal |
| 62 | // accounts β oidc.go's checkIssuer handles the per-tenant iss. |
| 63 | c = URLComponents(string: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize")! |
| 64 | c.queryItems = idTokenQuery(clientID: microsoftClientID, redirect: redirect, |
| 65 | state: state, nonce: nonce) |
| 66 | case .github: |
| 67 | guard let id = githubClientID, !id.isEmpty else { return nil } |
| 68 | c = URLComponents(string: "https://github.com/login/oauth/authorize")! |
| 69 | c.queryItems = [ |
| 70 | URLQueryItem(name: "client_id", value: id), |
| 71 | URLQueryItem(name: "redirect_uri", value: redirect), |
| 72 | URLQueryItem(name: "state", value: state), |
| 73 | ] |
| 74 | } |
| 75 | return c.url |
| 76 | } |
| 77 | |
| 78 | private static func idTokenQuery(clientID: String, redirect: String, |
| 79 | state: String, nonce: String) -> [URLQueryItem] { |
| 80 | [ |
| 81 | URLQueryItem(name: "client_id", value: clientID), |
| 82 | URLQueryItem(name: "redirect_uri", value: redirect), |
| 83 | URLQueryItem(name: "response_type", value: "id_token"), |
| 84 | URLQueryItem(name: "response_mode", value: "form_post"), |
| 85 | URLQueryItem(name: "scope", value: "openid"), |
| 86 | URLQueryItem(name: "nonce", value: nonce), |
| 87 | URLQueryItem(name: "state", value: state), |
| 88 | ] |
| 89 | } |
| 90 | |
| 91 | /// What the trampoline bounced back: an id_token (Google/Microsoft) or a |
| 92 | /// single-use code (GitHub) β both ride the URL FRAGMENT, kept out of |
| 93 | /// logs, parsed with query semantics exactly like the Mac SiwA callback. |
| 94 | public struct Callback: Equatable { |
| 95 | public let idToken: String? |
| 96 | public let code: String? |
| 97 | } |
| 98 | |
| 99 | /// Nil when the URL isn't ours, the state doesn't round-trip, or no |
| 100 | /// credential rode along β every nil is "start over", never a crash. |
| 101 | public static func callback(_ url: URL, state expected: String) -> Callback? { |
| 102 | guard url.host == "home-oidc", |
| 103 | let raw = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedFragment |
| 104 | else { return nil } |
| 105 | var frag = URLComponents() |
| 106 | frag.percentEncodedQuery = raw |
| 107 | let items = frag.queryItems ?? [] |
| 108 | func value(_ name: String) -> String? { items.first { $0.name == name }?.value } |
| 109 | guard value("state") == expected else { return nil } |
| 110 | let idToken = value("id_token") |
| 111 | let code = value("code") |
| 112 | guard idToken != nil || code != nil else { return nil } |
| 113 | return Callback(idToken: idToken, code: code) |
| 114 | } |
| 115 | } |
| 116 | |