diff --git a/README.md b/README.md index 1c9662d..d897cbd 100644 --- a/README.md +++ b/README.md @@ -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! @@ -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) @@ -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 @@ -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 { @@ -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. diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 14d4ee2..96c19f6 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -1,7 +1,7 @@ import Foundation import AuthenticationServices -public enum AuthenticatorError: Error { +public enum AuthenticatorError: Error, Hashable { case missingScheme case missingAuthorizationCode case missingTokenURL @@ -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. @@ -40,6 +43,20 @@ 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 @@ -47,20 +64,23 @@ public actor Authenticator { 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 @@ -68,12 +88,14 @@ public actor Authenticator { 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 @@ -88,6 +110,9 @@ public actor Authenticator { let urlLoader: URLResponseProvider private var activeTokenTask: Task? 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 @@ -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. @@ -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) @@ -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 + ) + } } diff --git a/Sources/OAuthenticator/DPoPSigner.swift b/Sources/OAuthenticator/DPoPSigner.swift index b72cde2..8d3e6e1 100644 --- a/Sources/OAuthenticator/DPoPSigner.swift +++ b/Sources/OAuthenticator/DPoPSigner.swift @@ -1,12 +1,196 @@ -struct JSONWebKey { - public enum KeyType: String, Sendable { - case rsa - case ec +#if canImport(CryptoKit) +import Foundation + +struct DPoPTokenPayload: Codable, Hashable, Sendable { + public let uniqueCode: String + public let httpMethod: String + public let httpRequestURL: String + /// UNIX type, seconds since epoch + public let createdAt: Int + /// UNIX type, seconds since epoch + public let expiresAt: Int + public let nonce: String? + + public enum CodingKeys: String, CodingKey { + case uniqueCode = "jti" + case httpMethod = "htm" + case httpRequestURL = "htu" + case createdAt = "iat" + case expiresAt = "exp" + case nonce + } + + public init( + httpMethod: String, + httpRequestURL: String, + createdAt: Int, + expiresAt: Int, + nonce: String? = nil + ) { + self.uniqueCode = UUID().uuidString + self.httpMethod = httpMethod + self.httpRequestURL = httpRequestURL + self.createdAt = createdAt + self.expiresAt = expiresAt + self.nonce = nonce + } +} + +struct DPoPRequestPayload: Codable, Hashable, Sendable { + public let uniqueCode: String + public let httpMethod: String + public let httpRequestURL: String + /// UNIX type, seconds since epoch + public let createdAt: Int + /// UNIX type, seconds since epoch + public let expiresAt: Int + public let nonce: String? + public let authorizationServerIssuer: String + public let accessTokenHash: String + + public enum CodingKeys: String, CodingKey { + case uniqueCode = "jti" + case httpMethod = "htm" + case httpRequestURL = "htu" + case createdAt = "iat" + case expiresAt = "exp" + case nonce + case authorizationServerIssuer = "iss" + case accessTokenHash = "ath" } - public let keyType: KeyType + public init( + httpMethod: String, + httpRequestURL: String, + createdAt: Int, + expiresAt: Int, + nonce: String, + authorizationServerIssuer: String, + accessTokenHash: String + ) { + self.uniqueCode = UUID().uuidString + self.httpMethod = httpMethod + self.httpRequestURL = httpRequestURL + self.createdAt = createdAt + self.expiresAt = expiresAt + self.nonce = nonce + self.authorizationServerIssuer = authorizationServerIssuer + self.accessTokenHash = accessTokenHash + } +} + +public enum DPoPError: Error { + case nonceExpected(URLResponse) + case requestInvalid(URLRequest) } -final class DPoPSigner { +/// Manages state and operations for OAuth Demonstrating Proof-of-Possession (DPoP). +/// +/// Currently only uses ES256. +/// +/// Details here: https://datatracker.ietf.org/doc/html/rfc9449 +public final class DPoPSigner { + public struct JWTParameters: Sendable, Hashable { + public let keyType: String + + public let httpMethod: String + public let requestEndpoint: String + public let nonce: String? + public let tokenHash: String? + public let issuingServer: String? + } + public typealias NonceDecoder = (Data, URLResponse) throws -> String + public typealias JWTGenerator = @Sendable (JWTParameters) throws -> String + private let nonceDecoder: NonceDecoder + public var nonce: String? + + public static func nonceHeaderDecoder(data: Data, response: URLResponse) throws -> String { + guard let value = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "DPoP-Nonce") else { + print("data:", String(decoding: data, as: UTF8.self)) + throw DPoPError.nonceExpected(response) + } + + return value + } + + public init(nonceDecoder: @escaping NonceDecoder = nonceHeaderDecoder) { + self.nonceDecoder = nonceDecoder + } +} + +extension DPoPSigner { + public func authenticateRequest( + _ request: inout URLRequest, + using jwtGenerator: JWTGenerator, + token: String?, + tokenHash: String?, + issuer: String? + ) throws { + guard + let method = request.httpMethod, + let url = request.url + else { + throw DPoPError.requestInvalid(request) + } + + let params = JWTParameters( + keyType: "dpop+jwt", + httpMethod: method, + requestEndpoint: url.absoluteString, + nonce: nonce, + tokenHash: tokenHash, + issuingServer: issuer + ) + + let jwt = try jwtGenerator(params) + + request.setValue(jwt, forHTTPHeaderField: "DPoP") + + if let token { + request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") + } + } + + @discardableResult + public func setNonce(from response: URLResponse) -> Bool { + let newValue = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "dpop-nonce") + + nonce = newValue + + return newValue != nil + } + + public func response( + isolation: isolated (any Actor), + for request: URLRequest, + using jwtGenerator: JWTGenerator, + token: String?, + tokenHash: String?, + issuingServer: String?, + provider: URLResponseProvider + ) async throws -> (Data, URLResponse) { + var request = request + + try authenticateRequest(&request, using: jwtGenerator, token: token, tokenHash: tokenHash, issuer: issuingServer) + + let (data, response) = try await provider(request) + + let existingNonce = nonce + + self.nonce = try nonceDecoder(data, response) + + if nonce == existingNonce { + return (data, response) + } + + print("DPoP nonce updated", existingNonce ?? "", nonce ?? "") + + // repeat once, using newly-established nonce + try authenticateRequest(&request, using: jwtGenerator, token: token, tokenHash: tokenHash, issuer: issuingServer) + + return try await provider(request) + } } + +#endif diff --git a/Sources/OAuthenticator/Data+Base64URLEncode.swift b/Sources/OAuthenticator/Data+Base64URLEncode.swift new file mode 100644 index 0000000..ff3b802 --- /dev/null +++ b/Sources/OAuthenticator/Data+Base64URLEncode.swift @@ -0,0 +1,14 @@ +import Foundation + +extension Data { + func base64EncodedURLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + init?(base64URLEncoded string: String) { + self.init(base64Encoded: string) + } +} diff --git a/Sources/OAuthenticator/JSONWebKey.swift b/Sources/OAuthenticator/JSONWebKey.swift new file mode 100644 index 0000000..8afec7e --- /dev/null +++ b/Sources/OAuthenticator/JSONWebKey.swift @@ -0,0 +1,119 @@ +/// Model of a JSON Web Key. +/// +/// Defined by: https://datatracker.ietf.org/doc/html/rfc7517 +struct JSONWebKey: Hashable, Sendable { + public struct EllipticCurveParameters: Hashable, Sendable { + public let curve: String + public let x: String + public let y: String + + public init(curve: String, x: String, y: String) { + self.curve = curve + self.x = x + self.y = y + } + } + + public enum KeyType: Hashable, Sendable { + case rsa + case ec(EllipticCurveParameters) + } + + public enum KeyUse: RawRepresentable, Hashable, Sendable { + case signature + case encryption + case custom(String) + + public init?(rawValue: String) { + switch rawValue { + case "sig": + self = .signature + case "enc": + self = .encryption + default: + self = .custom(rawValue) + } + } + + public var rawValue: String { + switch self { + case .encryption: + "enc" + case .signature: + "sig" + case let .custom(value): + value + } + } + } + + public let keyType: KeyType + public let use: KeyUse? + public let id: String? + + public init(keyType: KeyType, use: KeyUse? = nil, id: String? = nil) { + self.keyType = keyType + self.use = use + self.id = id + } + + public init(params: EllipticCurveParameters, use: KeyUse? = nil, id: String? = nil) { + self.init(keyType: .ec(params), use: use, id: id) + } +} + +extension JSONWebKey: Codable { + enum CodingKeys: String, CodingKey { + case keyType = "kty" + case use + case id = "kid" + case curve = "crv" + case ecX = "x" + case ecY = "y" + } + + public init(from decoder: any Decoder) throws { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "This just isn't implenented yet")) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch keyType { + case .rsa: + try container.encode("RSA", forKey: .keyType) + case let .ec(params): + try container.encode("EC", forKey: .keyType) + + try container.encode(params.curve, forKey: .curve) + try container.encode(params.x, forKey: .ecX) + try container.encode(params.y, forKey: .ecY) + } + + if let use { + try container.encode(use.rawValue, forKey: .use) + } + + if let id { + try container.encode(id, forKey: .id) + } + } +} + +#if canImport(CryptoKit) +import CryptoKit + +extension JSONWebKey.EllipticCurveParameters { + public init(p256Key: P256.Signing.PublicKey) { + self.init(curve: "P-256", x: "x", y: "y") + } +} + +extension JSONWebKey { + public init(p256Key: P256.Signing.PublicKey, use: KeyUse? = nil, id: String? = nil) { + let curve = EllipticCurveParameters(p256Key: p256Key) + + self.init(params: curve, use: use, id: id) + } +} +#endif diff --git a/Sources/OAuthenticator/JWT.swift b/Sources/OAuthenticator/JWT.swift new file mode 100644 index 0000000..e433b24 --- /dev/null +++ b/Sources/OAuthenticator/JWT.swift @@ -0,0 +1,69 @@ +import Foundation + +enum JSONWebTokenError: Error { + case signatureInvalid +} + +enum JSONWebTokenAlgorithm: String, Codable, Hashable, Sendable { + case ES256 +} + +protocol JSONWebTokenHeader: Codable { + var algorithm: JSONWebTokenAlgorithm { get } +} + +typealias JSONWebTokenSigner = (JSONWebTokenAlgorithm, Data) throws -> Data + +struct JSONWebToken { + public let header: Header + public let payload: Payload + + public init(header: Header, payload: Payload) { + self.header = header + self.payload = payload + } + + public func encode(with signer: JSONWebTokenSigner) throws -> String { + let encoder = JSONEncoder() + + encoder.outputFormatting = .sortedKeys + + let headerString = try encoder.encode(header).base64EncodedURLEncodedString() + let payloadString = try encoder.encode(payload).base64EncodedURLEncodedString() + + let inputData = [headerString, payloadString].joined(separator: ".") + let signatureData = try signer(header.algorithm, Data(inputData.utf8)) + + let signature = signatureData.base64EncodedURLEncodedString() + + return [headerString, payloadString, signature].joined(separator: ".") + } +} + +extension JSONWebToken: Equatable where Header: Equatable, Payload: Equatable {} +extension JSONWebToken: Hashable where Header: Hashable, Payload: Hashable {} +extension JSONWebToken: Sendable where Header: Sendable, Payload: Sendable {} + +#if canImport(CryptoKit) +import CryptoKit + +extension JSONWebToken { + public init(encodedString: String, validator: (JSONWebTokenAlgorithm, Data, Data) throws -> Bool) throws { + let components = encodedString.components(separatedBy: ".") + let headerData = Data(base64URLEncoded: components[0])! + let payloadData = Data(base64URLEncoded: components[1])! + let signatureData = Data(base64URLEncoded: components[2])! + + let decoder = JSONDecoder() + + self.header = try decoder.decode(Header.self, from: headerData) + self.payload = try decoder.decode(Payload.self, from: payloadData) + + let message = Data(components.dropLast().joined(separator: ".").utf8) + + guard try validator(self.header.algorithm, message, signatureData) else { + throw JSONWebTokenError.signatureInvalid + } + } +} +#endif diff --git a/Sources/OAuthenticator/Models.swift b/Sources/OAuthenticator/Models.swift index 9f30cc4..d64a207 100644 --- a/Sources/OAuthenticator/Models.swift +++ b/Sources/OAuthenticator/Models.swift @@ -30,11 +30,12 @@ public struct Token: Codable, Hashable, Sendable { public struct Login: Codable, Hashable, Sendable { public var accessToken: Token public var refreshToken: Token? - + // User authorized scopes public var scopes: String? - - public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil) { + public var issuingServer: String? + + public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil, issuingServer: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.scopes = scopes @@ -86,6 +87,16 @@ public struct LoginStorage { } } +public struct PARConfiguration: Hashable, Sendable { + public let url: URL + public let parameters: [String: String] + + public init(url: URL, parameters: [String : String] = [:]) { + self.url = url + self.parameters = parameters + } +} + public struct TokenHandling { public enum ResponseStatus: Hashable, Sendable { case valid @@ -94,14 +105,33 @@ public struct TokenHandling { case refreshOrAuthorize } - public typealias AuthorizationURLProvider = @Sendable (AppCredentials, URLResponseProvider) async throws -> URL + public struct AuthorizationURLParameters: Sendable { + public let credentials: AppCredentials + public let pcke: PKCEVerifier + public let parRequestURI: String? + public let stateToken: String + public let responseProvider: URLResponseProvider + } + + public struct LoginProviderParameters: Sendable { + public let authorizationURL: URL + public let credentials: AppCredentials + public let redirectURL: URL + public let responseProvider: URLResponseProvider + public let stateToken: String + public let pcke: PKCEVerifier + } + + /// The output of this is a URL suitable for user authentication in a browser. + public typealias AuthorizationURLProvider = @Sendable (AuthorizationURLParameters) async throws -> URL + /// A function that processes the results of an authentication operation /// /// URL: The result of the Configuration.UserAuthenticator function /// AppCredentials: The credentials from Configuration.appCredentials /// URL: the authenticated URL from the OAuth service /// URLResponseProvider: the authenticator's provider - public typealias LoginProvider = @Sendable (URL, AppCredentials, URL, URLResponseProvider) async throws -> Login + public typealias LoginProvider = @Sendable (LoginProviderParameters) async throws -> Login public typealias RefreshProvider = @Sendable (Login, AppCredentials, URLResponseProvider) async throws -> Login public typealias ResponseStatusProvider = @Sendable ((Data, URLResponse)) throws -> ResponseStatus @@ -109,15 +139,24 @@ public struct TokenHandling { public let loginProvider: LoginProvider public let refreshProvider: RefreshProvider? public let responseStatusProvider: ResponseStatusProvider - - public init(authorizationURLProvider: @escaping AuthorizationURLProvider, - loginProvider: @escaping LoginProvider, - refreshProvider: RefreshProvider? = nil, - responseStatusProvider: @escaping ResponseStatusProvider = Self.refreshOrAuthorizeWhenUnauthorized) { + public let dpopJWTGenerator: DPoPSigner.JWTGenerator? + public let parConfiguration: PARConfiguration? + + public init( + parConfiguration: PARConfiguration? = nil, + authorizationURLProvider: @escaping AuthorizationURLProvider, + loginProvider: @escaping LoginProvider, + refreshProvider: RefreshProvider? = nil, + responseStatusProvider: @escaping ResponseStatusProvider = Self.refreshOrAuthorizeWhenUnauthorized, + dpopJWTGenerator: DPoPSigner.JWTGenerator? = nil + + ) { self.authorizationURLProvider = authorizationURLProvider self.loginProvider = loginProvider self.refreshProvider = refreshProvider self.responseStatusProvider = responseStatusProvider + self.dpopJWTGenerator = dpopJWTGenerator + self.parConfiguration = parConfiguration } @Sendable diff --git a/Sources/OAuthenticator/PKCE.swift b/Sources/OAuthenticator/PKCE.swift new file mode 100644 index 0000000..60976b9 --- /dev/null +++ b/Sources/OAuthenticator/PKCE.swift @@ -0,0 +1,53 @@ +#if canImport(CryptoKit) +import CryptoKit +import Foundation + +extension SHA256.Digest { + var data: Data { + self.withUnsafeBytes { buffer in + Data(bytes: buffer.baseAddress!, count: buffer.count) + } + } +} + +public struct PKCEVerifier: Hashable, Sendable { + public struct Challenge: Hashable, Sendable { + public let value: String + public let method: String + } + + public let verifier: String + public let challenge: Challenge + + public static func randomString(length: Int) -> String { + let characters = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + + var string = "" + + for _ in 0.. String { + let digest = SHA256.hash(data: Data(value.utf8)) + + return digest.data.base64EncodedURLEncodedString() + } + + public func validate(_ value: String) -> Bool { + Self.computeHash(value) == verifier + } +} +#endif diff --git a/Sources/OAuthenticator/Services/Bluesky.swift b/Sources/OAuthenticator/Services/Bluesky.swift index 0011050..7473562 100644 --- a/Sources/OAuthenticator/Services/Bluesky.swift +++ b/Sources/OAuthenticator/Services/Bluesky.swift @@ -1,230 +1,129 @@ import Foundation -import CryptoKit - -struct PKCE: Hashable, Sendable { - let verifier: String - let challenge: String - let method: String - - init() { - self.method = "S256" - self.verifier = UUID().uuidString - self.challenge = Self.computeHash(verifier) - } - - static func computeHash(_ value: String) -> String { - let digest = SHA256.hash(data: Data(value.utf8)) - - return digest.map { String(format: "%02X", $0) }.joined() - } - - func validate(_ value: String) -> Bool { - Self.computeHash(value) == verifier - } -} - /// Find the spec here: https://atproto.com/specs/oauth public enum Bluesky { - public struct ServerMetadata: Codable, Hashable, Sendable { - public let issuer: String - public let authorizationEndpoint: String - public let tokenEndpoint: String - public let responseTypesSupported: [String] - public let grantTypesSupported: [String] - public let codeChallengeMethodsSupported: [String] - public let tokenEndpointAuthMethodsSupported: [String] - public let tokenEndpointAuthSigningAlgValuesSupported: [String] - public let scopesSupported: [String] - public let authorizationResponseIssParameterSupported: Bool - public let requirePushedAuthorizationRequests: Bool - public let pushedAuthorizationRequestEndpoint: String - public let dpopSigningAlgValuesSupported: [String] - public let requireRequestUriRegistration: Bool - public let clientIdMetadataDocumentSupported: Bool - - enum CodingKeys: String, CodingKey { - case issuer - case authorizationEndpoint = "authorization_endpoint" - case tokenEndpoint = "token_endpoint" - case responseTypesSupported = "response_types_supported" - case grantTypesSupported = "grant_types_supported" - case codeChallengeMethodsSupported = "code_challenge_methods_supported" - case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" - case tokenEndpointAuthSigningAlgValuesSupported = "token_endpoint_auth_signing_alg_values_supported" - case scopesSupported = "scopes_supported" - case authorizationResponseIssParameterSupported = "authorization_response_iss_parameter_supported" - case requirePushedAuthorizationRequests = "require_pushed_authorization_requests" - case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" - case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" - case requireRequestUriRegistration = "require_request_uri_registration" - case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported" + struct TokenRequest: Hashable, Sendable, Codable { + public let code: String + public let code_verifier: String + public let redirect_uri: String + public let grant_type: String + public let client_id: String + + public init(code: String, code_verifier: String, redirect_uri: String, grant_type: String, client_id: String) { + self.code = code + self.code_verifier = code_verifier + self.redirect_uri = redirect_uri + self.grant_type = grant_type + self.client_id = client_id } } - - public struct ClientConfiguration: Hashable, Sendable { - public let clientId: String - public let callbackURI: String - - public init(clientId: String, callbackURI: String) { - self.clientId = clientId - self.callbackURI = callbackURI + + struct TokenResponse: Hashable, Sendable, Codable { + public let access_token: String + public let refresh_token: String? + public let sub: String + public let scope: String + public let token_type: String + public let expires_in: Int + + public func login(for issuingServer: String) -> Login { + Login( + accessToken: Token(value: access_token, expiresIn: expires_in), + refreshToken: refresh_token.map { Token(value: $0) }, + scopes: scope, + issuingServer: issuingServer + ) } } - - public struct AuthorizationURLRequest: Codable, Hashable, Sendable { - - } - struct PARResponse: Codable, Hashable, Sendable { - let request_uri: String - let expires_in: Int - } - - public struct AuthorizationURLResponse: Hashable, Sendable { - public let requestURI: String - public let expiry: Date - let nonce: String - let pkce: PKCE - - public func validateState(_ state: String) -> Bool { - pkce.validate(state) - } + public static func tokenHandling(account: String, server: ServerMetadata, jwtGenerator: @escaping DPoPSigner.JWTGenerator) -> TokenHandling { + TokenHandling( + parConfiguration: PARConfiguration( + url: URL(string: server.pushedAuthorizationRequestEndpoint)!, + parameters: ["login_hint": account] + ), + authorizationURLProvider: authorizionURLProvider(server: server), + loginProvider: loginProvider(server: server), + refreshProvider: refreshProvider(server: server), + dpopJWTGenerator: jwtGenerator + ) } - - public static func serverConfiguration(for host: String, provider: URLResponseProvider) async throws -> ServerMetadata { - var components = URLComponents() - - components.scheme = "https" - components.host = host - components.path = "/.well-known/oauth-authorization-server" - components.queryItems = [ - URLQueryItem(name: "Accept", value: "application/json") - ] - - guard let url = components.url else { - throw AuthenticatorError.missingAuthorizationURL + + private static func authorizionURLProvider(server: ServerMetadata) -> TokenHandling.AuthorizationURLProvider { + return { params in + var components = URLComponents(string: server.authorizationEndpoint) + + guard let parRequestURI = params.parRequestURI else { + throw AuthenticatorError.parRequestURIMissing + } + + components?.queryItems = [ + URLQueryItem(name: "request_uri", value: parRequestURI), + URLQueryItem(name: "client_id", value: params.credentials.clientId), + ] + + guard let url = components?.url else { + throw AuthenticatorError.missingAuthorizationURL + } + + return url } - - let (data, _) = try await provider(URLRequest(url: url)) - - return try JSONDecoder().decode(ServerMetadata.self, from: data) } - - public static func pushAuthorizationRequest(clientConfig: ClientConfiguration, hint: String, metadata: ServerMetadata, provider: URLResponseProvider) async throws -> AuthorizationURLResponse { - guard let url = URL(string: metadata.pushedAuthorizationRequestEndpoint) else { - throw AuthenticatorError.missingAuthorizationURL - } - - let state = UUID().uuidString - let pkce = PKCE() - - 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 body = [ - "client_id=\(clientConfig.clientId)", - "state=\(state)", - "scopes=atproto", - "response_type=code", - "redirect_uri=\(clientConfig.callbackURI)", - "code_challenge=\(pkce.challenge)", - "code_challenge_method=\(pkce.method)", - "login_hint=\(hint)", - ].joined(separator: "&") - - request.httpBody = Data(body.utf8) - - let (data, response) = try await provider(request) - - guard let httpResponse = response as? HTTPURLResponse else { + + private static func loginProvider(server: ServerMetadata) -> TokenHandling.LoginProvider { + return { params in + // decode the params in the redirectURL + guard let redirectComponents = URLComponents(url: params.redirectURL, resolvingAgainstBaseURL: false) else { + throw AuthenticatorError.missingTokenURL + } + + guard + let authCode = redirectComponents.queryItems?.first(where: { $0.name == "code" })?.value, + let iss = redirectComponents.queryItems?.first(where: { $0.name == "iss" })?.value, + let state = redirectComponents.queryItems?.first(where: { $0.name == "state" })?.value + else { + throw AuthenticatorError.missingAuthorizationCode + } + + if state != params.stateToken { + throw AuthenticatorError.stateTokenMismatch(state, params.stateToken) + } + + // and use them (plus just a little more) to construct the token request + guard let tokenURL = URL(string: server.tokenEndpoint) else { + throw AuthenticatorError.missingTokenURL + } + + let tokenRequest = TokenRequest( + code: authCode, + code_verifier: params.pcke.verifier, + redirect_uri: params.credentials.callbackURL.absoluteString, + grant_type: "authorization_code", + client_id: params.credentials.clientId // is this field truly necessary? + ) + + var request = URLRequest(url: tokenURL) + + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(tokenRequest) + + let (data, response) = try await params.responseProvider(request) + print("data:", String(decoding: data, as: UTF8.self)) - - throw AuthenticatorError.httpResponseExpected + print("response:", response) + + let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) + + guard tokenResponse.token_type == "DPoP" else { + throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type) + } + + return tokenResponse.login(for: iss) } - - let nonce = httpResponse.value(forHTTPHeaderField: "dpop-nonce") ?? "" - - let parResponse = try JSONDecoder().decode(PARResponse.self, from: data) - - return AuthorizationURLResponse( - requestURI: parResponse.request_uri, - expiry: Date(timeIntervalSinceNow: Double(parResponse.expires_in)), - nonce: nonce, - pkce: pkce - ) } - -// public static func tokenHandling(with server: String) -> TokenHandling { -// TokenHandling( -// authorizationURLProvider: authorizationURLProvider(with: server), -// loginProvider: loginProvider, -// refreshProvider: refreshProvider -// ) -// } -// -// static func authorizationURLProvider(with server: String) -> TokenHandling.AuthorizationURLProvider { -// return { credentials, provider in -// -// var components = URLComponents() -// -// components.scheme = "https" -// components.host = server -// components.path = "/.well-known/oauth-authorization-server" -// components.queryItems = [ -// URLQueryItem(name: "Accept", value: "application/json") -// ] -// -// guard let url = components.url else { -// throw AuthenticatorError.missingAuthorizationURL -// } -// -// let (data, _) = try await provider(URLRequest(url: url)) -// -// let response = try JSONDecoder().decode(AuthorizationServerResponse.self, from: data) -// -// print(response) -// -//// var urlBuilder = URLComponents() -//// -//// urlBuilder.scheme = "https" -//// urlBuilder.host = host -//// urlBuilder.path = "/login/oauth/authorize" -//// urlBuilder.queryItems = [ -//// URLQueryItem(name: "client_id", value: credentials.clientId), -//// URLQueryItem(name: "redirect_uri", value: credentials.callbackURL.absoluteString), -//// URLQueryItem(name: "scope", value: credentials.scopeString), -//// ] -//// -//// if let state = parameters.state { -//// urlBuilder.queryItems?.append(URLQueryItem(name: "state", value: state)) -//// } -//// -//// guard let url = urlBuilder.url else { -// throw AuthenticatorError.missingAuthorizationURL -//// } -// -//// return url -// } -// } -// -// @Sendable -// static func loginProvider(url: URL, credentials: AppCredentials, tokenURL: URL, urlLoader: URLResponseProvider) async throws -> Login { -// throw AuthenticatorError.missingAuthorizationURL -//// let request = try authenticationRequest(with: url, appCredentials: credentials) -//// -//// let (data, _) = try await urlLoader(request) -//// -//// let response = try JSONDecoder().decode(GitHub.AppAuthResponse.self, from: data) -//// -//// return response.login -// } -// -// @Sendable -// static func refreshProvider(login: Login, credentials: AppCredentials, urlLoader: URLResponseProvider) async throws -> Login { -// // TODO: will have to figure this out -// throw AuthenticatorError.refreshUnsupported -// } + + private static func refreshProvider(server: ServerMetadata) -> TokenHandling.RefreshProvider { + { _, _, _ in throw AuthenticatorError.refreshUnsupported } + } } diff --git a/Sources/OAuthenticator/Services/GitHub.swift b/Sources/OAuthenticator/Services/GitHub.swift index 8fcdd20..32f3c95 100644 --- a/Sources/OAuthenticator/Services/GitHub.swift +++ b/Sources/OAuthenticator/Services/GitHub.swift @@ -61,19 +61,25 @@ public enum GitHub { /// TokenHandling for GitHub Apps public static func gitHubAppTokenHandling(with parameters: UserTokenParameters = .init()) -> TokenHandling { - TokenHandling(authorizationURLProvider: authorizationURLProvider(with: parameters), - loginProvider: gitHubAppLoginProvider, - refreshProvider: refreshProvider) + TokenHandling( + authorizationURLProvider: authorizationURLProvider(with: parameters), + loginProvider: gitHubAppLoginProvider, + refreshProvider: refreshProvider + ) } /// TokenHandling for OAuth Apps public static func OAuthAppTokenHandling() -> TokenHandling { - TokenHandling(authorizationURLProvider: authorizationURLProvider(with: .init()), - loginProvider: OAuthAppLoginProvider) + TokenHandling( + authorizationURLProvider: authorizationURLProvider(with: .init()), + loginProvider: OAuthAppLoginProvider + ) } static func authorizationURLProvider(with parameters: UserTokenParameters) -> TokenHandling.AuthorizationURLProvider { - return { credentials, _ in + return { params in + let credentials = params.credentials + var urlBuilder = URLComponents() urlBuilder.scheme = "https" @@ -125,10 +131,10 @@ public enum GitHub { } @Sendable - static func gitHubAppLoginProvider(url: URL, credentials: AppCredentials, tokenURL: URL, urlLoader: URLResponseProvider) async throws -> Login { - let request = try authenticationRequest(with: url, appCredentials: credentials) + static func gitHubAppLoginProvider(params: TokenHandling.LoginProviderParameters) async throws -> Login { + let request = try authenticationRequest(with: params.authorizationURL, appCredentials: params.credentials) - let (data, _) = try await urlLoader(request) + let (data, _) = try await params.responseProvider(request) let response = try JSONDecoder().decode(GitHub.AppAuthResponse.self, from: data) @@ -136,10 +142,10 @@ public enum GitHub { } @Sendable - static func OAuthAppLoginProvider(url: URL, credentials: AppCredentials, tokenURL: URL, urlLoader: URLResponseProvider) async throws -> Login { - let request = try authenticationRequest(with: url, appCredentials: credentials) + static func OAuthAppLoginProvider(params: TokenHandling.LoginProviderParameters) async throws -> Login { + let request = try authenticationRequest(with: params.authorizationURL, appCredentials: params.credentials) - let (data, _) = try await urlLoader(request) + let (data, _) = try await params.responseProvider(request) let response = try JSONDecoder().decode(GitHub.OAuthResponse.self, from: data) diff --git a/Sources/OAuthenticator/Services/GoogleAPI.swift b/Sources/OAuthenticator/Services/GoogleAPI.swift index 7f2a28a..2d98ae6 100644 --- a/Sources/OAuthenticator/Services/GoogleAPI.swift +++ b/Sources/OAuthenticator/Services/GoogleAPI.swift @@ -77,7 +77,7 @@ public struct GoogleAPI { public static func googleAPITokenHandling(with parameters: GoogleAPIParameters = .init()) -> TokenHandling { TokenHandling(authorizationURLProvider: Self.authorizationURLProvider(with: parameters), - loginProvider: Self.loginProvider(), + loginProvider: Self.loginProvider, refreshProvider: Self.refreshProvider()) } @@ -85,7 +85,9 @@ public struct GoogleAPI { /// /// Will request an authentication `code` based on the acceptance by the user public static func authorizationURLProvider(with parameters: GoogleAPIParameters) -> TokenHandling.AuthorizationURLProvider { - return { credentials, _ in + return { params in + let credentials = params.credentials + var urlBuilder = URLComponents() urlBuilder.scheme = GoogleAPI.scheme @@ -160,24 +162,23 @@ public struct GoogleAPI { return request } - - static func loginProvider() -> TokenHandling.LoginProvider { - return { url, appCredentials, tokenURL, urlLoader in - let request = try authenticationRequest(url: url, appCredentials: appCredentials) - let (data, _) = try await urlLoader(request) + @Sendable + static func loginProvider(params: TokenHandling.LoginProviderParameters) async throws -> Login { + let request = try authenticationRequest(url: params.authorizationURL, appCredentials: params.credentials) - do { - let jsonString = String(data: data, encoding: .utf8) ?? "" - os_log(.debug, "%s", jsonString) - - let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) - return response.login - } - catch let decodingError as DecodingError { - os_log(.fault, "Reponse from AuthenticationProvider is not conformed to provided response format. %s", decodingError.failureReason ?? decodingError.localizedDescription) - throw decodingError - } + let (data, _) = try await params.responseProvider(request) + + do { + let jsonString = String(data: data, encoding: .utf8) ?? "" + os_log(.debug, "%s", jsonString) + + let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) + return response.login + } + catch let decodingError as DecodingError { + os_log(.fault, "Reponse from AuthenticationProvider is not conformed to provided response format. %s", decodingError.failureReason ?? decodingError.localizedDescription) + throw decodingError } } diff --git a/Sources/OAuthenticator/Services/Mastodon.swift b/Sources/OAuthenticator/Services/Mastodon.swift index 1fd9024..ce7f6d7 100644 --- a/Sources/OAuthenticator/Services/Mastodon.swift +++ b/Sources/OAuthenticator/Services/Mastodon.swift @@ -74,13 +74,17 @@ public struct Mastodon { } public static func tokenHandling(with parameters: UserTokenParameters) -> TokenHandling { - TokenHandling(authorizationURLProvider: authorizationURLProvider(with: parameters), - loginProvider: loginProvider(with: parameters), - refreshProvider: refreshProvider(with: parameters)) + TokenHandling( + authorizationURLProvider: authorizationURLProvider(with: parameters), + loginProvider: loginProvider(with: parameters), + refreshProvider: refreshProvider(with: parameters) + ) } static func authorizationURLProvider(with parameters: UserTokenParameters) -> TokenHandling.AuthorizationURLProvider { - return { credentials, _ in + return { params in + let credentials = params.credentials + var urlBuilder = URLComponents() urlBuilder.scheme = Mastodon.scheme @@ -130,11 +134,11 @@ public struct Mastodon { return request } - static func loginProvider(with parameters: UserTokenParameters) -> TokenHandling.LoginProvider { - return { url, appCredentials, tokenURL, urlLoader in - let request = try authenticationRequest(with: parameters, url: url, appCredentials: appCredentials) + static func loginProvider(with userParameters: UserTokenParameters) -> TokenHandling.LoginProvider { + return { params in + let request = try authenticationRequest(with: userParameters, url: params.redirectURL, appCredentials: params.credentials) - let (data, _) = try await urlLoader(request) + let (data, _) = try await params.responseProvider(request) let response = try JSONDecoder().decode(Mastodon.AppAuthResponse.self, from: data) @@ -165,7 +169,9 @@ public struct Mastodon { } var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, _) = try await urlLoader(request) let registrationResponse = try JSONDecoder().decode(AppRegistrationResponse.self, from: data) diff --git a/Sources/OAuthenticator/WellknownEndpoints.swift b/Sources/OAuthenticator/WellknownEndpoints.swift new file mode 100644 index 0000000..45a829a --- /dev/null +++ b/Sources/OAuthenticator/WellknownEndpoints.swift @@ -0,0 +1,97 @@ +import Foundation + +enum MetadataError: Error { + case urlInvalid +} + +public struct ServerMetadata: Codable, Hashable, Sendable { + public let issuer: String + public let authorizationEndpoint: String + public let tokenEndpoint: String + public let responseTypesSupported: [String] + public let grantTypesSupported: [String] + public let codeChallengeMethodsSupported: [String] + public let tokenEndpointAuthMethodsSupported: [String] + public let tokenEndpointAuthSigningAlgValuesSupported: [String] + public let scopesSupported: [String] + public let authorizationResponseIssParameterSupported: Bool + public let requirePushedAuthorizationRequests: Bool + public let pushedAuthorizationRequestEndpoint: String + public let dpopSigningAlgValuesSupported: [String] + public let requireRequestUriRegistration: Bool + public let clientIdMetadataDocumentSupported: Bool + + enum CodingKeys: String, CodingKey { + case issuer + case authorizationEndpoint = "authorization_endpoint" + case tokenEndpoint = "token_endpoint" + case responseTypesSupported = "response_types_supported" + case grantTypesSupported = "grant_types_supported" + case codeChallengeMethodsSupported = "code_challenge_methods_supported" + case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" + case tokenEndpointAuthSigningAlgValuesSupported = "token_endpoint_auth_signing_alg_values_supported" + case scopesSupported = "scopes_supported" + case authorizationResponseIssParameterSupported = "authorization_response_iss_parameter_supported" + case requirePushedAuthorizationRequests = "require_pushed_authorization_requests" + case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" + case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" + case requireRequestUriRegistration = "require_request_uri_registration" + case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported" + } + + public static func load(for host: String, provider: URLResponseProvider) async throws -> ServerMetadata { + var components = URLComponents() + + components.scheme = "https" + components.host = host + components.path = "/.well-known/oauth-authorization-server" + components.queryItems = [ + URLQueryItem(name: "Accept", value: "application/json") + ] + + guard let url = components.url else { + throw MetadataError.urlInvalid + } + + let (data, _) = try await provider(URLRequest(url: url)) + + return try JSONDecoder().decode(ServerMetadata.self, from: data) + } +} + +public struct ClientMetadata: Hashable, Codable, Sendable { + public let clientId: String + public let scope: String + public let redirectURIs: [String] + public let dpopBoundAccessTokens: Bool + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case scope + case redirectURIs = "redirect_uris" + case dpopBoundAccessTokens = "dpop_bound_access_tokens" + } + + public static func load(for endpoint: String, provider: URLResponseProvider) async throws -> ClientMetadata { + guard let url = URL(string: endpoint) else { + throw MetadataError.urlInvalid + } + + let (data, _) = try await provider(URLRequest(url: url)) + + return try JSONDecoder().decode(ClientMetadata.self, from: data) + } +} + +extension ClientMetadata { + public var credentials: AppCredentials { + let url = redirectURIs.first.map({ URL(string: $0)! })! + + return AppCredentials( + clientId: clientId, + clientPassword: "", + scopes: scope.components(separatedBy: " "), + callbackURL: url + ) + } +} diff --git a/Tests/OAuthenticatorTests/AuthenticatorTests.swift b/Tests/OAuthenticatorTests/AuthenticatorTests.swift index 6142739..75159bd 100644 --- a/Tests/OAuthenticatorTests/AuthenticatorTests.swift +++ b/Tests/OAuthenticatorTests/AuthenticatorTests.swift @@ -40,17 +40,12 @@ final class AuthenticatorTests: XCTestCase { } @Sendable - private static func disabledAuthorizationURLProvider(credentials: AppCredentials) throws -> URL { + private static func disabledAuthorizationURLProvider(parameters: TokenHandling.AuthorizationURLParameters) throws -> URL { throw AuthenticatorTestsError.disabled } @Sendable - private static func disabledLoginProvider( - url: URL, - credentials: AppCredentials, - otherURL: URL, - responseProvider: URLResponseProvider - ) throws -> Login{ + private static func disabledLoginProvider(parameters: TokenHandling.LoginProviderParameters) throws -> Login { throw AuthenticatorTestsError.disabled } @@ -74,12 +69,12 @@ final class AuthenticatorTests: XCTestCase { return URL(string: "my://login")! } - let urlProvider: TokenHandling.AuthorizationURLProvider = { creds in - return URL(string: "my://auth?client_id=\(creds.clientId)")! + let urlProvider: TokenHandling.AuthorizationURLProvider = { params in + return URL(string: "my://auth?client_id=\(params.credentials.clientId)")! } - let loginProvider: TokenHandling.LoginProvider = { url, creds, tokenUrl, _ in - XCTAssertEqual(url, URL(string: "my://login")!) + let loginProvider: TokenHandling.LoginProvider = { params in + XCTAssertEqual(params.redirectURL, URL(string: "my://login")!) return Login(token: "TOKEN") } @@ -135,9 +130,11 @@ final class AuthenticatorTests: XCTestCase { return ("hello".data(using: .utf8)!, URLResponse()) } - let tokenHandling = TokenHandling(authorizationURLProvider: Self.disabledAuthorizationURLProvider, - loginProvider: Self.disabledLoginProvider, - responseStatusProvider: TokenHandling.allResponsesValid) + let tokenHandling = TokenHandling( + authorizationURLProvider: Self.disabledAuthorizationURLProvider, + loginProvider: Self.disabledLoginProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) let retrieveTokenExp = expectation(description: "get token") let storage = LoginStorage { @@ -214,12 +211,12 @@ final class AuthenticatorTests: XCTestCase { @MainActor func testManualAuthentication() async throws { - let urlProvider: TokenHandling.AuthorizationURLProvider = { creds in - return URL(string: "my://auth?client_id=\(creds.clientId)")! + let urlProvider: TokenHandling.AuthorizationURLProvider = { parameters in + return URL(string: "my://auth?client_id=\(parameters.credentials.clientId)")! } - let loginProvider: TokenHandling.LoginProvider = { url, creds, tokenUrl, _ in - XCTAssertEqual(url, URL(string: "my://login")!) + let loginProvider: TokenHandling.LoginProvider = { parameters in + XCTAssertEqual(parameters.redirectURL, URL(string: "my://login")!) return Login(token: "TOKEN") } @@ -268,12 +265,12 @@ final class AuthenticatorTests: XCTestCase { } func testManualAuthenticationWithSuccessResult() async throws { - let urlProvider: TokenHandling.AuthorizationURLProvider = { creds in - return URL(string: "my://auth?client_id=\(creds.clientId)")! + let urlProvider: TokenHandling.AuthorizationURLProvider = { params in + return URL(string: "my://auth?client_id=\(params.credentials.clientId)")! } - let loginProvider: TokenHandling.LoginProvider = { url, creds, tokenUrl, _ in - XCTAssertEqual(url, URL(string: "my://login")!) + let loginProvider: TokenHandling.LoginProvider = { params in + XCTAssertEqual(params.redirectURL, URL(string: "my://login")!) return Login(token: "TOKEN") } @@ -329,12 +326,12 @@ final class AuthenticatorTests: XCTestCase { // Test AuthenticationResultHandler with a failed UserAuthenticator func testManualAuthenticationWithFailedResult() async throws { - let urlProvider: TokenHandling.AuthorizationURLProvider = { creds in - return URL(string: "my://auth?client_id=\(creds.clientId)")! + let urlProvider: TokenHandling.AuthorizationURLProvider = { params in + return URL(string: "my://auth?client_id=\(params.credentials.clientId)")! } - let loginProvider: TokenHandling.LoginProvider = { url, creds, tokenUrl, _ in - XCTAssertEqual(url, URL(string: "my://login")!) + let loginProvider: TokenHandling.LoginProvider = { params in + XCTAssertEqual(params.redirectURL, URL(string: "my://login")!) return Login(token: "TOKEN") } diff --git a/Tests/OAuthenticatorTests/DPoPSignerTests.swift b/Tests/OAuthenticatorTests/DPoPSignerTests.swift index 21b3249..0a459d7 100644 --- a/Tests/OAuthenticatorTests/DPoPSignerTests.swift +++ b/Tests/OAuthenticatorTests/DPoPSignerTests.swift @@ -1,10 +1,34 @@ +import Foundation import Testing + import OAuthenticator +struct ExamplePayload: Codable, Hashable, Sendable { + let value: String +} + struct DPoPSignerTests { @Test func basicSignature() throws { - - } + let signer = DPoPSigner() + + var request = URLRequest(url: URL(string: "https://example.com")!) + try signer.authenticateRequest( + &request, + using: { _ in "my_fake_jwt" }, + token: "token", + tokenHash: "token_hash", + issuer: "issuer" + ) + + let headers = try #require(request.allHTTPHeaderFields) + let authorization = try #require(headers["Authorization"]) + + #expect(authorization == "DPoP token") + + let dpop = try #require(headers["DPoP"]) + + #expect(dpop == "my_fake_jwt") + } } diff --git a/Tests/OAuthenticatorTests/GoogleTests.swift b/Tests/OAuthenticatorTests/GoogleTests.swift index 0211823..b1f71e7 100644 --- a/Tests/OAuthenticatorTests/GoogleTests.swift +++ b/Tests/OAuthenticatorTests/GoogleTests.swift @@ -33,7 +33,7 @@ final class GoogleTests: XCTestCase { XCTAssert(!login.accessToken.valid) } - func testSuppliedParameters() throws { + func testSuppliedParameters() async throws { let googleParameters = GoogleAPI.GoogleAPIParameters(includeGrantedScopes: true, loginHint: "john@doe.com") XCTAssertNotNil(googleParameters.loginHint) @@ -49,10 +49,18 @@ final class GoogleTests: XCTestCase { tokenHandling: tokenHandling, userAuthenticator: Authenticator.failingUserAuthenticator ) + let provider: URLResponseProvider = { _ in throw AuthenticatorError.httpResponseExpected } // Validate URL is properly constructed - let googleURLProvider = try config.tokenHandling.authorizationURLProvider(creds) - + let params = TokenHandling.AuthorizationURLParameters( + credentials: creds, + pcke: PKCEVerifier(), + parRequestURI: nil, + stateToken: "unused", + responseProvider: provider + ) + let googleURLProvider = try await config.tokenHandling.authorizationURLProvider(params) + let urlComponent = URLComponents(url: googleURLProvider, resolvingAgainstBaseURL: true) XCTAssertNotNil(urlComponent) XCTAssertEqual(urlComponent!.scheme, GoogleAPI.scheme) @@ -65,7 +73,7 @@ final class GoogleTests: XCTestCase { XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == "john@doe.com" })) } - func testDefaultParameters() throws { + func testDefaultParameters() async throws { let googleParameters = GoogleAPI.GoogleAPIParameters() XCTAssertNil(googleParameters.loginHint) @@ -81,10 +89,18 @@ final class GoogleTests: XCTestCase { tokenHandling: tokenHandling, userAuthenticator: Authenticator.failingUserAuthenticator ) + let provider: URLResponseProvider = { _ in throw AuthenticatorError.httpResponseExpected } // Validate URL is properly constructed - let googleURLProvider = try config.tokenHandling.authorizationURLProvider(creds) - + let params = TokenHandling.AuthorizationURLParameters( + credentials: creds, + pcke: PKCEVerifier(), + parRequestURI: nil, + stateToken: "unused", + responseProvider: provider + ) + let googleURLProvider = try await config.tokenHandling.authorizationURLProvider(params) + let urlComponent = URLComponents(url: googleURLProvider, resolvingAgainstBaseURL: true) XCTAssertNotNil(urlComponent) XCTAssertEqual(urlComponent!.scheme, GoogleAPI.scheme)