~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
526 lines · 21.7 KB Raw History
@testable import Grain
import XCTest

/// A stand-in network: each URL (without its query) answers as set, and every
/// request is kept.
private final class Network: @unchecked Sendable {
    struct Answer {
        var status = 200
        var headers: [String: String] = ["Content-Type": "application/json"]
        var body = "{}"
    }

    private let lock = NSLock()
    private var routes: [String: [Answer]] = [:]
    private var seen: [URLRequest] = []

    /// Answer `url` with `answers` in turn, the last one from then on.
    func on(_ url: String, _ answers: Answer...) {
        lock.withLock { routes[url] = answers }
    }

    func on(_ url: String, json: String) {
        on(url, Answer(body: json))
    }

    func answer(_ request: URLRequest) throws -> (Data, HTTPURLResponse) {
        var components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
        components.query = nil
        let key = components.url!.absoluteString
        let answer: Answer? = lock.withLock {
            seen.append(request)
            guard var list = routes[key], !list.isEmpty else { return nil }
            let next = list.removeFirst()
            if list.isEmpty {
                list = [next]
            }
            routes[key] = list
            return next
        }
        guard let answer else { throw URLError(.cannotFindHost) }
        let response = HTTPURLResponse(url: request.url!, statusCode: answer.status, httpVersion: nil, headerFields: answer.headers)!
        return (Data(answer.body.utf8), response)
    }

    func requests(to url: String) -> [URLRequest] {
        lock.withLock { seen.filter { $0.url?.absoluteString.hasPrefix(url) == true } }
    }

    var all: [URLRequest] {
        lock.withLock { seen }
    }

    static func form(_ request: URLRequest) -> [String: String] {
        var data = request.httpBody
        if data == nil, let stream = request.httpBodyStream {
            stream.open()
            defer { stream.close() }
            var collected = Data()
            let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 4096)
            defer { buffer.deallocate() }
            while stream.hasBytesAvailable {
                let read = stream.read(buffer, maxLength: 4096)
                guard read > 0 else { break }
                collected.append(buffer, count: read)
            }
            data = collected
        }
        guard let data, let body = String(data: data, encoding: .utf8) else { return [:] }
        var form: [String: String] = [:]
        for pair in body.split(separator: "&") {
            let parts = pair.split(separator: "=", maxSplits: 1).map(String.init)
            guard parts.count == 2 else { continue }
            form[parts[0].removingPercentEncoding ?? parts[0]] = parts[1].removingPercentEncoding ?? parts[1]
        }
        return form
    }
}

/// Signing in at the account's own PDS: resolving who the user is, finding
/// where they sign in, the PAR/PKCE/DPoP exchange, and the checks that keep
/// one server from handing over another's account.
@MainActor
final class OAuthSignInTests: GrainTestCase {
    private var network: Network!
    private let did = "did:plc:alice"
    private let pds = "https://pds.alice.test"
    private let issuer = "https://auth.alice.test"

    override func setUp() async throws {
        try await super.setUp()
        network = Network()
        let network = network!
        MockURLProtocol.handler = { try network.answer($0) }
    }

    override func tearDown() async throws {
        MockURLProtocol.handler = nil
        for account in TokenStorage.accounts {
            TokenStorage.removeAccount(account.did)
            try? DPoP.clearKey(for: account.did)
        }
        try await super.tearDown()
    }

    // MARK: - Fixtures

    private func didDocument(_ did: String, handle: String, pds: String) -> String {
        """
        {"id": "\(did)", "alsoKnownAs": ["at://\(handle)"],
         "service": [{"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "\(pds)"}]}
        """
    }

    private func serveAuthorizationServer(_ issuer: String, for pds: String, calls name: String? = nil) {
        network.on("\(pds)/.well-known/oauth-protected-resource", json: #"{"resource": "\#(pds)", "authorization_servers": ["\#(issuer)"]}"#)
        network.on("\(issuer)/.well-known/oauth-authorization-server", json: """
        {"issuer": "\(name ?? issuer)",
         "authorization_endpoint": "\(issuer)/oauth/authorize",
         "token_endpoint": "\(issuer)/oauth/token",
         "pushed_authorization_request_endpoint": "\(issuer)/oauth/par",
         "revocation_endpoint": "\(issuer)/oauth/revoke"}
        """)
    }

    /// alice.test: a handle on her own domain, a PLC DID, and a PDS whose
    /// authorization server lives at another origin.
    private func serveAlice() {
        network.on("https://alice.test/.well-known/atproto-did", Network.Answer(headers: [:], body: did))
        network.on("https://plc.directory/\(did)", json: didDocument(did, handle: "alice.test", pds: pds))
        serveAuthorizationServer(issuer, for: pds)
        network.on("\(issuer)/oauth/par", json: #"{"request_uri": "urn:ietf:params:oauth:request_uri:req-1", "expires_in": 60}"#)
        tokens(sub: did)
    }

    private func tokens(sub: String) {
        network.on("\(issuer)/oauth/token", json: """
        {"access_token": "access-1", "token_type": "DPoP", "expires_in": 3600,
         "refresh_token": "refresh-1", "sub": "\(sub)", "scope": "\(AuthManager.scope)"}
        """)
    }

    /// The user approving whatever was asked, the browser coming back with
    /// the request's own state unless `query` says otherwise.
    private func approve(_ auth: AuthManager, query: ((String) -> String)? = nil) {
        let network = network!
        let issuer = issuer
        auth.presentAuthorization = { url in
            let par = network.all.last { $0.url?.path.hasSuffix("/oauth/par") == true }
            let state = par.map(Network.form)?["state"] ?? ""
            let params = query?(state) ?? "code=the-code&state=\(state)&iss=\(issuer.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)"
            XCTAssertTrue(url.absoluteString.hasPrefix("\(issuer)/oauth/authorize?"))
            return URL(string: "\(AuthManager.redirectURI)?\(params)")!
        }
    }

    // MARK: - Identity

    func testAHandleResolvesThroughItsWellKnownFirst() async throws {
        serveAlice()

        let identity = try await IdentityResolver(session: MockURLProtocol.mockSession()).resolve("@Alice.test")

        XCTAssertEqual(identity, try ResolvedIdentity(did: did, handle: "alice.test", pds: XCTUnwrap(URL(string: pds))))
        XCTAssertTrue(network.requests(to: "https://public.api.bsky.app").isEmpty)
    }

    func testAHandleFallsBackToTheAppViewResolver() async throws {
        network.on("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", json: #"{"did": "\#(did)"}"#)
        network.on("https://plc.directory/\(did)", json: didDocument(did, handle: "alice.test", pds: pds))

        let identity = try await IdentityResolver(session: MockURLProtocol.mockSession()).resolve("alice.test")

        XCTAssertEqual(identity.did, did)
        let asked = try XCTUnwrap(network.requests(to: "https://public.api.bsky.app").first?.url)
        XCTAssertEqual(URLComponents(url: asked, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "alice.test")
    }

    /// A DID typed directly takes its handle from the document, once that
    /// handle resolves back to it.
    func testADIDTakesTheHandleItsDocumentClaims() async throws {
        serveAlice()

        let identity = try await IdentityResolver(session: MockURLProtocol.mockSession()).resolve(did)

        XCTAssertEqual(identity.handle, "alice.test")
        XCTAssertEqual(identity.pds.absoluteString, pds)
    }

    func testAHandleThatDoesNotPointBackIsNotTaken() async throws {
        network.on("https://plc.directory/\(did)", json: didDocument(did, handle: "mallory.test", pds: pds))
        network.on("https://mallory.test/.well-known/atproto-did", Network.Answer(headers: [:], body: "did:plc:mallory"))

        let identity = try await IdentityResolver(session: MockURLProtocol.mockSession()).resolve(did)

        XCTAssertNil(identity.handle)
    }

    func testADidWebDocumentComesFromItsHost() async throws {
        network.on("https://web.example:8443/.well-known/did.json", json: didDocument("did:web:web.example%3A8443", handle: "web.example", pds: pds))

        let identity = try await IdentityResolver(session: MockURLProtocol.mockSession()).resolve("did:web:web.example%3A8443")

        XCTAssertEqual(identity.pds.absoluteString, pds)
    }

    func testAnUnknownHandleSaysSo() async {
        do {
            _ = try await IdentityResolver(session: MockURLProtocol.mockSession()).resolve("nobody.test")
            XCTFail("Expected handleNotFound")
        } catch {
            XCTAssertEqual(error as? OAuthError, .handleNotFound("nobody.test"))
        }
    }

    // MARK: - Discovery

    /// The metadata must name the issuer the PDS pointed at: else any server
    /// could claim to sign in for another.
    func testAnAuthorizationServerCallingItselfSomethingElseIsRefused() async throws {
        serveAuthorizationServer(issuer, for: pds, calls: "https://elsewhere.test")
        let client = OAuthClient(session: MockURLProtocol.mockSession(), clientID: AuthManager.clientID, redirectURI: AuthManager.redirectURI)

        do {
            _ = try await client.server(forPDS: XCTUnwrap(URL(string: pds)))
            XCTFail("Expected issuerMismatch")
        } catch {
            XCTAssertEqual(error as? OAuthError, .issuerMismatch(expected: issuer, found: "https://elsewhere.test"))
        }
    }

    // MARK: - Sign in

    func testTheRequestedScopeIsExactlyGrains() {
        XCTAssertEqual(
            AuthManager.scope,
            "atproto blob:image/* repo:social.grain.gallery repo:social.grain.gallery.item repo:social.grain.photo "
                + "repo:social.grain.photo.exif repo:social.grain.actor.profile repo:social.grain.graph.follow "
                + "repo:social.grain.favorite repo:social.grain.comment repo:social.grain.story repo:social.grain.graph.block "
                + "repo:app.bsky.feed.post?action=create&action=update rpc:*?aud=did:web:grain.social%23grain_appview"
        )
        XCTAssertEqual(AuthManager.clientID, "https://grain.social/oauth/app-client-metadata.json")
        XCTAssertEqual(AuthManager.appviewService, "did:web:grain.social#grain_appview")
    }

    /// The PDS URL-decodes the audience, so a `did:web` port's `%3A` is
    /// escaped again, while the proxy header carries it as it is.
    func testTheRPCScopeEscapesAPortedAudience() {
        XCTAssertEqual(
            AuthManager.rpcScope(audience: "did:web:localhost%3A3000#grain_appview"),
            "rpc:*?aud=did:web:localhost%253A3000%23grain_appview"
        )
        XCTAssertEqual(
            AuthManager.rpcScope(audience: "did:web:grain.social#grain_appview"),
            "rpc:*?aud=did:web:grain.social%23grain_appview"
        )
    }

    func testSigningInPushesTheRequestToTheAccountsOwnServer() async throws {
        serveAlice()
        let auth = AuthManager()
        approve(auth)

        try await auth.login(handle: "alice.test")

        let par = try XCTUnwrap(network.requests(to: "\(issuer)/oauth/par").last)
        let form = Network.form(par)
        XCTAssertEqual(form["client_id"], AuthManager.clientID)
        XCTAssertEqual(form["redirect_uri"], "social.grain:/oauth/callback")
        XCTAssertEqual(form["response_type"], "code")
        XCTAssertEqual(form["code_challenge_method"], "S256")
        XCTAssertEqual(form["scope"], AuthManager.scope, "the scope survives form encoding intact")
        XCTAssertEqual(form["login_hint"], "alice.test")
        XCTAssertFalse(form["state"]?.isEmpty ?? true)
        XCTAssertNotNil(par.value(forHTTPHeaderField: "DPoP"))

        let token = try XCTUnwrap(network.requests(to: "\(issuer)/oauth/token").last)
        let tokenForm = Network.form(token)
        XCTAssertEqual(tokenForm["grant_type"], "authorization_code")
        XCTAssertEqual(tokenForm["code"], "the-code")
        XCTAssertEqual(tokenForm["client_id"], AuthManager.clientID)
        XCTAssertEqual(OAuthClient.challenge(for: tokenForm["code_verifier"] ?? ""), form["code_challenge"])
    }

    func testASignedInAccountKeepsWhereItSignedIn() async throws {
        serveAlice()
        let auth = AuthManager()
        approve(auth)

        try await auth.login(handle: "alice.test")

        XCTAssertEqual(auth.userDID, did)
        XCTAssertEqual(auth.userHandle, "alice.test")
        XCTAssertEqual(TokenStorage.server(for: did), try AccountServer(
            pds: XCTUnwrap(URL(string: pds)),
            issuer: issuer,
            tokenEndpoint: XCTUnwrap(URL(string: "\(issuer)/oauth/token")),
            revocationEndpoint: XCTUnwrap(URL(string: "\(issuer)/oauth/revoke"))
        ))
        XCTAssertEqual(TokenStorage.grantedScope(for: did), AuthManager.scope)
        let context = await auth.authContext()
        XCTAssertEqual(context?.pds.absoluteString, pds)
        XCTAssertEqual(context?.accessToken, "access-1")
    }

    /// An authorization server asks for a nonce on the first PAR; the request
    /// goes again with it.
    func testAParNonceRefusalIsSentAgainWithTheNonce() async throws {
        serveAlice()
        network.on(
            "\(issuer)/oauth/par",
            Network.Answer(status: 400, headers: ["DPoP-Nonce": "as-nonce"], body: #"{"error": "use_dpop_nonce"}"#),
            Network.Answer(body: #"{"request_uri": "urn:req", "expires_in": 60}"#)
        )
        let auth = AuthManager()
        approve(auth)

        try await auth.login(handle: "alice.test")

        XCTAssertEqual(network.requests(to: "\(issuer)/oauth/par").count, 2)
        XCTAssertEqual(try DPoPNonces.shared.nonce(for: XCTUnwrap(URL(string: issuer))), "as-nonce")
        XCTAssertEqual(auth.userDID, did)
    }

    func testABrowserReturnWithAnotherStateIsRefused() async {
        serveAlice()
        let auth = AuthManager()
        approve(auth) { _ in "code=the-code&state=forged" }

        do {
            try await auth.login(handle: "alice.test")
            XCTFail("Expected callbackMismatch")
        } catch {
            XCTAssertEqual(error as? OAuthError, .callbackMismatch)
        }
        XCTAssertTrue(network.requests(to: "\(issuer)/oauth/token").isEmpty)
        XCTAssertFalse(auth.isAuthenticated)
    }

    func testABrowserReturnFromAnotherIssuerIsRefused() async {
        serveAlice()
        let auth = AuthManager()
        approve(auth) { state in "code=c&state=\(state)&iss=https%3A%2F%2Felsewhere.test" }

        do {
            try await auth.login(handle: "alice.test")
            XCTFail("Expected callbackMismatch")
        } catch {
            XCTAssertEqual(error as? OAuthError, .callbackMismatch)
        }
    }

    func testDenyingIsTheDeniedError() async {
        serveAlice()
        let auth = AuthManager()
        approve(auth) { state in "error=access_denied&state=\(state)" }

        do {
            try await auth.login(handle: "alice.test")
            XCTFail("Expected authorizationDenied")
        } catch XRPCError.authorizationDenied {
            // expected
        } catch {
            XCTFail("Expected authorizationDenied, got \(error)")
        }
    }

    /// Tokens for someone other than the account asked for are not kept.
    func testTokensForAnotherAccountAreRefused() async {
        serveAlice()
        tokens(sub: "did:plc:mallory")
        let auth = AuthManager()
        approve(auth)

        do {
            try await auth.login(handle: "alice.test")
            XCTFail("Expected wrongAccount")
        } catch {
            XCTAssertEqual(error as? OAuthError, .wrongAccount(expected: did, found: "did:plc:mallory"))
        }
        XCTAssertNil(TokenStorage.accessToken(for: "did:plc:mallory"))
        XCTAssertFalse(auth.isAuthenticated)
    }

    // MARK: - Sign up

    /// The sign-up PDS here is its own authorization server.
    private func serveSignUp(newAccountPDS: String) {
        let signUp = AuthManager.signUpPDS.absoluteString
        serveAuthorizationServer(signUp, for: signUp)
        network.on("\(signUp)/oauth/par", json: #"{"request_uri": "urn:req", "expires_in": 60}"#)
        network.on("\(signUp)/oauth/token", json: """
        {"access_token": "a", "token_type": "DPoP", "expires_in": 3600, "refresh_token": "r", "sub": "did:plc:newbie"}
        """)
        network.on("https://plc.directory/did:plc:newbie", json: didDocument("did:plc:newbie", handle: "newbie.selfhosted.social", pds: newAccountPDS))
        network.on("https://newbie.selfhosted.social/.well-known/atproto-did", Network.Answer(headers: [:], body: "did:plc:newbie"))
    }

    private func approveSignUp(_ auth: AuthManager) {
        let network = network!
        auth.presentAuthorization = { _ in
            let par = network.all.last { $0.url?.path.hasSuffix("/oauth/par") == true }
            let state = par.map(Network.form)?["state"] ?? ""
            return URL(string: "\(AuthManager.redirectURI)?code=c&state=\(state)")!
        }
    }

    func testSigningUpAsksForTheCreatePromptAndTakesTheNewAccount() async throws {
        serveSignUp(newAccountPDS: AuthManager.signUpPDS.absoluteString)
        let auth = AuthManager()
        approveSignUp(auth)

        try await auth.login(createAccount: true)

        let form = try Network.form(XCTUnwrap(network.requests(to: "\(AuthManager.signUpPDS.absoluteString)/oauth/par").last))
        XCTAssertEqual(form["prompt"], "create")
        XCTAssertNil(form["login_hint"])
        XCTAssertEqual(auth.userDID, "did:plc:newbie")
        XCTAssertEqual(auth.userHandle, "newbie.selfhosted.social")
    }

    /// An account whose PDS signs in elsewhere was not made here.
    func testASignUpNamingAnAccountHostedElsewhereIsRefused() async {
        serveSignUp(newAccountPDS: pds)
        serveAuthorizationServer(issuer, for: pds)
        let auth = AuthManager()
        approveSignUp(auth)

        do {
            try await auth.login(createAccount: true)
            XCTFail("Expected accountElsewhere")
        } catch {
            XCTAssertEqual(error as? OAuthError, .accountElsewhere(did: "did:plc:newbie"))
        }
        XCTAssertFalse(auth.isAuthenticated)
    }

    // MARK: - Sign out

    private func storeAlice() throws {
        _ = try DPoP.loadOrCreate(for: did)
        TokenStorage.storeTokens(
            did: did, accessToken: "a", refreshToken: "alice-refresh", handle: "alice.test",
            expiresAt: Date().addingTimeInterval(3600), scope: AuthManager.scope
        )
        TokenStorage.storeServer(.test, for: did)
        TokenStorage.upsertAccount(StoredAccount(did: did, handle: "alice.test", avatar: nil))
        TokenStorage.activeDID = did
    }

    func testSigningOutRevokesTheRefreshToken() async throws {
        try storeAlice()
        try network.on(XCTUnwrap(AccountServer.test.revocationEndpoint?.absoluteString), json: "")
        let auth = AuthManager()

        await auth.logout()

        let revoke = try XCTUnwrap(try network.requests(to: XCTUnwrap(AccountServer.test.revocationEndpoint?.absoluteString)).first)
        XCTAssertEqual(Network.form(revoke)["token"], "alice-refresh")
        XCTAssertEqual(Network.form(revoke)["client_id"], AuthManager.clientID)
        XCTAssertNotNil(revoke.value(forHTTPHeaderField: "DPoP"))
        XCTAssertFalse(TokenStorage.hasCredentials(for: did))
        XCTAssertFalse(auth.isAuthenticated)
    }

    func testAFailedRevocationStillSignsOut() async throws {
        try storeAlice()
        try network.on(XCTUnwrap(AccountServer.test.revocationEndpoint?.absoluteString), Network.Answer(status: 503, body: ""))
        let auth = AuthManager()

        await auth.logout()

        XCTAssertFalse(TokenStorage.hasCredentials(for: did))
        XCTAssertFalse(auth.isAuthenticated)
    }

    // MARK: - Sessions from before

    /// A session with no PDS or authorization server on record can't make a
    /// signed-in call. It is dropped once at launch, with the reason shown.
    func testASessionWithoutItsServerIsDroppedOnceAtLaunch() throws {
        _ = try DPoP.loadOrCreate(for: did)
        TokenStorage.storeTokens(
            did: did, accessToken: "a", refreshToken: "r", handle: "alice.test",
            expiresAt: Date().addingTimeInterval(3600), scope: AuthManager.scope
        )
        TokenStorage.upsertAccount(StoredAccount(did: did, handle: "alice.test", avatar: nil))
        TokenStorage.activeDID = did

        let auth = AuthManager()

        XCTAssertFalse(auth.isAuthenticated)
        XCTAssertEqual(auth.reauthReason, AuthManager.reauthMessage)
        XCTAssertTrue(auth.accounts.isEmpty)
        XCTAssertFalse(TokenStorage.hasCredentials(for: did))

        XCTAssertNil(AuthManager().reauthReason, "the migration runs once")
    }

    func testASessionWithItsServerSurvivesLaunch() throws {
        try storeAlice()

        let auth = AuthManager()

        XCTAssertTrue(auth.isAuthenticated)
        XCTAssertEqual(auth.userDID, did)
        XCTAssertNil(auth.reauthReason)
    }

    /// When the active account is the stale one, another usable account takes
    /// over rather than the login screen.
    func testAStaleActiveAccountGivesWayToAUsableOne() throws {
        try storeAlice()
        let stale = "did:plc:stale"
        TokenStorage.storeTokens(
            did: stale, accessToken: "a", refreshToken: "r", handle: "stale.test",
            expiresAt: Date().addingTimeInterval(3600), scope: AuthManager.scope
        )
        TokenStorage.upsertAccount(StoredAccount(did: stale, handle: "stale.test", avatar: nil))
        TokenStorage.activeDID = stale

        let auth = AuthManager()

        XCTAssertEqual(auth.userDID, did)
        XCTAssertEqual(auth.accounts.map(\.did), [did])
    }
}