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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
import CryptoKit
import Foundation
import os
private let logger = Logger(subsystem: "social.grain.grain", category: "OAuth")
/// Why signing in failed, in words the login screen can show.
enum OAuthError: Error, LocalizedError, Equatable {
case handleRequired
case handleNotFound(String)
case unsupportedDID(String)
case noPDS(String)
/// The PDS names no authorization server, or that server publishes no
/// metadata.
case noAuthorizationServer(String)
/// The authorization server's metadata names a different issuer than the
/// one the PDS pointed at.
case issuerMismatch(expected: String, found: String)
/// The browser came back from a sign-in this one did not start.
case callbackMismatch
/// The tokens are for another account than the one asked for.
case wrongAccount(expected: String, found: String)
/// A sign-up named an account whose PDS signs in somewhere else.
case accountElsewhere(did: String)
case unsupportedTokenType(String)
/// The authorization server refused a request.
case refused(status: Int, error: String?, description: String?)
var errorDescription: String? {
switch self {
case .handleRequired:
"Enter your handle to sign in."
case let .handleNotFound(handle):
"Couldn't find an account for \(handle)."
case let .unsupportedDID(did):
"\(did) isn't a kind of account Grain can sign in to."
case let .noPDS(did):
"Couldn't find where \(did) is hosted."
case let .noAuthorizationServer(pds):
"\(pds) doesn't support signing in with OAuth."
case let .issuerMismatch(expected, _):
"The sign-in server at \(expected) is misconfigured."
case .callbackMismatch:
"Sign-in didn't complete. Try again."
case .wrongAccount:
"You signed in to a different account than the one you entered."
case .accountElsewhere:
"The new account isn't hosted where it was created."
case let .unsupportedTokenType(type):
"The sign-in server issued an unsupported token (\(type))."
case let .refused(status, error, description):
description ?? error.map { "Sign-in failed (\($0))" } ?? "Sign-in failed (HTTP \(status))"
}
}
}
/// An authorization server's endpoints, from its metadata.
struct AuthorizationServer: Codable, Sendable, Equatable {
let issuer: String
let authorizationEndpoint: URL
let tokenEndpoint: URL
let pushedAuthorizationRequestEndpoint: URL
let revocationEndpoint: URL?
enum CodingKeys: String, CodingKey {
case issuer
case authorizationEndpoint = "authorization_endpoint"
case tokenEndpoint = "token_endpoint"
case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint"
case revocationEndpoint = "revocation_endpoint"
}
}
/// A token response.
struct OAuthTokens: Decodable, Sendable {
let accessToken: String
let tokenType: String
let expiresIn: Int?
let refreshToken: String?
let sub: String
let scope: String?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case tokenType = "token_type"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case sub
case scope
}
var expiresAt: Date {
Date().addingTimeInterval(TimeInterval(expiresIn ?? 300))
}
}
/// An authorization request pushed to the account's server, waiting for the
/// user to approve it in the browser.
struct PendingAuthorization: Sendable {
/// The page where the user approves it.
let url: URL
let state: String
/// Who the user said they are: nil for an account they are making.
let identity: ResolvedIdentity?
let server: AuthorizationServer
let dpop: DPoP
let verifier: String
let scope: String
}
/// A redeemed sign-in: everything an account keeps.
struct OAuthSession: Sendable {
let identity: ResolvedIdentity
let server: AuthorizationServer
let tokens: OAuthTokens
let dpop: DPoP
/// The scope granted, or the one asked for when the server does not say.
let scope: String
}
/// atproto OAuth as a public native client: PAR, PKCE (S256) and DPoP-bound
/// tokens, with each server's nonce kept per origin.
struct OAuthClient: Sendable {
let session: URLSession
let clientID: String
let redirectURI: String
var resolver: IdentityResolver
init(session: URLSession, clientID: String, redirectURI: String) {
self.session = session
self.clientID = clientID
self.redirectURI = redirectURI
resolver = IdentityResolver(session: session)
}
// MARK: - Discovery
/// The authorization server for accounts on `pds`.
func server(forPDS pds: URL) async throws -> AuthorizationServer {
struct Resource: Decodable {
let authorizationServers: [String]?
enum CodingKeys: String, CodingKey { case authorizationServers = "authorization_servers" }
}
let base = pds.absoluteString.trimmingSuffix("/")
guard let resourceURL = URL(string: "\(base)/.well-known/oauth-protected-resource"),
let resource: Resource = try? await getJSON(resourceURL),
let issuer = resource.authorizationServers?.first,
let metadataURL = URL(string: "\(issuer.trimmingSuffix("/"))/.well-known/oauth-authorization-server")
else { throw OAuthError.noAuthorizationServer(base) }
guard let server: AuthorizationServer = try? await getJSON(metadataURL) else {
throw OAuthError.noAuthorizationServer(issuer)
}
guard server.issuer == issuer else {
throw OAuthError.issuerMismatch(expected: issuer, found: server.issuer)
}
return server
}
private func getJSON<T: Decodable>(_ url: URL) async throws -> T {
let (data, response) = try await session.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw OAuthError.refused(status: (response as? HTTPURLResponse)?.statusCode ?? 0, error: nil, description: nil)
}
return try JSONDecoder().decode(T.self, from: data)
}
// MARK: - Authorization
/// Push a request to act as `identity` with `scope`. `hint` is who the
/// user said they are.
func authorize(_ identity: ResolvedIdentity, hint: String, scope: String) async throws -> PendingAuthorization {
let server = try await server(forPDS: identity.pds)
return try await push(to: server, identity: identity, asking: ["login_hint": hint], scope: scope)
}
/// Push a request to act with `scope` as an account the user makes first
/// on `pds`, on the sign-up page its authorization server shows for
/// `prompt=create`. Who they are is known once it is redeemed.
func signUp(at pds: URL, scope: String) async throws -> PendingAuthorization {
let server = try await server(forPDS: pds)
return try await push(to: server, identity: nil, asking: ["prompt": "create"], scope: scope)
}
private func push(
to server: AuthorizationServer,
identity: ResolvedIdentity?,
asking: [String: String],
scope: String
) async throws -> PendingAuthorization {
let dpop = DPoP.createEphemeral()
let verifier = Self.random(32)
let state = Self.random(16)
var form = [
"client_id": clientID,
"redirect_uri": redirectURI,
"response_type": "code",
"code_challenge": Self.challenge(for: verifier),
"code_challenge_method": "S256",
"scope": scope,
"state": state,
]
form.merge(asking) { _, new in new }
struct Pushed: Decodable {
let requestURI: String
enum CodingKeys: String, CodingKey { case requestURI = "request_uri" }
}
let pushed: Pushed = try await post(server.pushedAuthorizationRequestEndpoint, form: form, dpop: dpop)
var components = URLComponents(url: server.authorizationEndpoint, resolvingAgainstBaseURL: false)!
components.queryItems = (components.queryItems ?? []) + [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "request_uri", value: pushed.requestURI),
]
guard let url = components.url else { throw XRPCError.invalidURL }
return PendingAuthorization(
url: url, state: state, identity: identity, server: server,
dpop: dpop, verifier: verifier, scope: scope
)
}
/// Trade the code the browser came back with for tokens, and check they
/// are for the account asked for.
func redeem(_ pending: PendingAuthorization, callback: URL) async throws -> OAuthSession {
let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
let params = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first })
guard params["state"] == pending.state else { throw OAuthError.callbackMismatch }
if let iss = params["iss"], iss != pending.server.issuer {
throw OAuthError.callbackMismatch
}
if let error = params["error"] {
throw error == "access_denied"
? XRPCError.authorizationDenied
: XRPCError.authorizationFailed(code: error, description: params["error_description"])
}
guard let code = params["code"] else { throw OAuthError.callbackMismatch }
let tokens: OAuthTokens = try await post(
pending.server.tokenEndpoint,
form: [
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirectURI,
"client_id": clientID,
"code_verifier": pending.verifier,
],
dpop: pending.dpop
)
guard tokens.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame else {
throw OAuthError.unsupportedTokenType(tokens.tokenType)
}
let identity: ResolvedIdentity = if let asked = pending.identity {
asked
} else {
try await made(tokens.sub, at: pending.server.issuer)
}
guard tokens.sub == identity.did else {
throw OAuthError.wrongAccount(expected: identity.did, found: tokens.sub)
}
return OAuthSession(
identity: identity, server: pending.server, tokens: tokens,
dpop: pending.dpop, scope: tokens.scope ?? pending.scope
)
}
/// The account `did`, which a sign-up at the server `issuer` made. As
/// atproto's OAuth asks, it is taken only if its PDS signs in there: else
/// any server could name any account.
func made(_ did: String, at issuer: String) async throws -> ResolvedIdentity {
let identity = try await resolver.resolve(did)
let theirs = try await server(forPDS: identity.pds).issuer
guard theirs == issuer else { throw OAuthError.accountElsewhere(did: did) }
return identity
}
// MARK: - Tokens
/// New tokens for a session, from the token endpoint it signed in at.
func refresh(refreshToken: String, tokenEndpoint: URL, dpop: DPoP) async throws -> OAuthTokens {
try await post(
tokenEndpoint,
form: [
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_id": clientID,
],
dpop: dpop
)
}
/// Revoke a session's refresh token, as far as its server lets it.
func revoke(refreshToken: String, revocationEndpoint: URL, dpop: DPoP) async throws {
_ = try await postForm(
revocationEndpoint,
form: ["token": refreshToken, "client_id": clientID],
dpop: dpop
)
}
// MARK: - Requests
private func post<T: Decodable>(_ url: URL, form: [String: String], dpop: DPoP) async throws -> T {
let data = try await postForm(url, form: form, dpop: dpop)
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw XRPCError.decodingError(error)
}
}
/// POST `form` with a DPoP proof, sending it again once when the server
/// answers `use_dpop_nonce`.
private func postForm(_ url: URL, form: [String: String], dpop: DPoP) async throws -> Data {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = Data(form.formEncoded.utf8)
var attempt = 0
while true {
attempt += 1
try await request.setValue(dpop.createProof(httpMethod: "POST", url: url), forHTTPHeaderField: "DPoP")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw OAuthError.refused(status: 0, error: nil, description: nil)
}
DPoPNonces.shared.keep(from: http, for: url)
if (200 ... 299).contains(http.statusCode) {
return data
}
struct Refusal: Decodable {
let error: String?
let errorDescription: String?
enum CodingKeys: String, CodingKey {
case error
case errorDescription = "error_description"
}
}
let refusal = try? JSONDecoder().decode(Refusal.self, from: data)
if refusal?.error == "use_dpop_nonce", attempt == 1 {
continue
}
logger.error("OAuth \(url.path, privacy: .public) refused (\(http.statusCode)): \(refusal?.error ?? "no error", privacy: .public)")
throw OAuthError.refused(status: http.statusCode, error: refusal?.error, description: refusal?.errorDescription)
}
}
// MARK: - PKCE
static func random(_ bytes: Int) -> String {
var buffer = [UInt8](repeating: 0, count: bytes)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
return Data(buffer).base64URLEncoded()
}
static func challenge(for verifier: String) -> String {
Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded()
}
}
// MARK: - Helpers
extension [String: String] {
/// `application/x-www-form-urlencoded`, every byte but the unreserved
/// ones escaped: a scope carries `?`, `&`, `=` and `%`.
var formEncoded: String {
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
return sorted { $0.key < $1.key }.map { key, value in
let escapedKey = key.addingPercentEncoding(withAllowedCharacters: unreserved) ?? key
let escapedValue = value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value
return "\(escapedKey)=\(escapedValue)"
}.joined(separator: "&")
}
}
extension String {
func trimmingSuffix(_ suffix: String) -> String {
var trimmed = self
while trimmed.hasSuffix(suffix) {
trimmed.removeLast(suffix.count)
}
return trimmed
}
}