Skip to content

Commit

Permalink
Bluesky support (DPoP, PAR, PKCE, Server/Client Config
Browse files Browse the repository at this point in the history
  • Loading branch information
mattmassicotte committed Dec 5, 2024
1 parent b687e1d commit 82562b0
Show file tree
Hide file tree
Showing 16 changed files with 1,032 additions and 340 deletions.
96 changes: 77 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ Features:
- Fine-grained control over the entire token and refresh flow
- Optional integration with `ASWebAuthenticationSession`
- Control over when and if users are prompted to log into a service
- Preliminary support for PAR, PKCE, Server/Client Metadata, and DPoP

This library currently doesn't have functional JWT or JWK generation, and both are required for DPoP. You must use an external JWT library to do this, connected to the system via the `DPoPSigner.JWTGenerator` function. I have used [jose-swift](https://github.com/beatt83/jose-swift) with success.

There's also built-in support for services to streamline integration:

- GitHub
- Mastodon
- Google API
- Bluesky

If you'd like to contribute a similar thing for another service, please open a PR!

Expand Down Expand Up @@ -47,25 +51,34 @@ let storage = LoginStorage {
}

// application credentials for your OAuth service
let appCreds = AppCredentials(clientId: "client_id",
clientPassword: "client_secret",
scopes: [],
callbackURL: URL(string: "my://callback")!)
let appCreds = AppCredentials(
clientId: "client_id",
clientPassword: "client_secret",
scopes: [],
callbackURL: URL(string: "my://callback")!
)

// the user authentication function
let userAuthenticator = ASWebAuthenticationSession.userAuthenticator

// functions that define how tokens are issued and refreshed
// This is the most complex bit, as all the pieces depend on exactly how the OAuth-based service works
let tokenHandling = TokenHandling(authorizationURLProvider: { appCreds in URL(string: "based on app credentials") }
loginProvider: { authURL, appCreds, codeURL, urlLoader in ... }
refreshProvider: { existingLogin, appCreds, urlLoader in ... },
responseStatusProvider: TokenHandling.refreshOrAuthorizeWhenUnauthorized)
// This is the most complex bit, as all the pieces depend on exactly how the OAuth-based service works.
// parConfiguration, and dpopJWTGenerator are optional
let tokenHandling = TokenHandling(
parConfiguration: PARConfiguration(url: parEndpointURL, parameters: extraQueryParams),
authorizationURLProvider: { params in URL(string: "based on app credentials") }
loginProvider: { params in ... }
refreshProvider: { existingLogin, appCreds, urlLoader in ... },
responseStatusProvider: TokenHandling.refreshOrAuthorizeWhenUnauthorized,
dpopJWTGenerator: { params in "signed JWT" }
)

let config = Authenticator.Configuration(appCredentials: appCreds,
loginStorage: storage,
tokenHandling: tokenHandling,
userAuthenticator: userAuthenticator)
let config = Authenticator.Configuration(
appCredentials: appCreds,
loginStorage: storage,
tokenHandling: tokenHandling,
userAuthenticator: userAuthenticator
)

let authenticator = Authenticator(config: config)

Expand All @@ -74,7 +87,7 @@ let myRequest = URLRequest(...)
let (data, response) = try await authenticator.response(for: myRequest)
```

If you want to receive the result of the authentication process without issuing a URLRequest first, you can specify
If you want to receive the result of the authentication process without issuing a request first, you can specify
an optional `Authenticator.AuthenticationStatusHandler` callback function within the `Authenticator.Configuration` initializer.

This allows you to support special cases where you need to capture the `Login` object before executing your first
Expand All @@ -91,11 +104,14 @@ let authenticationStatusHandler: Authenticator.AuthenticationStatusHandler = { r
}

// Configure Authenticator with result callback
let config = Authenticator.Configuration(appCredentials: appCreds,
tokenHandling: tokenHandling,
mode: .manualOnly,
userAuthenticator: userAuthenticator,
authenticationStatusHandler: authenticationStatusHandler)
let config = Authenticator.Configuration(
appCredentials: appCreds,
tokenHandling: tokenHandling,
mode: .manualOnly,
userAuthenticator: userAuthenticator,
authenticationStatusHandler: authenticationStatusHandler
)

let auth = Authenticator(config: config, urlLoader: mockLoader)
try await auth.authenticate()
if let authenticatedLogin = authenticatedLogin {
Expand Down Expand Up @@ -234,6 +250,48 @@ request.httpBody = ... // File data to upload
let (data, response) = try await authenticator.response(for: request)
```

### Bluesky API

Bluesky has a [complex](https://docs.bsky.app/docs/advanced-guides/oauth-client) OAuth implementation.

> [!WARNING]
> bsky.social's DPoP nonce changes frequently (maybe ever 10-30 seconds?). I have observed that if the nonce changes between a user requested a 2FA code and the code being entered, the server will reject the login attempt. Trying again will involve user interaction.

```swift
let responseProvider = URLSession.defaultProvider
let account = "myhandle.com"
let server = "https://bsky.social"
let clientMetadataEndpoint = "https://example.com/public/facing/client-metadata.json"

// You should know the client configuration, and could general the needed AppCredentials struct manually instead.
// The required fields are "clientId", "callbackURL", and "scopes"
let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider)
let serverConfig = try await ServerMetadata.load(for: server, provider: provider)

let jwtGenerator: DPoPSigner.JWTGenerator = { params in
// generate a P-256 signed token that uses `params` to match the specifications from
// https://docs.bsky.app/docs/advanced-guides/oauth-client#dpop
}

let tokenHandling = Bluesky.tokenHandling(
account: account,
server: serverConfig,
client: clientConfig,
jwtGenerator: jwtGenerator
)

let config = Authenticator.Configuration(
appCredentials: clientConfig.credentials,
loginStorage: loginStore,
tokenHandling: tokenHandling
)

let authenticator = Authenticator(config: config)

// you can now use this authenticator to make requests against the user's PDS. Resolving the PDS, which is not be the same as the authentication server, is beyond the scope of this library.
```

## Contributing and Collaboration

I'd love to hear from you! Get in touch via an issue or pull request.
Expand Down
148 changes: 129 additions & 19 deletions Sources/OAuthenticator/Authenticator.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation
import AuthenticationServices

public enum AuthenticatorError: Error {
public enum AuthenticatorError: Error, Hashable {
case missingScheme
case missingAuthorizationCode
case missingTokenURL
Expand All @@ -15,6 +15,9 @@ public enum AuthenticatorError: Error {
case missingRefreshToken
case missingScope
case failingAuthenticatorUsed
case dpopTokenExpected(String)
case parRequestURIMissing
case stateTokenMismatch(String, String)
}

/// Manage state required to executed authenticated URLRequests.
Expand All @@ -40,40 +43,59 @@ public actor Authenticator {
case manualOnly
}

struct PARResponse: Codable, Hashable, Sendable {
public let requestURI: String
public let expiresIn: Int

enum CodingKeys: String, CodingKey {
case requestURI = "request_uri"
case expiresIn = "expires_in"
}

var expiry: Date {
Date(timeIntervalSinceNow: Double(expiresIn))
}
}

public struct Configuration {
public let appCredentials: AppCredentials

public let loginStorage: LoginStorage?
public let tokenHandling: TokenHandling
public let userAuthenticator: UserAuthenticator
public let mode: UserAuthenticationMode

// Specify an authenticationResult closure to obtain result and grantedScope
public let authenticationStatusHandler: AuthenticationStatusHandler?

@available(tvOS 16.0, macCatalyst 13.0, *)
public init(appCredentials: AppCredentials,
loginStorage: LoginStorage? = nil,
tokenHandling: TokenHandling,
mode: UserAuthenticationMode = .automatic,
authenticationStatusHandler: AuthenticationStatusHandler? = nil) {
public init(
appCredentials: AppCredentials,
loginStorage: LoginStorage? = nil,
tokenHandling: TokenHandling,
mode: UserAuthenticationMode = .automatic,
authenticationStatusHandler: AuthenticationStatusHandler? = nil
) {
self.appCredentials = appCredentials
self.loginStorage = loginStorage
self.tokenHandling = tokenHandling
self.mode = mode

// It *should* be possible to use just a reference to
// ASWebAuthenticationSession.userAuthenticator directly here
// with GlobalActorIsolatedTypesUsability, but it isn't working
self.userAuthenticator = { try await ASWebAuthenticationSession.userAuthenticator(url: $0, scheme: $1) }
self.authenticationStatusHandler = authenticationStatusHandler
}

public init(appCredentials: AppCredentials,
loginStorage: LoginStorage? = nil,
tokenHandling: TokenHandling,
mode: UserAuthenticationMode = .automatic,
userAuthenticator: @escaping UserAuthenticator,
authenticationStatusHandler: AuthenticationStatusHandler? = nil) {
public init(
appCredentials: AppCredentials,
loginStorage: LoginStorage? = nil,
tokenHandling: TokenHandling,
mode: UserAuthenticationMode = .automatic,
userAuthenticator: @escaping UserAuthenticator,
authenticationStatusHandler: AuthenticationStatusHandler? = nil
) {
self.appCredentials = appCredentials
self.loginStorage = loginStorage
self.tokenHandling = tokenHandling
Expand All @@ -88,6 +110,9 @@ public actor Authenticator {
let urlLoader: URLResponseProvider
private var activeTokenTask: Task<Login, Error>?
private var localLogin: Login?
private let pkce = PKCEVerifier()
private var dpop = DPoPSigner()
private let stateToken = UUID().uuidString

public init(config: Configuration, urlLoader loader: URLResponseProvider? = nil) {
self.config = config
Expand Down Expand Up @@ -150,9 +175,11 @@ public actor Authenticator {
var authedRequest = request
let token = login.accessToken.value

authedRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if config.tokenHandling.dpopJWTGenerator == nil {
authedRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}

return try await urlLoader(authedRequest)
return try await dpopResponse(for: authedRequest, login: login)
}

/// Manually perform user authentication, if required.
Expand Down Expand Up @@ -252,11 +279,32 @@ extension Authenticator {
throw AuthenticatorError.manualAuthenticationRequired
}

let codeURL = try await config.tokenHandling.authorizationURLProvider(config.appCredentials, responseProvider)
let parRequestURI = try await getPARRequestURI()

let authConfig = TokenHandling.AuthorizationURLParameters(
credentials: config.appCredentials,
pcke: pkce,
parRequestURI: parRequestURI,
stateToken: stateToken,
responseProvider: { try await self.dpopResponse(for: $0, login: nil) }
)

let tokenURL = try await config.tokenHandling.authorizationURLProvider(authConfig)

let scheme = try config.appCredentials.callbackURLScheme

let url = try await userAuthenticator(codeURL, scheme)
let login = try await config.tokenHandling.loginProvider(url, config.appCredentials, codeURL, urlLoader)
let callbackURL = try await userAuthenticator(tokenURL, scheme)

let params = TokenHandling.LoginProviderParameters(
authorizationURL: tokenURL,
credentials: config.appCredentials,
redirectURL: callbackURL,
responseProvider: { try await self.dpopResponse(for: $0, login: nil) },
stateToken: stateToken,
pcke: pkce
)

let login = try await config.tokenHandling.loginProvider(params)

try await storeLogin(login)

Expand All @@ -276,16 +324,78 @@ extension Authenticator {
return nil
}

let login = try await refreshProvider(login, config.appCredentials, urlLoader)
let login = try await refreshProvider(login, config.appCredentials, { try await self.dpopResponse(for: $0, login: login) })

try await storeLogin(login)

return login
}

private func parRequest(url: URL, params: [String: String]) async throws -> PARResponse {
let challenge = pkce.challenge
let scopes = config.appCredentials.scopes.joined(separator: " ")
let callbackURI = config.appCredentials.callbackURL
let clientId = config.appCredentials.clientId

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

let base: [String: String] = [
"client_id": clientId,
"state": stateToken,
"scope": scopes,
"response_type": "code",
"redirect_uri": callbackURI.absoluteString,
"code_challenge": challenge.value,
"code_challenge_method": challenge.method,
]

let body = params
.merging(base, uniquingKeysWith: { a, b in a })
.map({ [$0, $1].joined(separator: "=") })
.joined(separator: "&")

request.httpBody = Data(body.utf8)

let (parData, _) = try await dpopResponse(for: request, login: nil)

return try JSONDecoder().decode(PARResponse.self, from: parData)
}

private func getPARRequestURI() async throws -> String? {
guard let parConfig = config.tokenHandling.parConfiguration else {
return nil
}

let parResponse = try await parRequest(url: parConfig.url, params: parConfig.parameters)

return parResponse.requestURI
}
}

extension Authenticator {
public nonisolated var responseProvider: URLResponseProvider {
{ try await self.response(for: $0) }
}

private func dpopResponse(for request: URLRequest, login: Login?) async throws -> (Data, URLResponse) {
guard let generator = config.tokenHandling.dpopJWTGenerator else {
return try await urlLoader(request)
}

let token = login?.accessToken.value
let tokenHash = token.map { PKCEVerifier.computeHash($0) }

return try await self.dpop.response(
isolation: self,
for: request,
using: generator,
token: token,
tokenHash: tokenHash,
issuingServer: login?.issuingServer,
provider: urlLoader
)
}
}
Loading

0 comments on commit 82562b0

Please sign in to comment.