summaryrefslogtreecommitdiff
path: root/octosentry/GitHubDeviceAuthClient.swift
blob: 63799ffd4512172d00e768983f95e10a8e0383dd (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
//
//  GitHubDeviceAuthClient.swift
//  octosentry
//
//  Implements the GitHub device authorization flow (spec §6): request a
//  device/user code pair, show the user code, then poll until they've
//  authorized it on github.com/login/device. No client secret involved —
//  device flow for native apps doesn't use one.
//

import Foundation

actor GitHubDeviceAuthClient {
    // Public client identifier for the "octosentry" OAuth App (Device Flow enabled).
    // Not a secret — safe to embed in source.
    private let clientID = "Ov23li6tqaTghDc4IJYv"

    // Grants Dependabot/code scanning/secret scanning alert access. Classic OAuth
    // scopes have no read-only variant (unlike fine-grained PATs); this is the
    // narrowest scope GitHub offers for these three endpoints via OAuth Apps.
    private let scope = "security_events"

    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func requestDeviceCode() async throws -> DeviceCodeResponse {
        let data = try await post(
            url: URL(string: "https://github.com/login/device/code")!,
            parameters: ["client_id": clientID, "scope": scope]
        )
        do {
            return try JSONDecoder().decode(DeviceCodeResponse.self, from: data)
        } catch {
            throw DeviceAuthError.decodingFailed(error.localizedDescription)
        }
    }

    /// Polls until the user authorizes, denies, or the device code expires.
    func pollForToken(deviceCode: String, interval: Int, expiresIn: Int) async throws -> String {
        var currentInterval = interval
        let deadline = Date().addingTimeInterval(TimeInterval(expiresIn))

        while Date() < deadline {
            try await Task.sleep(for: .seconds(currentInterval))
            try Task.checkCancellation()

            let data = try await post(
                url: URL(string: "https://github.com/login/oauth/access_token")!,
                parameters: [
                    "client_id": clientID,
                    "device_code": deviceCode,
                    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                ]
            )

            let response: AccessTokenResponse
            do {
                response = try JSONDecoder().decode(AccessTokenResponse.self, from: data)
            } catch {
                throw DeviceAuthError.decodingFailed(error.localizedDescription)
            }

            if let token = response.accessToken {
                return token
            }

            switch response.error {
            case "authorization_pending":
                continue
            case "slow_down":
                currentInterval = response.interval ?? (currentInterval + 5)
            case "expired_token":
                throw DeviceAuthError.expired
            case "access_denied":
                throw DeviceAuthError.denied
            default:
                throw DeviceAuthError.unknown(response.error ?? "unrecognized response")
            }
        }
        throw DeviceAuthError.expired
    }

    private func post(url: URL, parameters: [String: String]) async throws -> Data {
        var components = URLComponents()
        components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        request.httpBody = Data((components.percentEncodedQuery ?? "").utf8)

        let data: Data
        let response: URLResponse
        do {
            (data, response) = try await session.data(for: request)
        } catch {
            throw DeviceAuthError.network(error.localizedDescription)
        }

        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw DeviceAuthError.requestFailed
        }
        return data
    }
}

nonisolated enum DeviceAuthError: Error, LocalizedError {
    case network(String)
    case requestFailed
    case decodingFailed(String)
    case expired
    case denied
    case unknown(String)

    var errorDescription: String? {
        switch self {
        case .network(let message):
            "Network error: \(message)"
        case .requestFailed:
            "Failed to reach GitHub."
        case .decodingFailed(let message):
            "Unexpected response from GitHub: \(message)"
        case .expired:
            "The sign-in code expired before it was used. Try again."
        case .denied:
            "Sign-in was denied on GitHub."
        case .unknown(let message):
            "GitHub sign-in failed: \(message)"
        }
    }
}