Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@
- None

#### Enhancements
- None
- Support public OAuth2 clients that hold no client secret. `OAuth2ClientConfiguration` gains a
secret-less initializer (`init(publicClientIdentifier:environment:guestUsername:guestPassword:)`)
and an `isPublicClient` flag. A public client identifies itself with `client_id` in the token grant
body and omits the `Authorization: Basic` header across every grant type; a confidential client is
unchanged. Client-level authorization for a public client through `OAuth2RequestPipelineMiddleware`
needs guest credentials: client-level basic, and client-level bearer without guest credentials, fail
with `OAuth2Error.internalFailure` rather than sending a request with no credential
(RFC 6749 §4.4). A public client using `authorization_code` has no
code-to-client binding, so the authorization server must enforce PKCE (RFC 8252 §6); pass
`code_challenge` via `OAuth2AuthorizationRequest.additionalParameters` and `code_verifier` via
`tokenGrantRequestAdditionalBodyParameters`.

#### Bug Fixes
- None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,18 @@ public struct OAuth2ClientConfiguration: Equatable {
/// The OAuth2 client identifier
public var clientIdentifier: String

/// The OAuth2 client secret
/// The OAuth2 client secret. Empty for a public client (see `isPublicClient`); do not assign a
/// non-empty secret to a public client, as it will not be sent.
public var clientSecret: String

/// Whether the client is public, i.e. it holds no client secret. A public client identifies itself
/// with `client_id` in the token request body and omits the `Authorization: Basic` header across
/// every grant type. Client-level authorization through `OAuth2RequestPipelineMiddleware` needs guest
/// credentials (password grant): client-level basic, and client-level bearer without guest credentials,
/// fail with `OAuth2Error.internalFailure`. A confidential client (the default) is unchanged. Set only
/// through the public-client initializer.
public private(set) var isPublicClient: Bool = false

/// The guest user's username, if one exists or is needed for client-level authorization
public var guestUsername: String?

Expand Down Expand Up @@ -44,4 +53,45 @@ public struct OAuth2ClientConfiguration: Equatable {
self.guestPassword = guestPassword
self.environment = environment
}

/// Creates a new OAuth2ClientConfiguration for a public client, i.e. one that holds no client secret.
/// The client identifies itself with `client_id` in the token request body and omits the
/// `Authorization: Basic` header; `client_id` is identification, not authentication (RFC 6749 §3.2.1).
///
/// A public client using the `authorization_code` grant has no code-to-client binding, so the
/// authorization server must enforce PKCE (RFC 8252 §6): send `code_challenge` through
/// `OAuth2AuthorizationRequest.additionalParameters` and `code_verifier` through
/// `OAuth2AuthorizationCodeTokenGrantStrategy.tokenGrantRequestAdditionalBodyParameters`.
///
/// Client-level authorization through `OAuth2RequestPipelineMiddleware` requires guest credentials:
/// without them the middleware would have to send a `client_credentials` grant with no credential,
/// which RFC 6749 §4.4 reserves for confidential clients, so it fails with
/// `OAuth2Error.internalFailure` instead. Constructing `OAuth2ClientCredentialsTokenGrantStrategy`
/// directly is not guarded.
/// - Parameters:
/// - publicClientIdentifier: The OAuth2 client identifier
/// - environment: The OAuth2 server application environment that the client communicates with
/// - guestUsername: The guest user's username; `nil` unless client-level authorization is used, which requires it
/// - guestPassword: The guest user's password; `nil` unless client-level authorization is used, which requires it
public init(publicClientIdentifier: String,
environment: OAuth2ServerEnvironment,
guestUsername: String? = nil,
guestPassword: String? = nil) {
Comment thread
vishal-mb marked this conversation as resolved.
self.init(clientIdentifier: publicClientIdentifier,
clientSecret: "",
environment: environment,
guestUsername: guestUsername,
guestPassword: guestPassword)
Comment thread
vishal-mb marked this conversation as resolved.
self.isPublicClient = true
}

/// The `BasicToken` for client authentication, or `nil` for a public client, which identifies itself
/// with `client_id` in the request body instead. Reading through `isPublicClient` keeps a public
/// client from ever emitting a Basic header, even if a secret was mistakenly assigned.
var basicToken: BasicToken? {
guard !isPublicClient else {
Comment thread
vishal-mb marked this conversation as resolved.
return nil
}
return BasicToken(username: clientIdentifier, password: clientSecret)
Comment thread
vishal-mb marked this conversation as resolved.
}
}
22 changes: 13 additions & 9 deletions Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,23 +229,21 @@ public struct OAuth2RequestPipelineMiddleware: RequestPipelineMiddleware {
self.tokenStorage.store(token: newToken,
for: self.clientConfiguration,
with: self.authorization)
self.makeRequestByApplyingAuthorizationHeader(to: request,
with: newToken,
completion: completion)
self.makeRequestByApplyingAuthorizationHeader(to: request, with: newToken, completion: completion)
}
Auth.Migrator.notifyTokenPostFetchHooksWith(client: self.clientConfiguration,
authorizationLevel: self.authorization.level,
result: result)
}
}
else {
// Apply basic header
logger.verbose("Client doesn't require a bearer token. Proceeding with a basic token...")
let basicToken = BasicToken(username: clientConfiguration.clientIdentifier,
password: clientConfiguration.clientSecret)
makeRequestByApplyingAuthorizationHeader(to: request,
with: basicToken,
completion: completion)
guard let basicToken = clientConfiguration.basicToken else {
logger.warn("Client-level basic authorization was requested for a public client, which has no secret")
completion(.error(OAuth2Error.internalFailure))
return
}
Comment thread
vishal-mb marked this conversation as resolved.
makeRequestByApplyingAuthorizationHeader(to: request, with: basicToken, completion: completion)
}
}

Expand All @@ -260,6 +258,12 @@ public struct OAuth2RequestPipelineMiddleware: RequestPipelineMiddleware {
logger.verbose("Guest user credentials exist. Attempting password grant...")
authenticationStrategy = OAuth2PasswordTokenGrantStrategy(username: username, password: password, clientConfiguration: clientConfiguration)
}
else if clientConfiguration.isPublicClient {
// The client_credentials grant is reserved for confidential clients (RFC 6749 §4.4).
logger.warn("Client-level bearer authorization was requested for a public client without guest credentials")
completion(.error(OAuth2Error.internalFailure))
return
}
else {
logger.verbose("Guest user credentials do not exist. Attempting client_credentials grant...")
authenticationStrategy = OAuth2ClientCredentialsTokenGrantStrategy(clientConfiguration: clientConfiguration)
Expand Down
11 changes: 8 additions & 3 deletions Sources/Conduit/Auth/TokenGrants/OAuth2TokenGrantStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ extension OAuth2TokenGrantStrategy {
grantType: String,
additionalGrantParameters: [String: Any],
requestSerializer: RequestSerializer = JSONRequestSerializer()) throws -> URLRequest {
let basicToken = BasicToken(username: clientConfiguration.clientIdentifier, password: clientConfiguration.clientSecret)

let requestBuilder = HTTPRequestBuilder(url: clientConfiguration.environment.tokenGrantURL)
var parameters: [String: Any] = [
"grant_type": grantType
Expand All @@ -42,6 +40,11 @@ extension OAuth2TokenGrantStrategy {
parameters["scope"] = scope
}

// Set before merging additionalGrantParameters so a grant-supplied client_id still wins.
if clientConfiguration.isPublicClient {
parameters["client_id"] = clientConfiguration.clientIdentifier
}

for additionalParameter in additionalGrantParameters {
parameters[additionalParameter.key] = additionalParameter.value
}
Expand All @@ -51,7 +54,9 @@ extension OAuth2TokenGrantStrategy {
requestBuilder.serializer = requestSerializer
do {
var request = try requestBuilder.build()
request.setValue(basicToken.authorizationHeaderValue, forHTTPHeaderField: "Authorization")
if let basicToken = clientConfiguration.basicToken {
request.setValue(basicToken.authorizationHeaderValue, forHTTPHeaderField: "Authorization")
}
return request
}
catch let error {
Expand Down
27 changes: 27 additions & 0 deletions Tests/ConduitTests/Auth/OAuth2ClientConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,33 @@ class OAuth2ClientConfigurationTests: XCTestCase {

let environment = OAuth2ServerEnvironment(scope: "baz", tokenGrantURL: URL(fileURLWithPath: "baz.com"))

func testConfidentialClientByDefault() {
let configuration = OAuth2ClientConfiguration(clientIdentifier: "foo", clientSecret: "bar",
environment: environment)
XCTAssertFalse(configuration.isPublicClient)
XCTAssertEqual(configuration.clientSecret, "bar")
}

func testPublicClientHasNoSecret() {
let configuration = OAuth2ClientConfiguration(publicClientIdentifier: "foo", environment: environment)
XCTAssertTrue(configuration.isPublicClient)
XCTAssertEqual(configuration.clientIdentifier, "foo")
XCTAssertTrue(configuration.clientSecret.isEmpty)
}

func testPublicClientCarriesGuestCredentials() {
let configuration = OAuth2ClientConfiguration(publicClientIdentifier: "foo", environment: environment,
guestUsername: "u", guestPassword: "p")
XCTAssertEqual(configuration.guestUsername, "u")
XCTAssertEqual(configuration.guestPassword, "p")
}

func testPublicAndConfidentialClientsWithSameIdentifierAreNotEqual() {
XCTAssertNotEqual(OAuth2ClientConfiguration(publicClientIdentifier: "foo", environment: environment),
OAuth2ClientConfiguration(clientIdentifier: "foo", clientSecret: "",
environment: environment))
}

func testEquality() {
Comment thread
vishal-mb marked this conversation as resolved.
XCTAssertEqual(OAuth2ClientConfiguration(clientIdentifier: "foo", clientSecret: "bar",
environment: environment),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// OAuth2MiddlewareClientLevelAuthorizationTests.swift
// Conduit
//
// Copyright © 2026 MINDBODY. All rights reserved.
//

import XCTest
@testable import Conduit

/// Client-level authorization with no stored token: a confidential client applies its Basic header,
/// while a public client has no secret and must fail before any request is built.
class OAuth2MiddlewareClientLevelAuthorizationTests: XCTestCase {

private let clientIdentifier = "test_client"

private func environment() throws -> OAuth2ServerEnvironment {
OAuth2ServerEnvironment(scope: "all the things",
tokenGrantURL: try URL(absoluteString: "http://localhost:5000/oauth2/issue/token"))
}

private func confidentialConfiguration() throws -> OAuth2ClientConfiguration {
OAuth2ClientConfiguration(clientIdentifier: clientIdentifier, clientSecret: "test_secret", environment: try environment())
}

private func publicConfiguration() throws -> OAuth2ClientConfiguration {
OAuth2ClientConfiguration(publicClientIdentifier: clientIdentifier, environment: try environment())
}

private func makeDummyRequest() throws -> URLRequest {
let requestBuilder = HTTPRequestBuilder(url: try URL(absoluteString: "https://httpbingo.org/post"))
requestBuilder.method = .GET
return try requestBuilder.build()
}

private func prepare(clientConfiguration: OAuth2ClientConfiguration,
authorization: OAuth2Authorization) throws -> Result<URLRequest> {
let sut = OAuth2RequestPipelineMiddleware(clientConfiguration: clientConfiguration,
authorization: authorization, tokenStorage: OAuth2TokenMemoryStore())
var outcome: Result<URLRequest>?
let completionExpectation = expectation(description: "completion handler executed")
sut.prepareForTransport(request: try makeDummyRequest()) { result in
outcome = result
completionExpectation.fulfill()
}
waitForExpectations(timeout: 2)
return try XCTUnwrap(outcome)
}

func testConfidentialClientLevelBasicAppliesBasicHeader() throws {
let result = try prepare(clientConfiguration: try confidentialConfiguration(),
authorization: OAuth2Authorization(type: .basic, level: .client))
XCTAssertEqual(result.value?.allHTTPHeaderFields?["Authorization"]?.contains("Basic"), true)
}

func testPublicClientLevelBasicFailsWithInternalFailure() throws {
let result = try prepare(clientConfiguration: try publicConfiguration(),
authorization: OAuth2Authorization(type: .basic, level: .client))
guard let error = result.error, case OAuth2Error.internalFailure = error else {
return XCTFail("Expected an internalFailure: a public client has no secret to send")
}
}

func testPublicClientLevelBearerWithoutGuestCredentialsFailsWithInternalFailure() throws {
// The client_credentials grant is reserved for confidential clients (RFC 6749 §4.4).
let result = try prepare(clientConfiguration: try publicConfiguration(),
authorization: OAuth2Authorization(type: .bearer, level: .client))
guard let error = result.error, case OAuth2Error.internalFailure = error else {
return XCTFail("Expected an internalFailure instead of an unauthenticated client_credentials grant")
}
}

}
Loading
Loading