feat: auth improvements (#217)
* feat: improvement to oauth resources resolution * feat: improvements to refreshToken procedure and updates to unit tests * feat: improvements to documentation of clien_id
This commit is contained in:
parent
6132fd4b5b
commit
cb6a62f7c2
2 changed files with 144 additions and 31 deletions
|
|
@ -64,7 +64,7 @@ final class MockDiscoveryClient: OAuthDiscoveryFetching, @unchecked Sendable {
|
|||
)
|
||||
}
|
||||
|
||||
func fetchProtectedResourceMetadata(candidates: [URL], session: URLSession) async throws -> OAuthProtectedResourceMetadata {
|
||||
func fetchProtectedResourceMetadata(candidates: [URL], fallbackIssuer: URL?, session: URLSession) async throws -> OAuthProtectedResourceMetadata {
|
||||
fetchProtectedResourceMetadataCallCount += 1
|
||||
return protectedResourceMetadataResult
|
||||
}
|
||||
|
|
@ -100,6 +100,10 @@ final class MockTokenClient: OAuthTokenRequesting, @unchecked Sendable {
|
|||
|
||||
final class MockClientRegistrar: OAuthClientRegistering, @unchecked Sendable {
|
||||
var registerCallCount = 0
|
||||
var registrationResult: (
|
||||
response: OAuthClientRegistrationResponse,
|
||||
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||
)?
|
||||
|
||||
func register(
|
||||
configuration: OAuthConfiguration,
|
||||
|
|
@ -110,7 +114,7 @@ final class MockClientRegistrar: OAuthClientRegistering, @unchecked Sendable {
|
|||
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||
)? {
|
||||
registerCallCount += 1
|
||||
return nil
|
||||
return registrationResult
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -381,4 +385,45 @@ struct OAuthAuthorizerTests {
|
|||
|
||||
#expect(registrar.registerCallCount == 0)
|
||||
}
|
||||
|
||||
@Test("handleChallenge persists the DCR-assigned clientID on the saved token")
|
||||
func testHandleChallengePersistsDCRClientIDOnToken() async throws {
|
||||
let assignedClientID = "dcr-assigned-client-id"
|
||||
let tokenStorage = InMemoryTokenStorage()
|
||||
let registrar = MockClientRegistrar()
|
||||
registrar.registrationResult = (
|
||||
response: OAuthClientRegistrationResponse(
|
||||
clientID: assignedClientID,
|
||||
clientSecret: nil,
|
||||
tokenEndpointAuthMethod: nil,
|
||||
clientSecretExpiresAt: nil
|
||||
),
|
||||
updatedAuthentication: .none(clientID: assignedClientID)
|
||||
)
|
||||
|
||||
let config = OAuthConfiguration(
|
||||
authentication: .none(clientID: "")
|
||||
)
|
||||
let authorizer = OAuthAuthorizer(
|
||||
configuration: config,
|
||||
tokenStorage: tokenStorage,
|
||||
urlValidator: MockURLValidator(),
|
||||
discoveryClient: MockDiscoveryClient(),
|
||||
tokenEndpointClient: MockTokenClient(),
|
||||
clientRegistrar: registrar,
|
||||
authCodeFlow: MockAuthCodeFlow()
|
||||
)
|
||||
|
||||
let handled = try await authorizer.handleChallenge(
|
||||
statusCode: 401,
|
||||
headers: headers401,
|
||||
endpoint: endpoint,
|
||||
operationKey: nil,
|
||||
session: .shared
|
||||
)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(registrar.registerCallCount == 1)
|
||||
#expect(tokenStorage.load()?.clientID == assignedClientID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import Testing
|
|||
|
||||
let metadata = try await makeClient().fetchProtectedResourceMetadata(
|
||||
candidates: [URL(string: "https://example.com/.well-known/oauth-protected-resource")!],
|
||||
fallbackIssuer: nil,
|
||||
session: session
|
||||
)
|
||||
let expected = OAuthProtectedResourceMetadata(
|
||||
|
|
@ -76,6 +77,7 @@ import Testing
|
|||
URL(string: "https://example.com/.well-known/oauth-protected-resource/mcp")!,
|
||||
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
|
||||
],
|
||||
fallbackIssuer: nil,
|
||||
session: session
|
||||
)
|
||||
let expected = OAuthProtectedResourceMetadata(
|
||||
|
|
@ -103,6 +105,7 @@ import Testing
|
|||
URL(string: "https://example.com/.well-known/oauth-protected-resource/mcp")!,
|
||||
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
|
||||
],
|
||||
fallbackIssuer: nil,
|
||||
session: session
|
||||
)
|
||||
let expected = OAuthProtectedResourceMetadata(
|
||||
|
|
@ -112,7 +115,7 @@ import Testing
|
|||
#expect(metadata == expected)
|
||||
}
|
||||
|
||||
@Test("Throws metadataDiscoveryFailed when all candidates fail")
|
||||
@Test("Throws metadataDiscoveryFailed when all candidates fail and no fallback issuer")
|
||||
func testFetchProtectedResourceMetadataThrowsWhenAllFail() async throws {
|
||||
let (session, key) = makeIsolatedSession()
|
||||
await IsolatedMockURLProtocol.setHandler(key: key) { request in
|
||||
|
|
@ -126,11 +129,34 @@ import Testing
|
|||
candidates: [
|
||||
URL(string: "https://example.com/.well-known/oauth-protected-resource")!
|
||||
],
|
||||
fallbackIssuer: nil,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Returns synthetic metadata with fallback issuer when all candidates fail")
|
||||
func testFetchProtectedResourceMetadataUsesFallbackIssuer() async throws {
|
||||
let fallback = URL(string: "https://example.com")!
|
||||
let (session, key) = makeIsolatedSession()
|
||||
await IsolatedMockURLProtocol.setHandler(key: key) { request in
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!
|
||||
return (response, Data())
|
||||
}
|
||||
|
||||
let metadata = try await makeClient().fetchProtectedResourceMetadata(
|
||||
candidates: [URL(string: "https://example.com/.well-known/oauth-protected-resource")!],
|
||||
fallbackIssuer: fallback,
|
||||
session: session
|
||||
)
|
||||
let expected = OAuthProtectedResourceMetadata(
|
||||
resource: nil,
|
||||
authorizationServers: [fallback],
|
||||
scopesSupported: nil)
|
||||
#expect(metadata == expected)
|
||||
}
|
||||
|
||||
// MARK: - fetchAuthorizationServerMetadata
|
||||
|
||||
@Test("Returns server and metadata when issuer matches")
|
||||
|
|
@ -162,23 +188,23 @@ import Testing
|
|||
#expect(metadata == expectedMetadata)
|
||||
}
|
||||
|
||||
@Test("Skips candidate when issuer field does not match")
|
||||
func testFetchAuthorizationServerMetadataSkipsIssuerMismatch() async throws {
|
||||
let wrongIssuerBody = try makeASMetadataBody(issuer: "https://other.example.com")
|
||||
@Test("Uses metadata issuer as server identity when it differs from candidate URL")
|
||||
func testFetchAuthorizationServerMetadataUsesMetadataIssuer() async throws {
|
||||
let metadataIssuer = "https://other.example.com"
|
||||
let body = try makeASMetadataBody(issuer: metadataIssuer)
|
||||
let (session, key) = makeIsolatedSession()
|
||||
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
|
||||
let response = HTTPURLResponse(
|
||||
url: URL(string: "https://auth.example.com")!,
|
||||
statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
return (response, wrongIssuerBody)
|
||||
return (response, body)
|
||||
}
|
||||
|
||||
await #expect(throws: OAuthAuthorizationError.self) {
|
||||
try await makeClient().fetchAuthorizationServerMetadata(
|
||||
candidates: [URL(string: "https://auth.example.com")!],
|
||||
session: session
|
||||
)
|
||||
}
|
||||
let (server, _) = try await makeClient().fetchAuthorizationServerMetadata(
|
||||
candidates: [URL(string: "https://auth.example.com")!],
|
||||
session: session
|
||||
)
|
||||
#expect(server == URL(string: metadataIssuer)!)
|
||||
}
|
||||
|
||||
@Test("Skips private IP candidates without making HTTP calls")
|
||||
|
|
|
|||
Loading…
Reference in a new issue