Skip to content

ChimeHQ/OAuthenticator

Build Status Platforms Documentation

OAuthenticator

Lightweight OAuth 2.0 request authentication in Swift

There are lots of OAuth solutions out there. This one is small, uses Swift concurrency, and offers lots of control over the process.

Features:

  • Swift concurrency support
  • 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 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!

Integration

Swift Package Manager:

dependencies: [
    .package(url: "https://github.com/ChimeHQ/OAuthenticator", from: "0.3.0")
]

Usage

The main type is the Authenticator. It can execute a URLRequest in a similar fashion to URLSession, but will handle all authentication requirements and tack on the needed Authorization header. Its behavior is controlled via Authenticator.Configuration and URLResponseProvider. By default, the URLResponseProvider will be a private URLSession, but you can customize this if needed.

Setting up a Configuration can be more work, depending on the OAuth service you're interacting with.

// backing storage for your authentication data. Without this, tokens will be tied to the lifetime of the `Authenticator`.
let storage = LoginStorage {
    // get login here
} storeLogin: { login in
    // store `login` for later retrieval
}

// application credentials for your OAuth service
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.
// 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 authenticator = Authenticator(config: config)

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 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 authenticated URLRequest and manage that separately.

let authenticationStatusHandler: Authenticator.AuthenticationStatusHandler = { result in
    switch result {
    case .success (let login): 
        authenticatedLogin = login
    case .failure(let error):
        print("Authentication failed: \(error)")
    }
}

// Configure Authenticator with result callback
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 {
    // Process special case
    ...
}

GitHub

OAuthenticator also comes with pre-packaged configuration for GitHub, which makes set up much more straight-forward.

// pre-configured for GitHub
let appCreds = AppCredentials(clientId: "client_id",
                              clientPassword: "client_secret",
                              scopes: [],
                              callbackURL: URL(string: "my://callback")!)

let config = Authenticator.Configuration(appCredentials: appCreds,
                                         tokenHandling: GitHub.tokenHandling())

let authenticator = Authenticator(config: config)

let myRequest = URLRequest(...)

let (data, response) = try await authenticator.response(for: myRequest)

Mastodon

OAuthenticator also comes with pre-packaged configuration for Mastodon, which makes set up much more straight-forward. For more info, please check out https://docs.joinmastodon.org/client/token/

// pre-configured for Mastodon
let userTokenParameters = Mastodon.UserTokenParameters(
    host: "mastodon.social",
    clientName: "MyMastodonApp",
    redirectURI: "myMastodonApp://mastodon/oauth",
    scopes: ["read", "write", "follow"]
)

// The first thing we will need to do is to register an application, in order to be able to generate access tokens later.
// These values will be used to generate access tokens, so they should be cached for later use
let registrationData = try await Mastodon.register(with: userTokenParameters) { request in
    try await URLSession.shared.data(for: request)
}

// Now that we have an application, let’s obtain an access token that will authenticate our requests as that client application.
guard let redirectURI = registrationData.redirectURI, let callbackURL = URL(string: redirectURI) else {
    throw AuthenticatorError.missingRedirectURI
}

let appCreds = AppCredentials(
    clientId: registrationData.clientID,
    clientPassword: registrationData.clientSecret,
    scopes: userTokenParameters.scopes,
    callbackURL: callbackURL
)

let config = Authenticator.Configuration(
    appCredentials: appCreds,
    tokenHandling: Mastodon.tokenHandling(with: userTokenParameters)
)

let authenticator = Authenticator(config: config)

var urlBuilder = URLComponents()
urlBuilder.scheme = Mastodon.scheme
urlBuilder.host = userTokenParameters.host

guard let url = urlBuilder.url else {
    throw AuthenticatorError.missingScheme
}

let request = URLRequest(url: url)

let (data, response) = try await authenticator.response(for: request)

Google API

OAuthenticator also comes with pre-packaged configuration for Google APIs (access to Google Drive, Google People, Google Calendar, ...) according to the application requested scopes.

More info about those at Google Workspace. The Google OAuth process is described in Google Identity

Integration example below:

// Configuration for Google API

// Define how to store and retrieve the Google Access and Refresh Token
let storage = LoginStorage {
    // Fetch token and return them as a Login object
    return LoginFromSecureStorage(...) 
} storeLogin: { login in
    // Store access and refresh token in Secure storage
    MySecureStorage(login: login)
}

let appCreds = AppCredentials(clientId: googleClientApp.client_id,
                              clientPassword: googleClientApp.client_secret,
                              scopes: googleClientApp.scopes,
                              callbackURL: googleClient.callbackURL)

let config = Authenticator.Configuration(appCredentials: Self.oceanCredentials,
                                         loginStorage: storage,
                                         tokenHandling: tokenHandling,
                                         mode: .automatic)

let authenticator = Authenticator(config: config)

// If you just want the user to authenticate his account and get the tokens, do 1:
// If you want to access a secure Google endpoint with the proper access token, do 2:

// 1: Only Authenticate
try await authenticator.authenticate()

// 2: Access secure Google endpoint (ie: Google Drive: upload a file) with access token
var urlBuilder = URLComponents()
urlBuilder.scheme = GoogleAPI.scheme          // https:
urlBuilder.host = GoogleAPI.host              // www.googleapis.com
urlBuilder.path = GoogleAPI.path              // /upload/drive/v3/files
urlBuilder.queryItems = [
    URLQueryItem(name: GoogleDrive.uploadType, value: "media"),
]

guard let url = urlBuilder.url else {
    throw AuthenticatorError.missingScheme
}

let request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = ...          // File data to upload

let (data, response) = try await authenticator.response(for: request)

Bluesky API

Bluesky has a complex 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.

Resovling PDS servers for a user is involved, but you can start with plc.directory, which can sometimes work. The actual resolution, however, is beyond the scope of this library.

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. Remember, the PDS will not be the same as the authentication server.

Contributing and Collaboration

I'd love to hear from you! Get in touch via an issue or pull request.

I prefer collaboration, and would love to find ways to work together if you have a similar project.

I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace.

By participating in this project you agree to abide by the Contributor Code of Conduct.