Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ let package = Package(
),
.testTarget(
name: "ATOAuthKitTests",
dependencies: ["ATOAuthKit"]
dependencies: ["ATOAuthKit", "OAuthTypes"]
),
]
)
2 changes: 1 addition & 1 deletion Sources/OAuthTypes/Constants.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Models.swift
// Constants.swift
// ATOAuthKit
//
// Created by Christopher Jr Riley on 2025-07-28.
Expand Down
9 changes: 9 additions & 0 deletions Sources/OAuthTypes/OAuthAuthorizationDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,13 @@ public struct AuthorizationDetail: Codable {
self.identifier = identifier
self.privileges = privileges
}

enum CodingKeys: String, CodingKey {
case type
case locations
case actions
case dataTypes = "datatypes"
case identifier
case privileges
}
}
13 changes: 9 additions & 4 deletions Sources/OAuthTypes/OAuthAuthorizationServerMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,16 @@ public struct AuthorizationServerMetadata: Codable {
self.authorizationEndpoint = try container.decode(URI.WebURI.self, forKey: .authorizationEndpoint)
self.tokenEndpoint = try container.decode(URI.WebURI.self, forKey: .tokenEndpoint)

let tokenEndpointAuthMethodsSupported = try container.decode([String].self, forKey: .tokenEndpointAuthMethodsSupported)
if tokenEndpointAuthMethodsSupported == [""] {
self.tokenEndpointAuthMethodsSupported = ["client_secret_basic"]
// The TypeScript zod `.default(["client_secret_basic"])` only applies when the key is
// absent; an explicit JSON null fails validation. Mirror that: decode non-optionally when
// the key is present (so null throws), and default only when the key is omitted entirely.
if container.contains(.tokenEndpointAuthMethodsSupported) {
self.tokenEndpointAuthMethodsSupported = try container.decode(
[String].self,
forKey: .tokenEndpointAuthMethodsSupported
)
} else {
self.tokenEndpointAuthMethodsSupported = try container.decode([String].self, forKey: .tokenEndpointAuthMethodsSupported)
self.tokenEndpointAuthMethodsSupported = ["client_secret_basic"]
}

self.tokenEndpointAuthSigningAlgorithmValuesSupported = try container.decodeIfPresent([String].self, forKey: .tokenEndpointAuthSigningAlgorithmValuesSupported)
Expand Down
44 changes: 31 additions & 13 deletions Sources/OAuthTypes/OAuthClientIDDiscoverable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,38 +40,52 @@ public struct ClientIDDiscoverable: Codable, CustomStringConvertible {
try URI.validateHTTPSURI(uriString: clientID)

guard let urlComponents = URLComponents(string: clientID) else {
throw OAuthClientIDDiscoverableError.invlaidURL
throw OAuthClientIDDiscoverableError.invalidURL
}

guard urlComponents.user != nil || urlComponents.password != nil else {
guard urlComponents.user == nil, urlComponents.password == nil else {
throw OAuthClientIDDiscoverableError.credentialsDetected
}

guard urlComponents.fragment == nil else {
throw OAuthClientIDDiscoverableError.containsFragment
}

guard urlComponents.path != "/" else {
// The WHATWG URL parser normalizes a bare authority (e.g. "https://example.com")
// to a "/" pathname; URLComponents leaves it empty. Treat an empty path as "no path
// component" to match the TypeScript `url.pathname === '/'` rejection.
guard !urlComponents.path.isEmpty else {
throw OAuthClientIDDiscoverableError.endsInTrailingSlash
}

// Reject any path that ends in a trailing slash (TS: `url.pathname.endsWith('/')`).
// This also covers the root path "/" once a bare authority has been normalized.
guard !urlComponents.path.hasSuffix("/") else {
throw OAuthClientIDDiscoverableError.endsInTrailingSlash
}

guard let hostname = urlComponents.host else {
throw OAuthClientIDDiscoverableError.invlaidURL
throw OAuthClientIDDiscoverableError.invalidURL
}

guard isHostnameIPAddress(hostname) == false else {
throw OAuthClientIDDiscoverableError.containsIPAddress
}

let url = URL(string: clientID)
guard let originalURLPath = url?.path(percentEncoded: false) else {
throw OAuthClientIDDiscoverableError.invlaidURL
// The WHATWG URL parser normalizes the path (resolving "." and ".." segments),
// so we compare the raw, un-normalized path against the normalized one to reject
// path traversal (TS: `extractUrlPath(value) !== url.pathname`). `URLComponents`
// preserves the raw path; `URL.standardized` resolves the dot segments. A mismatch
// means the original value was not in canonical form.
let rawPath = urlComponents.path
guard let normalizedPath = URL(string: clientID)?.standardized.path(percentEncoded: false) else {
throw OAuthClientIDDiscoverableError.invalidURL
}

guard originalURLPath == urlComponents.path else {
guard rawPath == normalizedPath else {
throw OAuthClientIDDiscoverableError.incorrectCanonicalForm(
expectedValue: urlComponents.path,
foundValue: originalURLPath
expectedValue: normalizedPath,
foundValue: rawPath
)
}
}
Expand Down Expand Up @@ -106,15 +120,19 @@ public struct ConventionalOAuthClientID: Codable, CustomStringConvertible {
///
/// - Parameter rawValue: The raw value to validate and use for the new instance.
public init(validating rawValue: String) throws {
// A conventional client ID is the intersection of the discoverable schema
// and the conventional-specific checks, so validate it as discoverable first.
_ = try ClientIDDiscoverable(validating: rawValue)

guard let urlComponents = URLComponents(string: rawValue) else {
throw OAuthClientIDDiscoverableError.invlaidURL
throw OAuthClientIDDiscoverableError.invalidURL
}

guard urlComponents.port != nil else {
guard urlComponents.port == nil else {
throw OAuthClientIDDiscoverableError.containsPort
}

guard urlComponents.query != nil else {
guard urlComponents.query == nil else {
throw OAuthClientIDDiscoverableError.containsQuery
}

Expand Down
26 changes: 19 additions & 7 deletions Sources/OAuthTypes/OAuthIssuerIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,40 @@ public struct IssuerIdentifier: Codable, CustomStringConvertible {
let webURIValue = try String(describing: URI.WebURI(validating: rawValue))

guard webURIValue.last != "/" else {
throw OAuthIssuerIdentifierError.issurURLEndsWithSlash
throw OAuthIssuerIdentifierError.issuerURLEndsWithSlash
}

guard let urlComponents = URLComponents(string: webURIValue) else {
throw OAuthIssuerIdentifierError.invalidURL
}

guard urlComponents.user == nil || urlComponents.password == nil else {
guard urlComponents.user == nil, urlComponents.password == nil else {
throw OAuthIssuerIdentifierError.usernameOrPasswordDetected
}

guard urlComponents.query != nil || urlComponents.fragment != nil else {
guard urlComponents.query == nil, urlComponents.fragment == nil else {
throw OAuthIssuerIdentifierError.queryOrFragmentDetected
}

let port = urlComponents.port != nil ? ":\(String(describing: urlComponents.port))" : nil

guard let scheme = urlComponents.scheme, let host = urlComponents.host, let absoluteString = urlComponents.url?.absoluteString else {
guard let scheme = urlComponents.scheme, let host = urlComponents.host else {
throw OAuthIssuerIdentifierError.invalidURL
}

let canonicalValue = urlComponents.path == "/" ? "\(scheme)://\(host)\(port ?? "")" : "\(absoluteString)"
// The WHATWG URL parser lowercases the scheme and host when it builds `url.origin`
// and `url.href`, so a mixed-case host such as "https://AUTH.EXAMPLE.com" is not in
// canonical form. Lowercase the scheme and host (never the path) before comparing.
let canonicalScheme = scheme.lowercased()
let canonicalHost = host.lowercased()

// The parser also drops the scheme's default port (443 for "https", 80 for "http")
// when it builds `url.origin`, so an issuer that spells out the default port is not
// in canonical form. Any other port is kept.
let isDefaultPort = (canonicalScheme == "https" && urlComponents.port == 443)
|| (canonicalScheme == "http" && urlComponents.port == 80)
let port = urlComponents.port.flatMap { isDefaultPort ? nil : ":\($0)" }
let canonicalValue = urlComponents.path.isEmpty || urlComponents.path == "/"
? "\(canonicalScheme)://\(canonicalHost)\(port ?? "")"
: "\(canonicalScheme)://\(canonicalHost)\(port ?? "")\(urlComponents.path)"

guard rawValue == canonicalValue else {
throw OAuthIssuerIdentifierError.notInCanonicalForm
Expand Down
26 changes: 19 additions & 7 deletions Sources/OAuthTypes/OAuthPARResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,39 @@ public struct OAuthPARResponse: Codable {
/// The request URI of the PAR response.
public let requestURI: String

/// The date and time it will expire (in a UNIX timestamp).
/// The lifetime of the request URI, in seconds.
///
/// This will always be a positive number.
/// This will always be a positive integer.
public let expiresIn: Int

/// Creates an instance of `OAuthPARResponse`.
///
/// If a negative number is inserted into `expiresIn`, it will be converted to a positive number.
///
/// - Parameters:
/// - requestURI: The request URI of the PAR response.
/// - expiresIn: This will always be a positive number.
/// - expiresIn: The lifetime of the request URI, in seconds. Must be a positive integer.
public init(requestURI: String, expiresIn: Int) {
self.requestURI = requestURI
self.expiresIn = abs(expiresIn)
self.expiresIn = expiresIn
}

enum CodingKeys: String, CodingKey {
case requestURI = "request_uri"
case expiresIn = "expires_in"
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

self.requestURI = try container.decode(String.self, forKey: .requestURI)
self.expiresIn = abs(try container.decode(Int.self, forKey: .expiresIn))

let decodedExpiresIn = try container.decode(Int.self, forKey: .expiresIn)
guard decodedExpiresIn >= 1 else {
throw DecodingError.dataCorruptedError(
forKey: .expiresIn,
in: container,
debugDescription: "expires_in must be a positive integer; received \(decodedExpiresIn)."
)
}
self.expiresIn = decodedExpiresIn
}
}
7 changes: 3 additions & 4 deletions Sources/OAuthTypes/OAuthProtectedResourceMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,14 @@ public struct ProtectedResourceMetadata: Codable {
resourcePolicyURI: URI.WebURI? = nil,
resourceTermsOfServiceURI: URI.WebURI? = nil
) throws {
#if !DEBUG
if String(describing: resource).contains("?") {
throw OAuthProtectedResourceMetadataError.containsQuery
}

if String(describing: resource).contains("#") {
throw OAuthProtectedResourceMetadataError.containsFragment
}
#endif

self.resource = resource
self.authorizationServers = authorizationServers
self.jwksURI = jwksURI
Expand All @@ -78,15 +77,15 @@ public struct ProtectedResourceMetadata: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self)

let decodedResource = try container.decode(URI.WebURI.self, forKey: .resource)
#if !DEBUG

if String(describing: decodedResource).contains("?") {
throw OAuthProtectedResourceMetadataError.containsQuery
}

if String(describing: decodedResource).contains("#") {
throw OAuthProtectedResourceMetadataError.containsFragment
}
#endif

self.resource = decodedResource
self.authorizationServers = try container.decodeIfPresent([IssuerIdentifier].self, forKey: .authorizationServers)
self.jwksURI = try container.decodeIfPresent(URI.WebURI.self, forKey: .jwksURI)
Expand Down
5 changes: 5 additions & 0 deletions Sources/OAuthTypes/OAuthRedirectURI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ public struct OAuthLoopbackRedirectURI: Codable, CustomStringConvertible {
///
/// - Parameter rawValue: The raw value to validate and use for the new instance.
public init(validating rawValue: String) throws {
// Chain the full loopback URI schema first (http: scheme + loopback IP host).
_ = try URI.LoopbackRedirectURI(validating: rawValue)

// Then exclude the "localhost" hostname (RFC 8252).
guard !rawValue.starts(with: "http://localhost") else {
throw OAuthRedirectURIError.localhostDetected
}

self.rawValue = rawValue
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/OAuthTypes/OAuthScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import Foundation

/// An structure representing the set of possible OAuth scopes as plain strings.
/// A structure representing the set of possible OAuth scopes as plain strings.
///
/// Each OAuth scope defines what permissions or access a client is requesting from the user.
/// This enum provides a helper for validating if a given scope string matches the expected
Expand Down
9 changes: 7 additions & 2 deletions Sources/OAuthTypes/OAuthTokenIdentification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ public struct TokenIdentification: Codable {
/// The actual OAuth token string.
public let token: Token

/// A hint to help the server identify the type of token being sent.
public let tokenTypeHint: TokenTypeHint
/// A hint to help the server identify the type of token being sent. Optional.
public let tokenTypeHint: TokenTypeHint?

enum CodingKeys: String, CodingKey {
case token
case tokenTypeHint = "token_type_hint"
}

/// A representation of possible OAuth token variants.
public enum Token: Codable {
Expand Down
4 changes: 2 additions & 2 deletions Sources/OAuthTypes/OAuthTokenResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ public struct TokenResponse: Codable {
/// The refresh token of the response. Optional.
public let refreshToken: String?

/// The date and time the response expires. Optional.
public let expiresIn: Date?
/// The lifetime of the access token, in seconds. Optional.
public let expiresIn: Int?

/// The signed JSON Web Token (JWT). Optional.
public let idToken: SignedJWT?
Expand Down
18 changes: 18 additions & 0 deletions Sources/OAuthTypes/OAuthTokenType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,22 @@ public enum OAuthTokenType: String, Codable, CaseIterable {
return nil
}
}

public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)

guard let instance = Self.parse(value) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid OAuthTokenType value: \(value)"
)
}
self = instance
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.rawValue)
}
}
14 changes: 7 additions & 7 deletions Sources/OAuthTypes/OAuthTypesLabsErrors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ public enum OAuthTypesLabsURIError: Error, LocalizedError, CustomStringConvertib
/// The URI/URL lacks the "HTTPS" protocol.
case noHTTPSProtocol

/// The URI has less than two segements.
case lessThanTwoSegementsInURI
/// The URI has less than two segments.
case lessThanTwoSegmentsInURI

/// The URI ended with `.local`.
case endsInLocal
Expand Down Expand Up @@ -53,7 +53,7 @@ public enum OAuthTypesLabsURIError: Error, LocalizedError, CustomStringConvertib
return "The URI must be 'http://localhost:8080', '127.0.0.1', or '[::1]' as the hostname."
case .noHTTPSProtocol:
return "The URI must start with 'https://'."
case .lessThanTwoSegementsInURI:
case .lessThanTwoSegmentsInURI:
return "The URI must contain at least two segments."
case .endsInLocal:
return "The URI must not end with '.local'."
Expand Down Expand Up @@ -271,7 +271,7 @@ public enum OAuthClientIDLoopbackError: Error, LocalizedError, CustomStringConve
public enum OAuthIssuerIdentifierError: Error, LocalizedError, CustomStringConvertible {

/// Issuer URL contained a slash (`/`) at the end.
case issurURLEndsWithSlash
case issuerURLEndsWithSlash

/// The URL provided was invalid.
case invalidURL
Expand All @@ -287,7 +287,7 @@ public enum OAuthIssuerIdentifierError: Error, LocalizedError, CustomStringConve

public var errorDescription: String? {
switch self {
case .issurURLEndsWithSlash:
case .issuerURLEndsWithSlash:
return "Issuer URL must not end with a slash."
case .invalidURL:
return "The URL provided was invalid."
Expand Down Expand Up @@ -338,7 +338,7 @@ public enum OAuthAuthorizationServerMetadataError: Error, CustomStringConvertibl
public enum OAuthClientIDDiscoverableError: Error, LocalizedError, CustomStringConvertible {

/// The client ID provided doesn't have the "https://" protocol.
case invlaidURL
case invalidURL

/// The client ID contains a username and/or password.
case credentialsDetected
Expand Down Expand Up @@ -377,7 +377,7 @@ public enum OAuthClientIDDiscoverableError: Error, LocalizedError, CustomStringC

public var errorDescription: String? {
switch self {
case .invlaidURL:
case .invalidURL:
return "The client ID URL is invalid."
case .credentialsDetected:
return "The client ID must not contain a username or password."
Expand Down
Loading