swift-sdk/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift
Maksym Mova 6132fd4b5b
V0.12.x (#215)
* fix #203 NetworkTransport race, with main-isolated variables (#206)

Co-authored-by: wti <wti@users.noreply.github.com>

* feat/auth: Authentication implementation (#205)

* feat: added authentication

* feat(auth): improvement to error handling

* feat(auth): updated errors and tests in OAuthClientRegistrar

* feat(auth): updated ci conformance package version

* feat(auth): bumped node version

* feat(auth): updated ci conformance step

* feat(auth): improvement to Linux support

* chore: added docc plugin

* chore: added spi yml

* chore: added documentation step to ci

* chore: increased timeout for test from 5 to 10m

---------

Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com>
Co-authored-by: wti <wti@users.noreply.github.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
2026-03-24 11:05:22 +02:00

178 lines
6.3 KiB
Swift

import Foundation
import Testing
@testable import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
@Suite("OAuthAuthorizationCodeFlow")
struct OAuthAuthorizationCodeFlowTests {
let flow = OAuthAuthorizationCodeFlow()
let authEndpoint = URL(string: "https://auth.example.com/authorize")!
let resource = URL(string: "https://api.example.com")!
let redirectURI = URL(string: "https://app.example.com/callback")!
let scopeSelector = DefaultOAuthScopeSelector()
// MARK: - buildURL
@Test("buildURL includes all required parameters")
func testBuildURLRequiredParams() throws {
let url = try flow.buildURL(
authorizationEndpoint: authEndpoint,
resource: resource,
redirectURI: redirectURI,
clientID: "my-client",
codeChallenge: "abc123",
scopes: nil,
state: "state-xyz",
scopeSerializer: scopeSelector
)
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let items = Dictionary(
uniqueKeysWithValues: components.queryItems!.map { ($0.name, $0.value ?? "") })
#expect(items["response_type"] == "code")
#expect(items["client_id"] == "my-client")
#expect(items["redirect_uri"] == redirectURI.absoluteString)
#expect(items["state"] == "state-xyz")
#expect(items["resource"] == resource.absoluteString)
#expect(items["code_challenge"] == "abc123")
#expect(items["code_challenge_method"] == "S256")
}
@Test("buildURL includes scope when provided")
func testBuildURLWithScope() throws {
let url = try flow.buildURL(
authorizationEndpoint: authEndpoint,
resource: resource,
redirectURI: redirectURI,
clientID: "my-client",
codeChallenge: "abc123",
scopes: Set(["read", "write"]),
state: "state-xyz",
scopeSerializer: scopeSelector
)
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let items = Dictionary(
uniqueKeysWithValues: components.queryItems!.map { ($0.name, $0.value ?? "") })
let scope = items["scope"] ?? ""
#expect(scope.contains("read"))
#expect(scope.contains("write"))
}
@Test("buildURL omits scope for nil scopes")
func testBuildURLOmitsScopeWhenNil() throws {
let url = try flow.buildURL(
authorizationEndpoint: authEndpoint,
resource: resource,
redirectURI: redirectURI,
clientID: "my-client",
codeChallenge: "abc123",
scopes: nil,
state: "state-xyz",
scopeSerializer: scopeSelector
)
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let items = components.queryItems ?? []
#expect(!items.contains(where: { $0.name == "scope" }))
}
@Test("buildURL omits scope for empty scope set")
func testBuildURLOmitsScopeWhenEmpty() throws {
let url = try flow.buildURL(
authorizationEndpoint: authEndpoint,
resource: resource,
redirectURI: redirectURI,
clientID: "my-client",
codeChallenge: "abc123",
scopes: Set(),
state: "state-xyz",
scopeSerializer: scopeSelector
)
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let items = components.queryItems ?? []
#expect(!items.contains(where: { $0.name == "scope" }))
}
// MARK: - extractCode
@Test("extractCode returns code from valid redirect URL")
func testExtractCodeSuccess() throws {
let redirectURL = URL(string:
"https://app.example.com/callback?code=auth-code-123&state=my-state")!
let code = try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
#expect(code == "auth-code-123")
}
@Test("extractCode throws state mismatch")
func testExtractCodeThrowsStateMismatch() {
let redirectURL = URL(string:
"https://app.example.com/callback?code=auth-code-123&state=wrong-state")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
}
}
@Test("extractCode throws missing state")
func testExtractCodeThrowsMissingState() {
let redirectURL = URL(string:
"https://app.example.com/callback?code=auth-code-123")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
}
}
@Test("extractCode throws missing code")
func testExtractCodeThrowsMissingCode() {
let redirectURL = URL(string:
"https://app.example.com/callback?state=my-state")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
}
}
@Test("extractCode throws redirect URI mismatch")
func testExtractCodeThrowsRedirectMismatch() {
let redirectURL = URL(string:
"https://evil.example.com/callback?code=auth-code-123&state=my-state")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
}
}
@Test("extractCode normalizes host case in redirect URI comparison")
func testExtractCodeCaseInsensitiveHost() throws {
let redirectURL = URL(string:
"https://APP.EXAMPLE.COM/callback?code=auth-code-123&state=my-state")!
let code = try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
#expect(code == "auth-code-123")
}
}