* 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>
178 lines
6.3 KiB
Swift
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")
|
|
}
|
|
}
|