diff --git a/Sources/OpenAPIRuntime/Interface/SecurityRequirement.swift b/Sources/OpenAPIRuntime/Interface/SecurityRequirement.swift new file mode 100644 index 00000000..1f659656 --- /dev/null +++ b/Sources/OpenAPIRuntime/Interface/SecurityRequirement.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// A security requirement of an OpenAPI operation. +/// +/// A security requirement is an *AND-group* of security schemes: to satisfy the +/// requirement, a request must satisfy *every* scheme in ``schemes``. +/// +/// An operation carries an array of security requirements, which is an +/// *OR-group* of alternatives: satisfying any one requirement in the array +/// authorizes the request. An empty array (`[]`) means the operation explicitly +/// opts out of security and is public. +public struct SecurityRequirement: Sendable, Hashable { + + /// The security schemes that must all be satisfied to fulfill this requirement. + public var schemes: [Scheme] + + /// Creates a new security requirement. + /// - Parameter schemes: The security schemes that must all be satisfied. + public init(schemes: [Scheme]) { self.schemes = schemes } + + /// A named security scheme used by an operation, together with the scopes + /// the operation requires for it. + public struct Scheme: Sendable, Hashable { + + /// The name of the security scheme, as declared in the OpenAPI + /// document's `components.securitySchemes`. + public var name: String + + /// The kind of the security scheme, describing how and where a + /// credential is provided. + public var kind: Kind + + /// The scopes the operation requires for this scheme. + /// + /// Meaningful only for ``Kind/oauth2`` and ``Kind/openIdConnect(url:)`` + /// schemes; empty for all others. + public var scopes: [String] + + /// Creates a new security scheme reference. + /// - Parameters: + /// - name: The name of the security scheme. + /// - kind: The kind of the security scheme. + /// - scopes: The scopes the operation requires for this scheme. + public init(name: String, kind: Kind, scopes: [String]) { + self.name = name + self.kind = kind + self.scopes = scopes + } + + /// The kind of an OpenAPI security scheme. + /// + /// Models the security scheme types defined by the OpenAPI + /// Specification. Describes the scheme definition — how a credential is + /// carried — not the per-operation scopes, which live on + /// ``Scheme/scopes``. + public enum Kind: Sendable, Hashable { + + /// An API key carried in a header field, query item, or cookie. + /// - Parameters: + /// - name: The name of the header field, query item, or cookie. + /// - location: Where the API key is carried in the request. + case apiKey(name: String, location: Location) + + /// An HTTP authentication scheme, for example `basic` or `bearer`. + /// - Parameters: + /// - scheme: The HTTP Authorization scheme, lowercased, for + /// example `bearer`. + /// - bearerFormat: A hint describing the bearer token format, if + /// provided by the document. + case http(scheme: String, bearerFormat: String?) + + /// An OAuth 2.0 security scheme. + /// + /// The scopes the operation requires are carried on + /// ``Scheme/scopes``. + case oauth2 + + /// An OpenID Connect security scheme. + /// - Parameter url: The OpenID Connect discovery URL, as written in + /// the OpenAPI document. + case openIdConnect(url: String) + + /// A mutual-TLS security scheme (OpenAPI 3.1). + case mutualTLS + } + + /// The location in an HTTP request where a security credential is carried. + public enum Location: String, Sendable, Hashable { + + /// Carried in a URL query item. + case query + + /// Carried in an HTTP header field. + case header + + /// Carried in a cookie. + case cookie + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_SecurityRequirement.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_SecurityRequirement.swift new file mode 100644 index 00000000..d21267a0 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_SecurityRequirement.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +@testable import OpenAPIRuntime + +final class Test_SecurityRequirement: XCTestCase { + + func testLocationRawValues() { + XCTAssertEqual(SecurityRequirement.Scheme.Location.query.rawValue, "query") + XCTAssertEqual(SecurityRequirement.Scheme.Location.header.rawValue, "header") + XCTAssertEqual(SecurityRequirement.Scheme.Location.cookie.rawValue, "cookie") + } + + func testEachKind() { + // Construct one value per kind and assert stored properties round-trip. + let apiKey = SecurityRequirement.Scheme( + name: "apiKeyAuth", + kind: .apiKey(name: "X-API-Key", location: .header), + scopes: [] + ) + guard case let .apiKey(name, location) = apiKey.kind else { return XCTFail("expected apiKey") } + XCTAssertEqual(name, "X-API-Key") + XCTAssertEqual(location, .header) + + let bearer = SecurityRequirement.Scheme( + name: "bearerAuth", + kind: .http(scheme: "bearer", bearerFormat: "JWT"), + scopes: [] + ) + guard case let .http(scheme, format) = bearer.kind else { return XCTFail("expected http") } + XCTAssertEqual(scheme, "bearer") + XCTAssertEqual(format, "JWT") + + let basic = SecurityRequirement.Scheme( + name: "basicAuth", + kind: .http(scheme: "basic", bearerFormat: nil), + scopes: [] + ) + guard case .http(_, let noFormat) = basic.kind else { return XCTFail("expected http") } + XCTAssertNil(noFormat) + + let oauth = SecurityRequirement.Scheme(name: "oauth", kind: .oauth2, scopes: ["read", "write"]) + XCTAssertEqual(oauth.scopes, ["read", "write"]) + + let oidc = SecurityRequirement.Scheme( + name: "oidc", + kind: .openIdConnect(url: "https://example.com/.well-known/openid-configuration"), + scopes: ["openid"] + ) + guard case let .openIdConnect(url) = oidc.kind else { return XCTFail("expected openIdConnect") } + XCTAssertEqual(url, "https://example.com/.well-known/openid-configuration") + + let mtls = SecurityRequirement.Scheme(name: "mtls", kind: .mutualTLS, scopes: []) + XCTAssertEqual(mtls.kind, .mutualTLS) + } + + func testHashableAndSetDeduplication() { + let a = SecurityRequirement(schemes: [.init(name: "oauth", kind: .oauth2, scopes: [])]) + let b = SecurityRequirement(schemes: [.init(name: "oauth", kind: .oauth2, scopes: [])]) + let differentScopes = SecurityRequirement(schemes: [.init(name: "oauth", kind: .oauth2, scopes: ["read"])]) + let differentName = SecurityRequirement(schemes: [.init(name: "other", kind: .oauth2, scopes: [])]) + + XCTAssertEqual(a, b) + XCTAssertEqual(a.hashValue, b.hashValue) + XCTAssertNotEqual(a, differentScopes) + XCTAssertNotEqual(a, differentName) + XCTAssertEqual(Set([a, b, differentScopes, differentName]).count, 3) + } + + func testEmitContract() { + // Locks the exact literal the generator emits for a single-scheme OAuth2 operation. + let requirements: [SecurityRequirement] = [ + .init(schemes: [.init(name: "OAuth2PasswordBearer", kind: .oauth2, scopes: [])]) + ] + XCTAssertEqual( + requirements, + [ + SecurityRequirement(schemes: [ + SecurityRequirement.Scheme(name: "OAuth2PasswordBearer", kind: .oauth2, scopes: []) + ]) + ] + ) + } + + func testOrAndSemanticsAreDistinct() { + // `[]` (public opt-out) is a different value from a requirement with no schemes. + let publicOptOut: [SecurityRequirement] = [] + let emptyAndGroup: [SecurityRequirement] = [.init(schemes: [])] + XCTAssertNotEqual(publicOptOut, emptyAndGroup) + } +}