+1
-1
Package.resolved
+1
-1
Package.resolved
+6
-1
Sources/CoreATProtocol/APEnvironment.swift
+6
-1
Sources/CoreATProtocol/APEnvironment.swift
···
5
5
// Created by Thomas Rademaker on 10/10/25.
6
6
//
7
7
8
+
import OAuthenticator
9
+
8
10
@APActor
9
11
public class APEnvironment {
10
12
public static var current: APEnvironment = APEnvironment()
···
12
14
public var host: String?
13
15
public var accessToken: String?
14
16
public var refreshToken: String?
17
+
public var login: Login?
18
+
public var dpopProofGenerator: DPoPSigner.JWTGenerator?
19
+
public var resourceServerNonce: String?
15
20
public var atProtocoldelegate: CoreATProtocolDelegate?
16
21
public let routerDelegate = APRouterDelegate()
22
+
public let resourceDPoPSigner = DPoPSigner()
17
23
18
24
private init() {}
19
25
···
23
29
// self.userAgent = userAgent
24
30
// }
25
31
}
26
-
+28
Sources/CoreATProtocol/CoreATProtocol.swift
+28
Sources/CoreATProtocol/CoreATProtocol.swift
···
1
1
// The Swift Programming Language
2
2
// https://docs.swift.org/swift-book
3
3
4
+
@_exported import OAuthenticator
5
+
4
6
public protocol CoreATProtocolDelegate: AnyObject {}
5
7
6
8
@APActor
···
26
28
public func update(hostURL: String?) {
27
29
APEnvironment.current.host = hostURL
28
30
}
31
+
32
+
@APActor
33
+
public func applyAuthenticationContext(login: Login, generator: @escaping DPoPSigner.JWTGenerator, resourceNonce: String? = nil) {
34
+
APEnvironment.current.login = login
35
+
APEnvironment.current.accessToken = login.accessToken.value
36
+
APEnvironment.current.refreshToken = login.refreshToken?.value
37
+
APEnvironment.current.dpopProofGenerator = generator
38
+
APEnvironment.current.resourceServerNonce = resourceNonce
39
+
APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce
40
+
}
41
+
42
+
@APActor
43
+
public func clearAuthenticationContext() {
44
+
APEnvironment.current.login = nil
45
+
APEnvironment.current.dpopProofGenerator = nil
46
+
APEnvironment.current.resourceServerNonce = nil
47
+
APEnvironment.current.accessToken = nil
48
+
APEnvironment.current.refreshToken = nil
49
+
APEnvironment.current.resourceDPoPSigner.nonce = nil
50
+
}
51
+
52
+
@APActor
53
+
public func updateResourceDPoPNonce(_ nonce: String?) {
54
+
APEnvironment.current.resourceServerNonce = nonce
55
+
APEnvironment.current.resourceDPoPSigner.nonce = nonce
56
+
}
+83
Sources/CoreATProtocol/DPoPJWTGenerator.swift
+83
Sources/CoreATProtocol/DPoPJWTGenerator.swift
···
1
+
import Foundation
2
+
import JWTKit
3
+
import OAuthenticator
4
+
5
+
public enum DPoPKeyMaterialError: Error, Equatable {
6
+
case publicKeyUnavailable
7
+
case invalidCoordinate
8
+
}
9
+
10
+
public actor DPoPJWTGenerator {
11
+
private let privateKey: ES256PrivateKey
12
+
private let keys: JWTKeyCollection
13
+
private let jwkHeader: [String: JWTHeaderField]
14
+
15
+
public init(privateKey: ES256PrivateKey) async throws {
16
+
self.privateKey = privateKey
17
+
self.keys = JWTKeyCollection()
18
+
self.jwkHeader = try Self.makeJWKHeader(from: privateKey)
19
+
await self.keys.add(ecdsa: privateKey)
20
+
}
21
+
22
+
public func jwtGenerator() -> DPoPSigner.JWTGenerator {
23
+
{ params in
24
+
try await self.makeJWT(for: params)
25
+
}
26
+
}
27
+
28
+
public func makeJWT(for params: DPoPSigner.JWTParameters) async throws -> String {
29
+
var header = JWTHeader()
30
+
header.typ = params.keyType
31
+
header.alg = header.alg ?? "ES256"
32
+
header.jwk = jwkHeader
33
+
34
+
let issuedAt = Date()
35
+
let payload = DPoPPayload(
36
+
htm: params.httpMethod,
37
+
htu: params.requestEndpoint,
38
+
iat: IssuedAtClaim(value: issuedAt),
39
+
exp: ExpirationClaim(value: issuedAt.addingTimeInterval(60)),
40
+
jti: IDClaim(value: UUID().uuidString),
41
+
nonce: params.nonce,
42
+
iss: params.issuingServer.map { IssuerClaim(value: $0) },
43
+
ath: params.tokenHash
44
+
)
45
+
46
+
return try await keys.sign(payload, header: header)
47
+
}
48
+
49
+
private static func makeJWKHeader(from key: ES256PrivateKey) throws -> [String: JWTHeaderField] {
50
+
guard let parameters = key.publicKey.parameters else {
51
+
throw DPoPKeyMaterialError.publicKeyUnavailable
52
+
}
53
+
54
+
guard
55
+
let xData = Data(base64Encoded: parameters.x),
56
+
let yData = Data(base64Encoded: parameters.y)
57
+
else {
58
+
throw DPoPKeyMaterialError.invalidCoordinate
59
+
}
60
+
61
+
return [
62
+
"kty": .string("EC"),
63
+
"crv": .string("P-256"),
64
+
"x": .string(xData.base64URLEncodedString()),
65
+
"y": .string(yData.base64URLEncodedString())
66
+
]
67
+
}
68
+
}
69
+
70
+
struct DPoPPayload: JWTPayload {
71
+
let htm: String
72
+
let htu: String
73
+
let iat: IssuedAtClaim
74
+
let exp: ExpirationClaim
75
+
let jti: IDClaim
76
+
let nonce: String?
77
+
let iss: IssuerClaim?
78
+
let ath: String?
79
+
80
+
func verify(using key: some JWTAlgorithm) throws {
81
+
try exp.verifyNotExpired(currentDate: Date())
82
+
}
83
+
}
+11
Sources/CoreATProtocol/Extensions/Data+Base64URL.swift
+11
Sources/CoreATProtocol/Extensions/Data+Base64URL.swift
···
1
+
import Foundation
2
+
3
+
extension Data {
4
+
/// Returns a URL-safe Base64 representation without padding.
5
+
func base64URLEncodedString() -> String {
6
+
base64EncodedString()
7
+
.replacingOccurrences(of: "+", with: "-")
8
+
.replacingOccurrences(of: "/", with: "_")
9
+
.replacingOccurrences(of: "=", with: "")
10
+
}
11
+
}
+18
-55
Sources/CoreATProtocol/LoginService.swift
+18
-55
Sources/CoreATProtocol/LoginService.swift
···
7
7
8
8
import Foundation
9
9
import OAuthenticator
10
-
import JWTKit
11
-
import CryptoKit
12
10
13
11
@APActor
14
-
class LoginService {
15
-
private var keys: JWTKeyCollection
16
-
private var privateKey: ES256PrivateKey
17
-
18
-
public init() async {
19
-
// Create keys once during initialization
20
-
self.privateKey = ES256PrivateKey()
21
-
self.keys = JWTKeyCollection()
22
-
// Add the key to the collection
23
-
await self.keys.add(ecdsa: privateKey)
12
+
public final class LoginService {
13
+
public enum Error: Swift.Error {
14
+
case missingStoredLogin
15
+
}
16
+
17
+
private let loginStorage: LoginStorage
18
+
private let jwtGenerator: DPoPSigner.JWTGenerator
19
+
20
+
public init(jwtGenerator: @escaping DPoPSigner.JWTGenerator, loginStorage: LoginStorage) {
21
+
self.jwtGenerator = jwtGenerator
22
+
self.loginStorage = loginStorage
24
23
}
25
-
26
-
public func login(account: String, clientMetadataEndpoint: String) async throws {
24
+
25
+
public func login(account: String, clientMetadataEndpoint: String) async throws -> Login {
27
26
let provider = URLSession.defaultProvider
28
27
let host = APEnvironment.current.host ?? ""
29
28
let server = if host.hasPrefix("https://") {
···
34
33
35
34
let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider)
36
35
let serverConfig = try await ServerMetadata.load(for: server, provider: provider)
37
-
38
-
// Create storage for persisting login state
39
-
let loginStorage = LoginStorage {
40
-
// Implement retrieving stored login
41
-
// Return stored Login if it exists, or nil
42
-
return nil
43
-
} storeLogin: { login in
44
-
// Implement storing the login
45
-
// Store the login securely
46
-
47
-
print("LOGIN: \(login)")
48
-
}
49
-
50
-
let jwtGenerator: DPoPSigner.JWTGenerator = { params in
51
-
try await self.generateJWT(params: params)
52
-
}
53
36
54
37
let tokenHandling = Bluesky.tokenHandling(account: account, server: serverConfig, jwtGenerator: jwtGenerator)
55
38
let config = Authenticator.Configuration(appCredentials: clientConfig.credentials, loginStorage: loginStorage, tokenHandling: tokenHandling, mode: .automatic)
56
39
let authenticator = Authenticator(config: config)
57
40
try await authenticator.authenticate()
58
-
}
59
-
60
-
private func generateJWT(params: DPoPSigner.JWTParameters) async throws -> String {
61
-
// Create DPoP payload using existing keys
62
-
let payload = DPoPPayload(
63
-
htm: params.httpMethod,
64
-
htu: params.requestEndpoint,
65
-
iat: .init(value: .now),
66
-
jti: .init(value: UUID().uuidString),
67
-
nonce: params.nonce
68
-
)
69
-
70
-
// Sign with existing keys
71
-
return try await self.keys.sign(payload)
72
-
}
73
-
}
74
41
75
-
private struct DPoPPayload: JWTPayload {
76
-
let htm: String
77
-
let htu: String
78
-
let iat: IssuedAtClaim
79
-
let jti: IDClaim
80
-
let nonce: String?
81
-
82
-
func verify(using key: some JWTAlgorithm) throws {
83
-
// No additional verification needed
42
+
guard let storedLogin = try await loginStorage.retrieveLogin() else {
43
+
throw Error.missingStoredLogin
44
+
}
45
+
46
+
return storedLogin
84
47
}
85
48
}
+35
-4
Sources/CoreATProtocol/Networking.swift
+35
-4
Sources/CoreATProtocol/Networking.swift
···
6
6
//
7
7
8
8
import Foundation
9
+
import CryptoKit
10
+
@preconcurrency import OAuthenticator
9
11
10
12
extension JSONDecoder {
11
13
public static var atDecoder: JSONDecoder {
···
30
32
return differenceInMinutes >= timeLimit
31
33
}
32
34
33
-
@APActor
35
+
@MainActor
34
36
public class APRouterDelegate: NetworkRouterDelegate {
35
37
private var shouldRefreshToken = false
36
38
37
39
public func intercept(_ request: inout URLRequest) async {
38
-
if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken {
40
+
if let generator = await APEnvironment.current.dpopProofGenerator,
41
+
let login = await APEnvironment.current.login {
42
+
let token = login.accessToken.value
43
+
let tokenHash = tokenHash(for: token)
44
+
let signer = await APEnvironment.current.resourceDPoPSigner
45
+
signer.nonce = await APEnvironment.current.resourceServerNonce
46
+
47
+
do {
48
+
try await signer.authenticateRequest(
49
+
&request,
50
+
isolation: MainActor.shared,
51
+
using: generator,
52
+
token: token,
53
+
tokenHash: tokenHash,
54
+
issuer: login.issuingServer
55
+
)
56
+
} catch {
57
+
// If DPoP signing fails, fall back to providing the token directly.
58
+
request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization")
59
+
}
60
+
61
+
return
62
+
}
63
+
64
+
if let refreshToken = await APEnvironment.current.refreshToken, shouldRefreshToken {
39
65
shouldRefreshToken = false
40
66
request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization")
41
-
} else if let accessToken = APEnvironment.current.accessToken {
67
+
} else if let accessToken = await APEnvironment.current.accessToken {
42
68
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
43
69
}
44
70
}
···
65
91
message.error == AtErrorType.expiredToken.rawValue {
66
92
return try await getNewToken()
67
93
}
68
-
94
+
69
95
return false
96
+
}
97
+
98
+
private func tokenHash(for token: String) -> String {
99
+
let digest = SHA256.hash(data: Data(token.utf8))
100
+
return Data(digest).base64URLEncodedString()
70
101
}
71
102
}
+1
-1
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
+1
-1
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift