| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // 🤖 Pins the native OIDC-door contract against home's Go trampoline tests |
| 5 | // (home/oidc_test.go, home/github_test.go): the callback fragment shapes the |
| 6 | // server emits are reproduced here verbatim, so client parsing and server |
| 7 | // emission can't drift apart silently. |
| 8 | final class HomeOIDCAuthTests: XCTestCase { |
| 9 | private let base = URL(string: "https://home.example.com")! |
| 10 | |
| 11 | // ---- authorize URLs ----------------------------------------------------- |
| 12 | |
| 13 | func testGoogleAuthorizeURL() throws { |
| 14 | let url = try XCTUnwrap(HomeOIDCAuth.authorizeURL( |
| 15 | .google, base: base, state: "ios.abc", nonce: "n0nce")) |
| 16 | XCTAssertEqual(url.host, "accounts.google.com") |
| 17 | XCTAssertEqual(url.path, "/o/oauth2/v2/auth") |
| 18 | let q = try queryDict(url) |
| 19 | XCTAssertEqual(q["client_id"], HomeOIDCAuth.googleClientID) |
| 20 | XCTAssertEqual(q["redirect_uri"], "https://home.example.com/oidc/return") |
| 21 | XCTAssertEqual(q["response_type"], "id_token") |
| 22 | XCTAssertEqual(q["response_mode"], "form_post") |
| 23 | XCTAssertEqual(q["scope"], "openid") |
| 24 | XCTAssertEqual(q["nonce"], "n0nce") |
| 25 | XCTAssertEqual(q["state"], "ios.abc") |
| 26 | } |
| 27 | |
| 28 | func testMicrosoftAuthorizeURL() throws { |
| 29 | let url = try XCTUnwrap(HomeOIDCAuth.authorizeURL( |
| 30 | .microsoft, base: base, state: "mac.xyz", nonce: "n1")) |
| 31 | XCTAssertEqual(url.host, "login.microsoftonline.com") |
| 32 | XCTAssertEqual(url.path, "/common/oauth2/v2.0/authorize") |
| 33 | let q = try queryDict(url) |
| 34 | XCTAssertEqual(q["client_id"], HomeOIDCAuth.microsoftClientID) |
| 35 | XCTAssertEqual(q["state"], "mac.xyz") |
| 36 | } |
| 37 | |
| 38 | func testGitHubAuthorizeURLNeedsClientID() throws { |
| 39 | XCTAssertNil(HomeOIDCAuth.authorizeURL(.github, base: base, state: "s", nonce: "n")) |
| 40 | XCTAssertNil(HomeOIDCAuth.authorizeURL(.github, base: base, state: "s", nonce: "n", |
| 41 | githubClientID: "")) |
| 42 | let url = try XCTUnwrap(HomeOIDCAuth.authorizeURL( |
| 43 | .github, base: base, state: "ios.s1", nonce: "ignored", |
| 44 | githubClientID: "Ov23liTESTID")) |
| 45 | XCTAssertEqual(url.host, "github.com") |
| 46 | let q = try queryDict(url) |
| 47 | XCTAssertEqual(q["client_id"], "Ov23liTESTID") |
| 48 | XCTAssertEqual(q["redirect_uri"], "https://home.example.com/oidc/return") |
| 49 | XCTAssertEqual(q["state"], "ios.s1") |
| 50 | // Code flow: no OIDC params ride along. |
| 51 | XCTAssertNil(q["nonce"]) |
| 52 | XCTAssertNil(q["response_type"]) |
| 53 | } |
| 54 | |
| 55 | /// The web trampoline routes on this prefix — a native state must never |
| 56 | /// carry it, or the flow lands on the web page instead of the app. |
| 57 | func testNativeStatesNeverWebPrefixed() { |
| 58 | for state in ["ios." + HomeAnchorAuth.mintNonce(), "mac." + HomeAnchorAuth.mintNonce()] { |
| 59 | XCTAssertFalse(state.hasPrefix("web.")) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // ---- callback parsing (fragments verbatim from the Go tests) ------------ |
| 64 | |
| 65 | func testCallbackParsesIDToken() throws { |
| 66 | // oidc_test.go: elevenmessenger://home-oidc#state=...&id_token=tok.en.value |
| 67 | let url = URL(string: "elevenmessenger://home-oidc#state=ios.abc&id_token=tok.en.value")! |
| 68 | let cb = try XCTUnwrap(HomeOIDCAuth.callback(url, state: "ios.abc")) |
| 69 | XCTAssertEqual(cb.idToken, "tok.en.value") |
| 70 | XCTAssertNil(cb.code) |
| 71 | } |
| 72 | |
| 73 | func testCallbackParsesCode() throws { |
| 74 | // github_test.go: elevenmessenger://home-oidc#state=mac.r2&code=abc |
| 75 | let url = URL(string: "elevenmessenger://home-oidc#state=mac.r2&code=abc")! |
| 76 | let cb = try XCTUnwrap(HomeOIDCAuth.callback(url, state: "mac.r2")) |
| 77 | XCTAssertEqual(cb.code, "abc") |
| 78 | XCTAssertNil(cb.idToken) |
| 79 | } |
| 80 | |
| 81 | func testCallbackRejectsWrongStateHostOrEmpty() { |
| 82 | let ok = URL(string: "elevenmessenger://home-oidc#state=ios.a&id_token=t")! |
| 83 | XCTAssertNil(HomeOIDCAuth.callback(ok, state: "ios.b"), "state must round-trip") |
| 84 | let wrongHost = URL(string: "elevenmessenger://home-siwa#state=ios.a&id_token=t")! |
| 85 | XCTAssertNil(HomeOIDCAuth.callback(wrongHost, state: "ios.a")) |
| 86 | let bare = URL(string: "elevenmessenger://home-oidc#state=ios.a")! |
| 87 | XCTAssertNil(HomeOIDCAuth.callback(bare, state: "ios.a"), "no credential → start over") |
| 88 | } |
| 89 | |
| 90 | func testCallbackDecodesPercentEncoding() throws { |
| 91 | // The trampoline url.QueryEscapes both fields; percent-decoding must |
| 92 | // happen exactly once. |
| 93 | let url = URL(string: "elevenmessenger://home-oidc#state=ios.a&id_token=a%2Fb%3Dc")! |
| 94 | let cb = try XCTUnwrap(HomeOIDCAuth.callback(url, state: "ios.a")) |
| 95 | XCTAssertEqual(cb.idToken, "a/b=c") |
| 96 | } |
| 97 | |
| 98 | private func queryDict(_ url: URL) throws -> [String: String] { |
| 99 | let items = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) |
| 100 | return Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value ?? "") }) |
| 101 | } |
| 102 | } |
| 103 | |