Sign in at the account's own PDS with atproto OAuth
Grain/API/Endpoints/RecordEndpoints.swift+88−11
| @@ -1,4 +1,7 @@ | ||
import Foundation | ||
import os | ||
| ||
private let logger = Logger(subsystem: "social.grain.grain", category: "Records") | ||
| ||
struct CreateRecordInput: Codable, Sendable { | ||
let collection: String | ||
| @@ -15,14 +18,19 @@ struct PutRecordInput: Codable, Sendable { | ||
let collection: String | ||
let rkey: String | ||
let record: AnyCodable | ||
var repo: String? | ||
let repo: String | ||
} | ||
| ||
struct DeleteRecordInput: Codable, Sendable { | ||
let repo: String | ||
let collection: String | ||
let rkey: String | ||
} | ||
| ||
struct IndexRecordsInput: Codable, Sendable { | ||
let uris: [String] | ||
} | ||
| ||
struct DeleteGalleryInput: Codable, Sendable { | ||
let rkey: String | ||
} | ||
| @@ -33,15 +41,16 @@ struct GetRecordResponse: Codable, Sendable { | ||
var record: AnyCodable? | ||
} | ||
| ||
/// One write inside a `dev.hatk.applyWrites` batch. | ||
/// One write inside a `com.atproto.repo.applyWrites` batch. | ||
/// | ||
/// Grain only ever creates, and always with an explicit rkey, so a batch that | ||
/// has to be retried addresses the same records both times. | ||
/// Every write names its rkey, so a batch that has to be retried addresses | ||
/// the same records both times. | ||
struct ApplyWrite: Codable, Sendable { | ||
let type: String | ||
let collection: String | ||
let rkey: String | ||
let value: AnyCodable | ||
/// The record, for a create or an update. | ||
let value: AnyCodable? | ||
| ||
enum CodingKeys: String, CodingKey { | ||
case type = "$type" | ||
| @@ -51,11 +60,20 @@ struct ApplyWrite: Codable, Sendable { | ||
} | ||
| ||
static func create(collection: String, rkey: String, value: AnyCodable) -> ApplyWrite { | ||
ApplyWrite(type: "dev.hatk.applyWrites#create", collection: collection, rkey: rkey, value: value) | ||
ApplyWrite(type: "com.atproto.repo.applyWrites#create", collection: collection, rkey: rkey, value: value) | ||
} | ||
| ||
static func update(collection: String, rkey: String, value: AnyCodable) -> ApplyWrite { | ||
ApplyWrite(type: "com.atproto.repo.applyWrites#update", collection: collection, rkey: rkey, value: value) | ||
} | ||
| ||
static func delete(collection: String, rkey: String) -> ApplyWrite { | ||
ApplyWrite(type: "com.atproto.repo.applyWrites#delete", collection: collection, rkey: rkey, value: nil) | ||
} | ||
} | ||
| ||
struct ApplyWritesInput: Codable, Sendable { | ||
let repo: String | ||
let writes: [ApplyWrite] | ||
} | ||
| ||
| @@ -68,27 +86,86 @@ struct ApplyWritesResponse: Codable, Sendable { | ||
var results: [ApplyWriteResult]? | ||
} | ||
| ||
/// Writes go to the account's PDS as `com.atproto.repo.*` calls, with the | ||
/// account's DID as the repo. Each one that lands is then handed to the | ||
/// AppView's `social.grain.sync.indexRecords`, so the next read sees it | ||
/// without waiting on the firehose. | ||
extension XRPCClient { | ||
/// How many URIs one `indexRecords` call takes. | ||
static let indexBatchSize = 25 | ||
| ||
func getRecord(uri: String, auth: AuthContext? = nil) async throws -> GetRecordResponse { | ||
try await query("dev.hatk.getRecord", params: ["uri": uri], auth: auth, as: GetRecordResponse.self) | ||
} | ||
| ||
func createRecord(collection: String, repo: String, record: AnyCodable, auth: AuthContext? = nil) async throws -> CreateRecordResponse { | ||
try await procedure("dev.hatk.createRecord", input: CreateRecordInput(collection: collection, repo: repo, record: record), auth: auth, as: CreateRecordResponse.self) | ||
let response = try await procedure( | ||
"com.atproto.repo.createRecord", | ||
input: CreateRecordInput(collection: collection, repo: repo, record: record), | ||
auth: auth, | ||
as: CreateRecordResponse.self | ||
) | ||
await indexRecords([response.uri].compactMap(\.self), auth: auth) | ||
return response | ||
} | ||
| ||
/// Create or overwrite the record at `rkey`. `repo` defaults to the | ||
/// signed-in account's. | ||
func putRecord(collection: String, rkey: String, record: AnyCodable, repo: String? = nil, auth: AuthContext? = nil) async throws -> CreateRecordResponse { | ||
try await procedure("dev.hatk.putRecord", input: PutRecordInput(collection: collection, rkey: rkey, record: record, repo: repo), auth: auth, as: CreateRecordResponse.self) | ||
let repo = repo ?? auth?.did ?? "" | ||
let response = try await procedure( | ||
"com.atproto.repo.putRecord", | ||
input: PutRecordInput(collection: collection, rkey: rkey, record: record, repo: repo), | ||
auth: auth, | ||
as: CreateRecordResponse.self | ||
) | ||
await indexRecords([response.uri ?? Self.uri(repo, collection, rkey)], auth: auth) | ||
return response | ||
} | ||
| ||
/// Apply every write in a single atomic PDS commit. Either all the records | ||
/// land or none do — there is no partially-created gallery to clean up. | ||
/// land or none do: there is no partially-created gallery to clean up. | ||
func applyWrites(_ writes: [ApplyWrite], auth: AuthContext? = nil) async throws -> ApplyWritesResponse { | ||
try await procedure("dev.hatk.applyWrites", input: ApplyWritesInput(writes: writes), auth: auth, as: ApplyWritesResponse.self) | ||
let repo = auth?.did ?? "" | ||
let response = try await procedure( | ||
"com.atproto.repo.applyWrites", | ||
input: ApplyWritesInput(repo: repo, writes: writes), | ||
auth: auth, | ||
as: ApplyWritesResponse.self | ||
) | ||
await indexRecords(writes.map { Self.uri(repo, $0.collection, $0.rkey) }, auth: auth) | ||
return response | ||
} | ||
| ||
func deleteRecord(collection: String, rkey: String, auth: AuthContext? = nil) async throws { | ||
try await procedure("dev.hatk.deleteRecord", input: DeleteRecordInput(collection: collection, rkey: rkey), auth: auth) | ||
let repo = auth?.did ?? "" | ||
try await procedure( | ||
"com.atproto.repo.deleteRecord", | ||
input: DeleteRecordInput(repo: repo, collection: collection, rkey: rkey), | ||
auth: auth | ||
) | ||
await indexRecords([Self.uri(repo, collection, rkey)], auth: auth) | ||
} | ||
| ||
/// Ask the AppView to index `uris` now, deleted ones included, in batches | ||
/// it accepts. Only Grain's own collections are its to index; a Bluesky | ||
/// cross-post is not. A failure is logged and never fails the write it | ||
/// follows: the firehose brings the record in the end. | ||
func indexRecords(_ uris: [String], auth: AuthContext?) async { | ||
let uris = uris.filter { $0.contains("/social.grain.") } | ||
guard let auth, !uris.isEmpty else { return } | ||
for start in stride(from: 0, to: uris.count, by: Self.indexBatchSize) { | ||
let batch = Array(uris[start ..< min(start + Self.indexBatchSize, uris.count)]) | ||
do { | ||
try await procedure("social.grain.sync.indexRecords", input: IndexRecordsInput(uris: batch), auth: auth) | ||
} catch { | ||
logger.error("indexRecords failed for \(batch.count) record(s): \(error)") | ||
} | ||
} | ||
} | ||
| ||
static func uri(_ repo: String, _ collection: String, _ rkey: String) -> String { | ||
"at://\(repo)/\(collection)/\(rkey)" | ||
} | ||
| ||
func deleteGallery(rkey: String, auth: AuthContext? = nil) async throws { | ||
Grain/API/OAuth/IdentityResolver.swiftnew+161−0
| @@ -0,0 +1,161 @@ | ||
import Foundation | ||
| ||
/// An account as its DID document describes it. | ||
struct ResolvedIdentity: Sendable, Equatable { | ||
let did: String | ||
/// The handle the DID document claims, when it resolves back to the DID. | ||
let handle: String? | ||
/// The `#atproto_pds` service endpoint. | ||
let pds: URL | ||
} | ||
| ||
/// Handle and DID resolution, as atproto's OAuth asks a client to do before | ||
/// it knows where an account signs in. | ||
/// | ||
/// A handle resolves through its own `/.well-known/atproto-did` first, then | ||
/// through `com.atproto.identity.resolveHandle` at the AppView: the resolvers, | ||
/// in the order, that grain's CLI uses. A DID document comes from the PLC | ||
/// directory for `did:plc`, or from its host for `did:web`. | ||
struct IdentityResolver: Sendable { | ||
let session: URLSession | ||
var handleService = URL(string: "https://public.api.bsky.app")! | ||
var plcDirectory = URL(string: "https://plc.directory")! | ||
| ||
/// How long a lookup at a host the name alone points to may take: any | ||
/// name can point at a host that takes the connection and never answers. | ||
static let lookupTimeout: TimeInterval = 3 | ||
| ||
/// Resolve what the user typed, a handle (with or without `@`) or a DID. | ||
func resolve(_ identifier: String) async throws -> ResolvedIdentity { | ||
let typed = Self.normalize(identifier) | ||
guard !typed.isEmpty else { throw OAuthError.handleRequired } | ||
| ||
if typed.hasPrefix("did:") { | ||
let document = try await document(for: typed) | ||
let pds = try Self.pds(in: document, did: typed) | ||
// Use the handle the document claims only if it resolves back. | ||
var handle: String? | ||
if let claimed = document.handle, let back = try? await resolveHandle(claimed), back == typed { | ||
handle = claimed | ||
} | ||
return ResolvedIdentity(did: typed, handle: handle, pds: pds) | ||
} | ||
| ||
guard Self.looksLikeHandle(typed) else { throw OAuthError.handleNotFound(typed) } | ||
let did = try await resolveHandle(typed) | ||
let document = try await document(for: did) | ||
let pds = try Self.pds(in: document, did: did) | ||
let claims = document.alsoKnownAs?.contains { $0.lowercased() == "at://\(typed)" } ?? false | ||
return ResolvedIdentity(did: did, handle: claims ? typed : nil, pds: pds) | ||
} | ||
| ||
/// A handle's DID, from the first resolver that answers with one. | ||
func resolveHandle(_ handle: String) async throws -> String { | ||
if let did = try? await wellKnown(handle) { | ||
return did | ||
} | ||
var components = URLComponents( | ||
url: handleService.appendingPathComponent("xrpc/com.atproto.identity.resolveHandle"), | ||
resolvingAgainstBaseURL: false | ||
)! | ||
components.queryItems = [URLQueryItem(name: "handle", value: handle)] | ||
struct Resolved: Decodable { let did: String } | ||
if let url = components.url, | ||
case let (data, response)? = try? await session.data(from: url), | ||
(response as? HTTPURLResponse)?.statusCode == 200, | ||
let resolved = try? JSONDecoder().decode(Resolved.self, from: data), | ||
resolved.did.hasPrefix("did:") | ||
{ | ||
return resolved.did | ||
} | ||
throw OAuthError.handleNotFound(handle) | ||
} | ||
| ||
private func wellKnown(_ handle: String) async throws -> String { | ||
guard let url = URL(string: "https://\(handle)/.well-known/atproto-did") else { throw OAuthError.handleNotFound(handle) } | ||
var request = URLRequest(url: url) | ||
request.timeoutInterval = Self.lookupTimeout | ||
let (data, response) = try await session.data(for: request) | ||
guard (response as? HTTPURLResponse)?.statusCode == 200, | ||
let did = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), | ||
did.hasPrefix("did:") | ||
else { throw OAuthError.handleNotFound(handle) } | ||
return did | ||
} | ||
| ||
/// `did`'s DID document. | ||
func document(for did: String) async throws -> DIDDocument { | ||
var request: URLRequest | ||
if did.hasPrefix("did:plc:") { | ||
request = URLRequest(url: plcDirectory.appendingPathComponent(did)) | ||
} else if did.hasPrefix("did:web:") { | ||
let host = String(did.dropFirst("did:web:".count)).replacingOccurrences(of: "%3A", with: ":") | ||
guard let url = URL(string: "https://\(host)/.well-known/did.json") else { throw OAuthError.unsupportedDID(did) } | ||
request = URLRequest(url: url) | ||
request.timeoutInterval = Self.lookupTimeout | ||
} else { | ||
throw OAuthError.unsupportedDID(did) | ||
} | ||
let (data, response) = try await session.data(for: request) | ||
guard (response as? HTTPURLResponse)?.statusCode == 200, | ||
let document = try? JSONDecoder().decode(DIDDocument.self, from: data) | ||
else { throw OAuthError.noPDS(did) } | ||
return document | ||
} | ||
| ||
static func pds(in document: DIDDocument, did: String) throws -> URL { | ||
guard let endpoint = document.service?.first(where: { $0.id.hasSuffix("#atproto_pds") })?.serviceEndpoint, | ||
let url = URL(string: endpoint), url.scheme == "https" || url.scheme == "http" | ||
else { throw OAuthError.noPDS(did) } | ||
return url | ||
} | ||
| ||
static func normalize(_ identifier: String) -> String { | ||
var typed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) | ||
if typed.hasPrefix("@") { | ||
typed.removeFirst() | ||
} | ||
return typed.hasPrefix("did:") ? typed : typed.lowercased() | ||
} | ||
| ||
/// Enough of atproto's handle syntax to keep a typo off the network: | ||
/// dot-separated labels of letters, digits and hyphens. | ||
static func looksLikeHandle(_ handle: String) -> Bool { | ||
let labels = handle.split(separator: ".", omittingEmptySubsequences: false) | ||
guard labels.count >= 2, handle.count <= 253 else { return false } | ||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") | ||
return labels.allSatisfy { label in | ||
!label.isEmpty && label.count <= 63 && !label.hasPrefix("-") && !label.hasSuffix("-") | ||
&& label.unicodeScalars.allSatisfy { allowed.contains($0) } | ||
} | ||
} | ||
} | ||
| ||
/// The parts of a DID document sign-in reads. | ||
struct DIDDocument: Decodable, Sendable { | ||
struct Service: Decodable, Sendable { | ||
let id: String | ||
let serviceEndpoint: String? | ||
| ||
enum CodingKeys: String, CodingKey { | ||
case id | ||
case serviceEndpoint | ||
} | ||
| ||
init(from decoder: Decoder) throws { | ||
let container = try decoder.container(keyedBy: CodingKeys.self) | ||
id = try container.decode(String.self, forKey: .id) | ||
// An endpoint may be an object or a list; only a URL string is a PDS. | ||
serviceEndpoint = try? container.decode(String.self, forKey: .serviceEndpoint) | ||
} | ||
} | ||
| ||
let id: String? | ||
let alsoKnownAs: [String]? | ||
let service: [Service]? | ||
| ||
/// The handle in the first `at://` alias. | ||
var handle: String? { | ||
alsoKnownAs?.first { $0.hasPrefix("at://") }.map { String($0.dropFirst("at://".count)).lowercased() } | ||
} | ||
} | ||
Grain/API/OAuth/OAuthClient.swiftnew+387−0
| @@ -0,0 +1,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 | ||
} | ||
} | ||
Grain/API/AuthManager.swift+180−309
489 lines changed: not loaded until asked for.
Grain/API/DPoP.swift+43−5
| @@ -1,5 +1,6 @@ | ||
import CryptoKit | ||
import Foundation | ||
import os | ||
import Security | ||
| ||
/// DPoP (Demonstration of Proof-of-Possession) proof generator using ES256. | ||
| @@ -26,24 +27,25 @@ final class DPoP: Sendable { | ||
"y": yCoord.base64URLEncoded(), | ||
] | ||
| ||
// JWK thumbprint (RFC 7638) — lexicographic JSON of required members | ||
// JWK thumbprint (RFC 7638): lexicographic JSON of required members | ||
let thumbprintInput = #"{"crv":"P-256","kty":"EC","x":"\#(xCoord.base64URLEncoded())","y":"\#(yCoord.base64URLEncoded())"}"# | ||
let hash = SHA256.hash(data: Data(thumbprintInput.utf8)) | ||
thumbprint = Data(hash).base64URLEncoded() | ||
} | ||
| ||
/// Create a DPoP proof JWT. | ||
/// Create a DPoP proof JWT for one request: `htm` and `htu` (the URL | ||
/// without query or fragment), `ath` when it carries an access token, and | ||
/// the nonce the request's origin last sent. | ||
func createProof( | ||
httpMethod: String, | ||
url: URL, | ||
accessToken: String? = nil, | ||
nonce: String? = nil | ||
accessToken: String? = nil | ||
) async throws -> String { | ||
// Normalize URL: scheme + host + path (no query) | ||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! | ||
components.query = nil | ||
components.fragment = nil | ||
let htu = components.url!.absoluteString | ||
let nonce = DPoPNonces.shared.nonce(for: url) | ||
| ||
// Header | ||
let header: [String: Any] = [ | ||
| @@ -79,6 +81,42 @@ final class DPoP: Sendable { | ||
} | ||
} | ||
| ||
// MARK: - Nonces | ||
| ||
/// The DPoP nonce each server last sent, by origin. An account's | ||
/// authorization server and its PDS are different origins with different | ||
/// nonces, and a proof carries the one for the origin it is sent to. Held in | ||
/// memory only: a server hands out a fresh one on the first refusal. | ||
final class DPoPNonces: Sendable { | ||
static let shared = DPoPNonces() | ||
| ||
private let nonces = OSAllocatedUnfairLock<[String: String]>(initialState: [:]) | ||
| ||
static func origin(of url: URL) -> String { | ||
let scheme = url.scheme?.lowercased() ?? "" | ||
let host = url.host?.lowercased() ?? "" | ||
let port = url.port.map { ":\($0)" } ?? "" | ||
return "\(scheme)://\(host)\(port)" | ||
} | ||
| ||
func nonce(for url: URL) -> String? { | ||
let origin = Self.origin(of: url) | ||
return nonces.withLock { $0[origin] } | ||
} | ||
| ||
/// Keep the nonce `response` to a request for `url` carries, if any. | ||
func keep(from response: HTTPURLResponse, for url: URL) { | ||
guard let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), !nonce.isEmpty else { return } | ||
let origin = Self.origin(of: url) | ||
nonces.withLock { $0[origin] = nonce } | ||
} | ||
| ||
func set(_ nonce: String?, for url: URL) { | ||
let origin = Self.origin(of: url) | ||
nonces.withLock { $0[origin] = nonce } | ||
} | ||
} | ||
| ||
// MARK: - Key Management | ||
| ||
extension DPoP { | ||
Grain/API/TokenStorage.swift+28−1
| @@ -12,6 +12,17 @@ struct StoredAccount: Codable, Identifiable, Hashable { | ||
} | ||
} | ||
| ||
/// Where an account signs in and where its repo lives: what a refresh, a | ||
/// revocation and every signed-in call need besides the tokens. | ||
struct AccountServer: Codable, Sendable, Equatable { | ||
/// The account's PDS, where signed-in calls go. | ||
let pds: URL | ||
/// The authorization server that issued the tokens. | ||
let issuer: String | ||
let tokenEndpoint: URL | ||
let revocationEndpoint: URL? | ||
} | ||
| ||
/// Secure storage for OAuth tokens using Keychain. | ||
/// | ||
/// Every credential is namespaced by DID so several accounts can stay signed in | ||
| @@ -120,6 +131,16 @@ enum TokenStorage { | ||
read("token_scope", did) | ||
} | ||
| ||
static func server(for did: String) -> AccountServer? { | ||
guard let raw = read("oauth_server", did) else { return nil } | ||
return try? JSONDecoder().decode(AccountServer.self, from: Data(raw.utf8)) | ||
} | ||
| ||
static func storeServer(_ server: AccountServer, for did: String) { | ||
guard let data = try? JSONEncoder().encode(server) else { return } | ||
write("oauth_server", did, String(data: data, encoding: .utf8)) | ||
} | ||
| ||
static func setAvatar(_ avatar: String?, for did: String) { | ||
write("user_avatar", did, avatar) | ||
} | ||
| @@ -150,11 +171,17 @@ enum TokenStorage { | ||
accessToken(for: did) != nil && refreshToken(for: did) != nil | ||
} | ||
| ||
/// Whether `did` can make signed-in calls: tokens, and the PDS and | ||
/// authorization server they belong to. | ||
static func hasSession(for did: String) -> Bool { | ||
hasCredentials(for: did) && server(for: did) != nil | ||
} | ||
| ||
/// Forget one account: wipe its credentials and drop it from the list. | ||
/// Clears `activeDID` if it was the active one — the caller decides which | ||
/// account, if any, takes over. | ||
static func removeAccount(_ did: String) { | ||
for name in ["access_token", "refresh_token", "user_handle", "user_avatar", "token_expires_at", "token_scope"] { | ||
for name in ["access_token", "refresh_token", "user_handle", "user_avatar", "token_expires_at", "token_scope", "oauth_server"] { | ||
write(name, did, nil) | ||
} | ||
accounts = accounts.filter { $0.did != did } | ||
Grain/API/XRPCClient.swift+122−163
| @@ -8,8 +8,7 @@ enum XRPCError: Error, LocalizedError { | ||
case httpError(statusCode: Int, body: Data?) | ||
case decodingError(Error) | ||
case unauthorized | ||
case dpopNonceRequired(nonce: String) | ||
/// The PDS rejected the authorization — usually the user tapping "Deny". | ||
/// The authorization server refused the request, usually the user tapping "Deny". | ||
case authorizationDenied | ||
/// Any other OAuth error handed back on the callback URL. | ||
case authorizationFailed(code: String, description: String?) | ||
| @@ -20,14 +19,19 @@ enum XRPCError: Error, LocalizedError { | ||
case let .httpError(code, _): "HTTP error \(code)" | ||
case let .decodingError(error): "Decoding error: \(error.localizedDescription)" | ||
case .unauthorized: "Unauthorized" | ||
case .dpopNonceRequired: "DPoP nonce required" | ||
case .authorizationDenied: "Sign-in canceled" | ||
case let .authorizationFailed(code, description): description ?? "Sign-in failed (\(code))" | ||
} | ||
} | ||
} | ||
| ||
/// XRPC client for communicating with the hatk server. | ||
/// XRPC client for Grain's calls. | ||
/// | ||
/// A call without an auth context goes to `baseURL`, Grain's AppView, as is. | ||
/// A call with one goes to the account's PDS, with a DPoP-bound token and a | ||
/// proof per request; every method outside `com.atproto.*` also carries an | ||
/// `atproto-proxy` header, so the PDS forwards it to the AppView with a | ||
/// service token, as Bluesky's apps reach their AppView. | ||
final class XRPCClient: Sendable { | ||
let baseURL: URL | ||
private let session: URLSession | ||
| @@ -50,16 +54,17 @@ final class XRPCClient: Sendable { | ||
auth: AuthContext? = nil, | ||
as type: T.Type | ||
) async throws -> T { | ||
var components = URLComponents(url: baseURL.appendingPathComponent("xrpc/\(nsid)"), resolvingAgainstBaseURL: false)! | ||
if !params.isEmpty { | ||
components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } | ||
let data = try await perform(nsid, auth: auth) { base in | ||
var components = URLComponents(url: base.appendingPathComponent("xrpc/\(nsid)"), resolvingAgainstBaseURL: false)! | ||
if !params.isEmpty { | ||
components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } | ||
} | ||
guard let url = components.url else { throw XRPCError.invalidURL } | ||
var request = URLRequest(url: url) | ||
request.httpMethod = "GET" | ||
return request | ||
} | ||
guard let url = components.url else { throw XRPCError.invalidURL } | ||
| ||
var request = URLRequest(url: url) | ||
request.httpMethod = "GET" | ||
| ||
return try await executeWithRetry(request, auth: auth, as: type) | ||
return try decode(type, from: data) | ||
} | ||
| ||
/// Execute an XRPC procedure (POST request). | ||
| @@ -69,13 +74,11 @@ final class XRPCClient: Sendable { | ||
auth: AuthContext? = nil, | ||
as type: O.Type | ||
) async throws -> O { | ||
let url = baseURL.appendingPathComponent("xrpc/\(nsid)") | ||
var request = URLRequest(url: url) | ||
request.httpMethod = "POST" | ||
request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
request.httpBody = try encoder.encode(input) | ||
| ||
return try await executeWithRetry(request, auth: auth, as: type) | ||
let body = try encoder.encode(input) | ||
let data = try await perform(nsid, auth: auth) { base in | ||
Self.post(base, nsid, body: body, contentType: "application/json") | ||
} | ||
return try decode(type, from: data) | ||
} | ||
| ||
/// Execute an XRPC procedure with no response body. | ||
| @@ -84,132 +87,118 @@ final class XRPCClient: Sendable { | ||
input: some Encodable, | ||
auth: AuthContext? = nil | ||
) async throws { | ||
let url = baseURL.appendingPathComponent("xrpc/\(nsid)") | ||
var request = URLRequest(url: url) | ||
request.httpMethod = "POST" | ||
request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
request.httpBody = try encoder.encode(input) | ||
| ||
try await executeVoidWithRetry(request, auth: auth) | ||
let body = try encoder.encode(input) | ||
_ = try await perform(nsid, auth: auth) { base in | ||
Self.post(base, nsid, body: body, contentType: "application/json") | ||
} | ||
} | ||
| ||
/// Upload a blob (binary data). | ||
/// Upload a blob to the account's PDS. | ||
func uploadBlob( | ||
data: Data, | ||
mimeType: String, | ||
auth: AuthContext? = nil | ||
) async throws -> UploadBlobResponse { | ||
let url = baseURL.appendingPathComponent("xrpc/dev.hatk.uploadBlob") | ||
var request = URLRequest(url: url) | ||
request.httpMethod = "POST" | ||
request.setValue(mimeType, forHTTPHeaderField: "Content-Type") | ||
request.httpBody = data | ||
let nsid = "com.atproto.repo.uploadBlob" | ||
let response = try await perform(nsid, auth: auth) { base in | ||
Self.post(base, nsid, body: data, contentType: mimeType) | ||
} | ||
return try decode(UploadBlobResponse.self, from: response) | ||
} | ||
| ||
return try await executeWithRetry(request, auth: auth, as: UploadBlobResponse.self) | ||
/// The `atproto-proxy` value a signed-in call to `nsid` carries, if any. | ||
static func proxy(for nsid: String) -> String? { | ||
nsid.hasPrefix("com.atproto.") ? nil : AuthManager.appviewService | ||
} | ||
| ||
// MARK: - Private | ||
| ||
private func executeWithRetry<T: Decodable>( | ||
_ request: URLRequest, | ||
auth: AuthContext?, | ||
as type: T.Type, | ||
retryCount: Int = 0 | ||
) async throws -> T { | ||
var req = request | ||
try await applyAuth(&req, auth: auth) | ||
private static func post(_ base: URL, _ nsid: String, body: Data, contentType: String) -> URLRequest { | ||
var request = URLRequest(url: base.appendingPathComponent("xrpc/\(nsid)")) | ||
request.httpMethod = "POST" | ||
request.setValue(contentType, forHTTPHeaderField: "Content-Type") | ||
request.httpBody = body | ||
return request | ||
} | ||
| ||
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T { | ||
do { | ||
return try await execute(req, as: type) | ||
} catch let XRPCError.dpopNonceRequired(nonce) where retryCount < 2 { | ||
logger.info("DPoP nonce required, retrying with nonce") | ||
var updatedAuth = auth | ||
updatedAuth?.nonce = nonce | ||
var retryReq = request | ||
try await applyAuth(&retryReq, auth: updatedAuth) | ||
return try await execute(retryReq, as: type) | ||
} catch XRPCError.unauthorized where retryCount < 1 { | ||
if let onUnauthorized { | ||
do { | ||
if let newAuth = try await onUnauthorized() { | ||
logger.info("Token refreshed, retrying request") | ||
return try await executeWithRetry(request, auth: newAuth, as: type, retryCount: retryCount + 1) | ||
} | ||
} catch { | ||
logger.error("Token refresh failed: \(error)") | ||
} | ||
} | ||
throw XRPCError.unauthorized | ||
return try decoder.decode(type, from: data) | ||
} catch { | ||
throw XRPCError.decodingError(error) | ||
} | ||
} | ||
| ||
private func executeVoidWithRetry( | ||
_ request: URLRequest, | ||
auth: AuthContext?, | ||
retryCount: Int = 0 | ||
) async throws { | ||
var req = request | ||
try await applyAuth(&req, auth: auth) | ||
| ||
let (data, response) = try await send(req) | ||
guard let httpResponse = response as? HTTPURLResponse else { return } | ||
| ||
if httpResponse.statusCode == 400, | ||
let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce"), | ||
retryCount < 2 | ||
{ | ||
logger.info("DPoP nonce required (void), retrying") | ||
var updatedAuth = auth | ||
updatedAuth?.nonce = nonce | ||
var retryReq = request | ||
try await applyAuth(&retryReq, auth: updatedAuth) | ||
let (_, retryResponse) = try await send(retryReq) | ||
guard let retryHttp = retryResponse as? HTTPURLResponse else { return } | ||
if retryHttp.statusCode == 401 { | ||
throw XRPCError.unauthorized | ||
/// Send the request `build` makes against the base URL a context calls | ||
/// for, and return the body of a 2xx answer. | ||
/// | ||
/// A DPoP nonce refusal (401 with `use_dpop_nonce` in `WWW-Authenticate`, | ||
/// or 400 with it as the error) is sent again once with the nonce. Any | ||
/// other 401 asks `onUnauthorized` for a renewed context once and sends | ||
/// the request again with it. | ||
private func perform( | ||
_ nsid: String, | ||
auth initial: AuthContext?, | ||
build: (URL) throws -> URLRequest | ||
) async throws -> Data { | ||
var auth = initial | ||
var refreshed = false | ||
var nonceRetried = false | ||
| ||
while true { | ||
var request = try build(auth?.pds ?? baseURL) | ||
try await applyAuth(&request, nsid: nsid, auth: auth) | ||
| ||
let (data, response) = try await send(request) | ||
guard let http = response as? HTTPURLResponse else { | ||
throw XRPCError.httpError(statusCode: 0, body: data) | ||
} | ||
guard (200 ... 299).contains(retryHttp.statusCode) else { | ||
throw XRPCError.httpError(statusCode: retryHttp.statusCode, body: nil) | ||
if let url = request.url { | ||
DPoPNonces.shared.keep(from: http, for: url) | ||
} | ||
return | ||
} | ||
| ||
if httpResponse.statusCode == 401 { | ||
// Try once more with DPoP-Nonce from response if present | ||
if let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce"), retryCount < 2 { | ||
logger.info("401 with nonce, retrying") | ||
var updatedAuth = auth | ||
updatedAuth?.nonce = nonce | ||
var retryReq = request | ||
try await applyAuth(&retryReq, auth: updatedAuth) | ||
let (_, retryResponse) = try await send(retryReq) | ||
guard let retryHttp = retryResponse as? HTTPURLResponse else { return } | ||
if retryHttp.statusCode == 401 { | ||
throw XRPCError.unauthorized | ||
} | ||
guard (200 ... 299).contains(retryHttp.statusCode) else { | ||
throw XRPCError.httpError(statusCode: retryHttp.statusCode, body: nil) | ||
} | ||
return | ||
if auth != nil, !nonceRetried, Self.isNonceRefusal(http, body: data) { | ||
logger.info("DPoP nonce required, retrying with nonce") | ||
nonceRetried = true | ||
continue | ||
} | ||
// Try token refresh | ||
if retryCount < 1, let onUnauthorized { | ||
do { | ||
if let newAuth = try await onUnauthorized() { | ||
logger.info("Token refreshed, retrying void request") | ||
try await executeVoidWithRetry(request, auth: newAuth, retryCount: retryCount + 1) | ||
return | ||
| ||
if http.statusCode == 401 { | ||
let wwwAuth = http.value(forHTTPHeaderField: "WWW-Authenticate") | ||
logger.error("401 \(nsid, privacy: .public): wwwAuth=\(wwwAuth ?? "nil", privacy: .public)") | ||
if !refreshed, let onUnauthorized { | ||
refreshed = true | ||
do { | ||
if let renewed = try await onUnauthorized() { | ||
logger.info("Token refreshed, retrying request") | ||
auth = renewed | ||
nonceRetried = false | ||
continue | ||
} | ||
} catch { | ||
logger.error("Token refresh failed: \(error)") | ||
} | ||
} catch { | ||
logger.error("Token refresh failed: \(error)") | ||
} | ||
throw XRPCError.unauthorized | ||
} | ||
| ||
guard (200 ... 299).contains(http.statusCode) else { | ||
logger.error("HTTP \(http.statusCode): \(String(data: data, encoding: .utf8) ?? "")") | ||
throw XRPCError.httpError(statusCode: http.statusCode, body: data) | ||
} | ||
throw XRPCError.unauthorized | ||
return data | ||
} | ||
} | ||
| ||
guard (200 ... 299).contains(httpResponse.statusCode) else { | ||
logger.error("HTTP \(httpResponse.statusCode): \(String(data: data, encoding: .utf8) ?? "")") | ||
throw XRPCError.httpError(statusCode: httpResponse.statusCode, body: data) | ||
static func isNonceRefusal(_ response: HTTPURLResponse, body: Data) -> Bool { | ||
switch response.statusCode { | ||
case 401: | ||
return response.value(forHTTPHeaderField: "WWW-Authenticate")?.contains("use_dpop_nonce") ?? false | ||
case 400: | ||
struct Refusal: Decodable { let error: String? } | ||
return (try? JSONDecoder().decode(Refusal.self, from: body))?.error == "use_dpop_nonce" | ||
default: | ||
return false | ||
} | ||
} | ||
| ||
| @@ -222,60 +211,30 @@ final class XRPCClient: Sendable { | ||
return try await session.data(for: request) | ||
} | ||
| ||
private func execute<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T { | ||
let (data, response) = try await send(request) | ||
guard let httpResponse = response as? HTTPURLResponse else { | ||
throw XRPCError.httpError(statusCode: 0, body: data) | ||
} | ||
| ||
// Check for DPoP nonce requirement | ||
if httpResponse.statusCode == 400, | ||
let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") | ||
{ | ||
throw XRPCError.dpopNonceRequired(nonce: nonce) | ||
} | ||
| ||
if httpResponse.statusCode == 401 { | ||
let body = String(data: data, encoding: .utf8) ?? "" | ||
let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") | ||
let wwwAuth = httpResponse.value(forHTTPHeaderField: "WWW-Authenticate") | ||
logger.error("401: body=\(body), nonce=\(nonce ?? "nil"), wwwAuth=\(wwwAuth ?? "nil")") | ||
if let nonce { | ||
throw XRPCError.dpopNonceRequired(nonce: nonce) | ||
} | ||
throw XRPCError.unauthorized | ||
} | ||
| ||
guard (200 ... 299).contains(httpResponse.statusCode) else { | ||
logger.error("HTTP \(httpResponse.statusCode): \(String(data: data, encoding: .utf8) ?? "")") | ||
throw XRPCError.httpError(statusCode: httpResponse.statusCode, body: data) | ||
} | ||
| ||
do { | ||
return try decoder.decode(type, from: data) | ||
} catch { | ||
throw XRPCError.decodingError(error) | ||
} | ||
} | ||
| ||
private func applyAuth(_ request: inout URLRequest, auth: AuthContext?) async throws { | ||
guard let auth else { return } | ||
private func applyAuth(_ request: inout URLRequest, nsid: String, auth: AuthContext?) async throws { | ||
guard let auth, let url = request.url else { return } | ||
request.setValue("DPoP \(auth.accessToken)", forHTTPHeaderField: "Authorization") | ||
let proof = try await auth.dpop.createProof( | ||
httpMethod: request.httpMethod ?? "GET", | ||
url: request.url!, | ||
accessToken: auth.accessToken, | ||
nonce: auth.nonce | ||
url: url, | ||
accessToken: auth.accessToken | ||
) | ||
request.setValue(proof, forHTTPHeaderField: "DPoP") | ||
if let proxy = Self.proxy(for: nsid) { | ||
request.setValue(proxy, forHTTPHeaderField: "atproto-proxy") | ||
} | ||
} | ||
} | ||
| ||
/// Context for authenticated requests. | ||
/// Context for authenticated requests: the active account's token, its DPoP | ||
/// key, and where its repo lives. | ||
struct AuthContext: Sendable { | ||
let accessToken: String | ||
let dpop: DPoP | ||
var nonce: String? | ||
/// The account's DID, the repo its writes go to. | ||
let did: String | ||
/// The account's PDS, where every call carrying this context goes. | ||
let pds: URL | ||
} | ||
| ||
struct UploadBlobResponse: Codable, Sendable { | ||
Grain/Utilities/BlueskyPost.swift+2−2
| @@ -122,7 +122,7 @@ enum BlueskyPost { | ||
// 6. Write the record | ||
let result: CreateRecordResponse | ||
if let rkey { | ||
logger.info(" calling dev.hatk.putRecord with collection=app.bsky.feed.post, repo=\(repo), rkey=\(rkey)") | ||
logger.info(" calling com.atproto.repo.putRecord with collection=app.bsky.feed.post, repo=\(repo), rkey=\(rkey)") | ||
result = try await client.putRecord( | ||
collection: "app.bsky.feed.post", | ||
rkey: rkey, | ||
| @@ -131,7 +131,7 @@ enum BlueskyPost { | ||
auth: auth | ||
) | ||
} else { | ||
logger.info(" calling dev.hatk.createRecord with collection=app.bsky.feed.post, repo=\(repo)") | ||
logger.info(" calling com.atproto.repo.createRecord with collection=app.bsky.feed.post, repo=\(repo)") | ||
result = try await client.createRecord( | ||
collection: "app.bsky.feed.post", | ||
repo: repo, | ||
GrainTests/Helpers/Fixtures.swift+11−0
| @@ -268,3 +268,14 @@ extension Fixtures { | ||
] | ||
} | ||
} | ||
| ||
extension AccountServer { | ||
/// Where a test account's tokens came from: a PDS that is its own | ||
/// authorization server, as a self-hosted one is. | ||
static let test = AccountServer( | ||
pds: URL(string: "https://pds.test")!, | ||
issuer: "https://pds.test", | ||
tokenEndpoint: URL(string: "https://pds.test/oauth/token")!, | ||
revocationEndpoint: URL(string: "https://pds.test/oauth/revoke")! | ||
) | ||
} | ||
GrainTests/Helpers/ViewRender.swift+1−0
| @@ -188,6 +188,7 @@ final class TestAccount { | ||
expiresAt: Date().addingTimeInterval(3600), | ||
scope: AuthManager.requiredScopes.joined(separator: " ") | ||
) | ||
TokenStorage.storeServer(.test, for: did) | ||
TokenStorage.upsertAccount(StoredAccount(did: did, handle: handle, avatar: nil)) | ||
| ||
// Making it the active account is what matters: `AuthManager.init` | ||
GrainTests/AuthManagerAccountTests.swift+6−3
| @@ -65,6 +65,7 @@ final class AuthManagerAccountTests: GrainTestCase { | ||
expiresAt: Date().addingTimeInterval(expiresIn), | ||
scope: AuthManager.requiredScopes.joined(separator: " ") | ||
) | ||
TokenStorage.storeServer(.test, for: did) | ||
TokenStorage.upsertAccount(StoredAccount(did: did, handle: handle, avatar: nil)) | ||
} | ||
| ||
| @@ -79,8 +80,9 @@ final class AuthManagerAccountTests: GrainTestCase { | ||
/// These are registered with the PDS; changing one without updating the | ||
/// client metadata breaks sign-in for everyone. | ||
func testTheOAuthIdentifiersAreTheRegisteredOnes() { | ||
XCTAssertEqual(AuthManager.clientID, "grain-native://app") | ||
XCTAssertEqual(AuthManager.redirectURI, "grain://oauth/callback") | ||
XCTAssertEqual(AuthManager.clientID, AuthManager.serverURL.absoluteString + "/oauth/app-client-metadata.json") | ||
XCTAssertEqual(AuthManager.redirectURI, "social.grain:/oauth/callback") | ||
XCTAssertTrue(AuthManager.redirectURI.hasPrefix(AuthManager.callbackScheme + ":/")) | ||
XCTAssertFalse(AuthManager.requiredScopes.isEmpty) | ||
XCTAssertTrue(AuthManager.requiredScopes.contains("atproto")) | ||
} | ||
| @@ -292,7 +294,8 @@ final class AuthManagerAccountTests: GrainTestCase { | ||
let context = await auth.authContext() | ||
| ||
XCTAssertEqual(context?.accessToken, "alice.test-access") | ||
XCTAssertNil(context?.nonce, "A fresh context starts without a nonce; the PDS supplies one") | ||
XCTAssertEqual(context?.did, alice) | ||
XCTAssertEqual(context?.pds, AccountServer.test.pds, "Signed-in calls go to the account's PDS") | ||
} | ||
| ||
// MARK: - AccountError | ||
GrainTests/AuthRefreshTests.swift+3−14
GrainTests/BlueskyCrossPostTests.swift+3−3
GrainTests/CrossPostPublishTests.swift+13−13
GrainTests/EndpointContractTests.swift+124−10
GrainTests/GalleryPublishTests.swift+19−19
GrainTests/GalleryServiceTests.swift+2−2
GrainTests/OAuthSignInTests.swiftnew+526−0
526 lines changed: not loaded until asked for.