diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da207c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +# Builds and tests on Linux to prove the package compiles and passes there, not only on Apple +# platforms. Apple, Android, and Windows are out of this workflow's scope. + +on: + push: + pull_request: + workflow_dispatch: + +# The jobs only need to read the repository contents. +permissions: + contents: read + +jobs: + linux: + name: Linux (Swift ${{ matrix.swift }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + swift: ["6.0", "6.1"] + container: swift:${{ matrix.swift }} + steps: + - name: Check out the repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Build + run: swift build + + - name: Test + run: swift test diff --git a/Conversion Documentation/oauth-types/oauth-types atproto-loopback-client-id conversion.md b/Conversion Documentation/oauth-types/oauth-types atproto-loopback-client-id conversion.md new file mode 100644 index 0000000..91ce24b --- /dev/null +++ b/Conversion Documentation/oauth-types/oauth-types atproto-loopback-client-id conversion.md @@ -0,0 +1,14 @@ +# Conversion for `oauth-types/src/atproto-loopback-client-id.ts` + +## `ATProtoLoopbackClient.buildClientID(_:)` + +Converts `buildAtprotoLoopbackClientId`. The `Configuration` type is `OAuthLoopbackClientIdConfig`: the scope is encoded only when it differs from the default `atproto` scope, and the redirect URIs only when they differ from `defaultRedirectURIs`. + +## `ATProtoLoopbackClient.parseClientID(_:)` + +Converts `parseAtprotoLoopbackClientId`. The `Parameters` type is `AtprotoLoopbackClientIdParams`: the scope defaults to `atproto` and must include the `atproto` value, and the redirect URIs default to `defaultRedirectURIs`. + +## Notes + +- `clientIDOrigin` shares its value (`http://localhost`) with `ClientIDLoopback.prefix` (`LOOPBACK_CLIENT_ID_ORIGIN`). +- `parseClientID` parses strictly, like the TypeScript `parseOAuthLoopbackClientId`: an invalid `redirect_uri`, an unexpected query parameter, or a duplicate `scope` is rejected. The existing `ClientIDLoopback.parse` is more lenient and silently drops invalid redirect URIs, so the AT Protocol layer does its own strict parse rather than build on it. The base leniency is flagged for review. diff --git a/Conversion Documentation/oauth-types/oauth-types atproto-loopback-client-redirect-uris conversion.md b/Conversion Documentation/oauth-types/oauth-types atproto-loopback-client-redirect-uris conversion.md new file mode 100644 index 0000000..be463d8 --- /dev/null +++ b/Conversion Documentation/oauth-types/oauth-types atproto-loopback-client-redirect-uris conversion.md @@ -0,0 +1,5 @@ +# Conversion for `oauth-types/src/atproto-loopback-client-redirect-uris.ts` + +## `ATProtoLoopbackClient.defaultRedirectURIs` + +Converts `DEFAULT_LOOPBACK_CLIENT_REDIRECT_URIS`, the default loopback redirect URIs `["http://127.0.0.1/", "http://[::1]/"]`, kept in IPv4-first order to match the reference. diff --git a/Conversion Documentation/oauth-types/oauth-types atproto-oauth-scope conversion.md b/Conversion Documentation/oauth-types/oauth-types atproto-oauth-scope conversion.md new file mode 100644 index 0000000..28a158c --- /dev/null +++ b/Conversion Documentation/oauth-types/oauth-types atproto-oauth-scope conversion.md @@ -0,0 +1,14 @@ +# Conversion for `oauth-types/src/atproto-oauth-scope.ts` + +## `ATProtoOAuthScope` + +Converts `AtprotoOAuthScope`, `isAtprotoOAuthScope`, `asAtprotoOAuthScope`, `ATPROTO_SCOPE_VALUE`, and `DEFAULT_ATPROTO_OAUTH_SCOPE`. + +- `ATProtoOAuthScope.atprotoScopeValue` is `ATPROTO_SCOPE_VALUE` (`"atproto"`). +- `ATProtoOAuthScope.default` is `DEFAULT_ATPROTO_OAUTH_SCOPE`. +- `ATProtoOAuthScope.isValid(_:)` is `isAtprotoOAuthScope`: a value that is a valid OAuth scope (`OAuthScope.isValid`) and includes the `atproto` value as one of its space-separated tokens (`isSpaceSeparatedValue`). +- `init(validating:)` is the throwing form of `asAtprotoOAuthScope`. + +## Notes + +- `isSpaceSeparatedValue(_:in:)` (in `Utilities.swift`) and `OAuthScope.isValid(_:)` (in `OAuthScope.swift`) were added to mirror the `util.ts` and `oauth-scope.ts` helpers this module depends on. diff --git a/Conversion Documentation/oauth-types/oauth-types atproto-oauth-token-response conversion.md b/Conversion Documentation/oauth-types/oauth-types atproto-oauth-token-response conversion.md new file mode 100644 index 0000000..0e0e4a8 --- /dev/null +++ b/Conversion Documentation/oauth-types/oauth-types atproto-oauth-token-response conversion.md @@ -0,0 +1,10 @@ +# Conversion for `oauth-types/src/atproto-oauth-token-response.ts` + +## `ATProtoTokenResponse` + +Converts `atprotoOAuthTokenResponseSchema`, which extends `oauthTokenResponseSchema` (`TokenResponse`). It requires the `DPoP` token type, a required `sub` (`ATProtoDID`) and `scope` (`ATProtoOAuthScope`), and rejects an `id_token`, because OpenID Connect is not compatible with AT Protocol identities. + +## Notes + +- `sub` uses `ATProtoDID` in place of `@atproto/did`'s `atprotoDidSchema`. `ATProtoDID` validates the `did:plc` and `did:web` syntax only; it does not resolve the identity. Full DID resolution is out of scope for OAuthTypes and belongs to a later phase of the client. This is flagged for review. +- The base `oauthTokenResponseSchema` uses `.passthrough()` to keep unknown keys; Swift's decoder ignores unknown keys, which matches the RFC 6749 instruction that the client must ignore unrecognized value names. diff --git a/Conversion Documentation/oauth-types/oauth-types oauth-introspection-response conversion.md b/Conversion Documentation/oauth-types/oauth-types oauth-introspection-response conversion.md new file mode 100644 index 0000000..6191020 --- /dev/null +++ b/Conversion Documentation/oauth-types/oauth-types oauth-introspection-response conversion.md @@ -0,0 +1,8 @@ +# Conversion for `oauth-types/src/oauth-introspection-response.ts` + +## `OAuthIntrospectionResponse` + +Converts `OAuthIntrospectionResponse` (RFC 7662, Section 2.2). The TypeScript discriminated union on `active` becomes an enum with `inactive` and `active(Active)` cases. + +- `Active` carries the optional metadata of an active token (`scope`, `client_id`, `username`, `token_type`, `authorization_details`, `aud`, `exp`, `iat`, `iss`, `jti`, `nbf`, `sub`). +- `Audience` models the `aud` claim, which may be a single string or an array of strings. diff --git a/Package.swift b/Package.swift index e4cd52e..9d6affb 100644 --- a/Package.swift +++ b/Package.swift @@ -40,7 +40,7 @@ let package = Package( ), .testTarget( name: "ATOAuthKitTests", - dependencies: ["ATOAuthKit"] + dependencies: ["ATOAuthKit", "OAuthTypes"] ), ] ) diff --git a/Sources/OAuthTypes/ATProtoDID.swift b/Sources/OAuthTypes/ATProtoDID.swift new file mode 100644 index 0000000..f9df816 --- /dev/null +++ b/Sources/OAuthTypes/ATProtoDID.swift @@ -0,0 +1,178 @@ +// +// ATProtoDID.swift +// ATOAuthKit +// +// Created by systemBlue on 2026-06-16. +// + +import Foundation + +/// A structure representing an AT Protocol decentralized identifier (DID). +/// +/// AT Protocol supports two DID methods: `did:plc` (a 24-character base32 identifier) and `did:web` +/// (a hostname). This type checks that a string is one of those two, syntactically. It deliberately +/// does **not** resolve the DID or verify that it points at a real identity; resolution is the job +/// of a higher layer (the identity resolvers planned for a later phase of the client). +/// +/// This mirrors the role of `atprotoDidSchema` from `@atproto/did`, which `@atproto/oauth-types` +/// uses for the `sub` of an AT Protocol token response. +public struct ATProtoDID: Codable, CustomStringConvertible, Sendable { + + /// The base32 alphabet a `did:plc` identifier is drawn from. + /// + /// This is the sortable, lowercase base32 alphabet (`a`–`z` and `2`–`7`). + private static let base32Alphabet = Set("abcdefghijklmnopqrstuvwxyz234567") + + /// The characters allowed in a `did:web` hostname. + private static let webHostCharacters = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-") + + /// The validated raw DID string. + public let rawValue: String + + /// A textual representation of the DID. + public var description: String { + return rawValue + } + + /// Validates the specified raw value, then creates a new instance. + /// + /// The value is valid when it is a `did:plc` DID (`did:plc:` followed by 24 base32 characters) + /// or a `did:web` DID (`did:web:` followed by a hostname). + /// + /// - Parameter rawValue: The raw value to validate and use for the new instance. + /// + /// - Throws: ``ATProtoDIDError`` describing why the value is not a valid AT Protocol DID. + public init(validating rawValue: String) throws { + guard rawValue.hasPrefix("did:") else { + throw ATProtoDIDError.invalidDID + } + + let afterScheme = rawValue.dropFirst("did:".count) + guard let methodEnd = afterScheme.firstIndex(of: ":") else { + throw ATProtoDIDError.invalidDID + } + + let method = String(afterScheme[.. Bool { + return (try? ATProtoDID(validating: input)) != nil + } + + /// Determines whether a `did:plc` method-specific identifier is valid. + /// + /// A valid identifier is exactly 24 characters drawn from the sortable base32 alphabet. + /// + /// - Parameter identifier: The portion of the DID after `did:plc:`. + /// - Returns: `true` if the identifier is valid, or `false` if not. + private static func isValidPLCIdentifier(_ identifier: String) -> Bool { + return identifier.count == 24 && identifier.allSatisfy { base32Alphabet.contains($0) } + } + + /// Determines whether a `did:web` method-specific identifier is valid. + /// + /// AT Protocol uses `did:web` for bare hostnames, so the identifier must contain at least one + /// dot, use only hostname characters, and carry no port, path, query, or fragment. + /// + /// - Parameter identifier: The portion of the DID after `did:web:`. + /// - Returns: `true` if the identifier is valid, or `false` if not. + private static func isValidWebIdentifier(_ identifier: String) -> Bool { + guard identifier.contains("."), + !identifier.hasPrefix("."), + !identifier.hasSuffix("."), + !identifier.hasPrefix("-"), + !identifier.hasSuffix("-"), + identifier.allSatisfy({ webHostCharacters.contains($0) }) else { + return false + } + + return true + } + + /// Creates a new instance by decoding from the given decoder. + /// + /// - Parameter decoder: The decoder to read data from. + /// - Throws: A `DecodingError` if the decoded string is not a valid AT Protocol DID. + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + + do { + try self.init(validating: value) + } catch { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ATProtoDID value: \(value)" + ) + } + } + + /// Encodes this value into the given encoder. + /// + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// Errors that can occur when validating an AT Protocol DID. +public enum ATProtoDIDError: Error, LocalizedError, CustomStringConvertible, Sendable { + + /// The value is not a syntactically valid DID (it lacks the `did::` shape). + case invalidDID + + /// The DID uses a method other than `plc` or `web`. + /// + /// - Parameter method: The unsupported method name. + case unsupportedMethod(_ method: String) + + /// The `did:plc` identifier is not 24 base32 characters. + case invalidPLCIdentifier + + /// The `did:web` identifier is not a valid bare hostname. + case invalidWebIdentifier + + /// A localized message describing what error occurred. + public var errorDescription: String? { + switch self { + case .invalidDID: + return "The value is not a valid DID." + case .unsupportedMethod(let method): + return "AT Protocol DIDs must use the \"plc\" or \"web\" method, not \"\(method)\"." + case .invalidPLCIdentifier: + return "A \"did:plc\" identifier must be 24 base32 characters." + case .invalidWebIdentifier: + return "A \"did:web\" identifier must be a bare hostname." + } + } + + /// A textual representation of the error. + public var description: String { + return errorDescription ?? String(describing: self) + } +} diff --git a/Sources/OAuthTypes/ATProtoLoopbackClientID.swift b/Sources/OAuthTypes/ATProtoLoopbackClientID.swift new file mode 100644 index 0000000..a0c2241 --- /dev/null +++ b/Sources/OAuthTypes/ATProtoLoopbackClientID.swift @@ -0,0 +1,314 @@ +// +// ATProtoLoopbackClientID.swift +// ATOAuthKit +// +// Created by systemBlue on 2026-06-16. +// + +import Foundation + +public extension ATProtoLoopbackClient { + + /// The origin that every AT Protocol loopback client ID begins with. + /// + /// This will always have a value of `http://localhost`. It mirrors `LOOPBACK_CLIENT_ID_ORIGIN` + /// from `@atproto/oauth-types` and shares its value with ``ClientIDLoopback/prefix``. + static var clientIDOrigin: String { ClientIDLoopback.prefix } + + /// A configuration for building an AT Protocol loopback client ID. + /// + /// Mirrors `OAuthLoopbackClientIdConfig` from `@atproto/oauth-types`. + struct Configuration: Sendable { + + /// The requested scope. + /// + /// When `nil` or equal to the default `atproto` scope, it is omitted from the built client + /// ID. When present and not the default, it must be a valid AT Protocol OAuth scope. + public let scope: String? + + /// The requested redirect URIs. + /// + /// When `nil` or equivalent to ``defaultRedirectURIs`` (regardless of order), they are + /// omitted from the built client ID. When present and not the default, each must be a valid + /// loopback redirect URI. + public let redirectURIs: [String]? + + /// Creates a configuration. + /// + /// - Parameters: + /// - scope: The requested scope, or `nil` to use the default. + /// - redirectURIs: The requested redirect URIs, or `nil` to use the defaults. + public init(scope: String? = nil, redirectURIs: [String]? = nil) { + self.scope = scope + self.redirectURIs = redirectURIs + } + } + + /// The parsed parameters of an AT Protocol loopback client ID. + /// + /// Mirrors `AtprotoLoopbackClientIdParams` from `@atproto/oauth-types`. + struct Parameters: Sendable { + + /// The AT Protocol OAuth scope, defaulting to ``ATProtoOAuthScope/default``. + public let scope: ATProtoOAuthScope + + /// The loopback redirect URIs, defaulting to ``defaultRedirectURIs``. + /// + /// This is never empty. + public let redirectURIs: [OAuthLoopbackRedirectURI] + + /// Creates parsed parameters. + /// + /// - Parameters: + /// - scope: The AT Protocol OAuth scope. + /// - redirectURIs: The non-empty loopback redirect URIs. + public init(scope: ATProtoOAuthScope, redirectURIs: [OAuthLoopbackRedirectURI]) { + self.scope = scope + self.redirectURIs = redirectURIs + } + } + + /// Makes an AT Protocol loopback client ID from a configuration. + /// + /// The scope is encoded only when it differs from the default `atproto` scope, and the redirect + /// URIs are encoded only when they differ from ``defaultRedirectURIs`` (ignoring order). When + /// neither needs encoding, the bare ``clientIDOrigin`` is returned. + /// + /// Mirrors `buildAtprotoLoopbackClientId` from `@atproto/oauth-types`. + /// + /// - Parameter configuration: The configuration to encode, or `nil` for the bare origin. + /// - Returns: A loopback client ID string. + /// + /// - Throws: ``ATProtoLoopbackClientIDError/invalidScope`` if the scope is not an AT Protocol + /// scope, ``ATProtoLoopbackClientIDError/emptyRedirectURIs`` if a non-default redirect URI list + /// is empty, or ``ATProtoLoopbackClientIDError/invalidRedirectURI(_:)`` if a redirect URI is + /// not a valid loopback redirect URI. + static func makeClientID(_ configuration: Configuration? = nil) throws -> String { + guard let configuration else { return clientIDOrigin } + + var queryItems: [URLQueryItem] = [] + + if let scope = configuration.scope, scope != ATProtoOAuthScope.atprotoScopeValue { + guard ATProtoOAuthScope.isValid(scope) else { + throw ATProtoLoopbackClientIDError.invalidScope + } + queryItems.append(URLQueryItem(name: "scope", value: scope)) + } + + if let redirectURIs = configuration.redirectURIs, + Set(redirectURIs) != Set(defaultRedirectURIs) { + guard !redirectURIs.isEmpty else { + throw ATProtoLoopbackClientIDError.emptyRedirectURIs + } + + for redirectURI in redirectURIs { + guard (try? OAuthLoopbackRedirectURI(validating: redirectURI)) != nil else { + throw ATProtoLoopbackClientIDError.invalidRedirectURI(redirectURI) + } + queryItems.append(URLQueryItem(name: "redirect_uri", value: redirectURI)) + } + } + + guard !queryItems.isEmpty else { return clientIDOrigin } + + var components = URLComponents() + components.queryItems = queryItems + + return "\(clientIDOrigin)?\(components.percentEncodedQuery ?? "")" + } + + /// Parses an AT Protocol loopback client ID into its parameters. + /// + /// The scope defaults to `atproto` when absent and must be a valid AT Protocol OAuth scope. The + /// redirect URIs default to ``defaultRedirectURIs`` when absent. + /// + /// Mirrors `parseAtprotoLoopbackClientId` from `@atproto/oauth-types`. + /// + /// - Parameter clientID: The loopback client ID to parse. + /// - Returns: The parsed parameters. + /// + /// - Throws: ``ATProtoLoopbackClientIDError`` if the client ID is malformed, or if its scope is + /// not an AT Protocol scope. + static func parseClientID(_ clientID: String) throws -> Parameters { + let parsed = try parseLoopbackClientID(clientID) + + let scope: ATProtoOAuthScope + if let parsedScope = parsed.scope { + // The query parse guarantees a valid OAuth scope; the AT Protocol layer additionally + // requires the "atproto" value. + guard let atprotoScope = try? ATProtoOAuthScope(validating: parsedScope) else { + throw ATProtoLoopbackClientIDError.missingATProtoScopeValue + } + scope = atprotoScope + } else { + scope = .default + } + + let redirectURIs: [OAuthLoopbackRedirectURI] + if let parsedRedirectURIs = parsed.redirectURIs, !parsedRedirectURIs.isEmpty { + redirectURIs = parsedRedirectURIs + } else { + redirectURIs = try defaultRedirectURIs.map { try OAuthLoopbackRedirectURI(validating: $0) } + } + + return Parameters(scope: scope, redirectURIs: redirectURIs) + } + + /// Parses the structural shape of a loopback client ID and its query string. + /// + /// This mirrors `parseOAuthLoopbackClientId` from `@atproto/oauth-types`: it validates the + /// origin, rejects hash and path components, and then parses the query string strictly. Unlike + /// ``ClientIDLoopback/parse(outhLoopbackClientID:)``, an invalid `redirect_uri` is rejected + /// rather than silently dropped, matching the TypeScript reference. + /// + /// - Parameter input: The loopback client ID to parse. + /// - Returns: The parsed scope string (if present) and validated redirect URIs (if present). + /// + /// - Throws: ``ATProtoLoopbackClientIDError`` if the structural shape or query string is + /// invalid. + private static func parseLoopbackClientID( + _ input: String + ) throws -> (scope: String?, redirectURIs: [OAuthLoopbackRedirectURI]?) { + guard input.hasPrefix(clientIDOrigin) else { + throw ATProtoLoopbackClientIDError.mustStartWithOrigin + } + + let originEnd = input.index(input.startIndex, offsetBy: clientIDOrigin.count) + guard !input[originEnd...].contains("#") else { + throw ATProtoLoopbackClientIDError.containsHashComponent + } + + // The query string begins right after the origin, plus one for an optional single "/". + let hasTrailingSlash = input.count > clientIDOrigin.count + && input.character(at: clientIDOrigin.count) == "/" + let queryStringIndex = clientIDOrigin.count + (hasTrailingSlash ? 1 : 0) + + // Anything between the origin (or "origin/") and a "?" is a path component, which is barred. + if input.count != queryStringIndex { + guard input.character(at: queryStringIndex) == "?" else { + throw ATProtoLoopbackClientIDError.containsPathComponent + } + } + + let queryString = input.count > queryStringIndex + ? String(input.dropFirst(queryStringIndex + 1)) + : "" + + return try parseLoopbackClientIDQueryString(queryString) + } + + /// Parses the query string of a loopback client ID. + /// + /// Mirrors `safeParseOAuthLoopbackClientIdQueryString` from `@atproto/oauth-types`: only + /// `scope` and `redirect_uri` parameters are allowed, `scope` may appear at most once and must + /// be a valid OAuth scope, and each `redirect_uri` must be a valid loopback redirect URI. + /// + /// - Parameter queryString: The raw query string (without the leading `?`). + /// - Returns: The parsed scope string (if present) and validated redirect URIs (if present). + /// + /// - Throws: ``ATProtoLoopbackClientIDError`` if the query string contains an unexpected + /// parameter, a duplicate scope, an invalid scope, or an invalid redirect URI. + private static func parseLoopbackClientIDQueryString( + _ queryString: String + ) throws -> (scope: String?, redirectURIs: [OAuthLoopbackRedirectURI]?) { + guard !queryString.isEmpty else { return (nil, nil) } + + // The query string is taken verbatim from the client ID, so it is already + // percent-encoded. Assign it as the encoded query so `queryItems` decodes it once + // (assigning `query` would treat it as decoded and re-encode the "%"). + var components = URLComponents() + components.percentEncodedQuery = queryString + let items = components.queryItems ?? [] + + var scope: String? + var redirectURIs: [OAuthLoopbackRedirectURI] = [] + + for item in items { + switch item.name { + case "scope": + guard scope == nil else { + throw ATProtoLoopbackClientIDError.duplicateScopeParameter + } + let value = item.value ?? "" + guard OAuthScope.isValid(value) else { + throw ATProtoLoopbackClientIDError.invalidScope + } + scope = value + case "redirect_uri": + let value = item.value ?? "" + guard let redirectURI = try? OAuthLoopbackRedirectURI(validating: value) else { + throw ATProtoLoopbackClientIDError.invalidRedirectURI(value) + } + redirectURIs.append(redirectURI) + default: + throw ATProtoLoopbackClientIDError.unexpectedQueryParameter(name: item.name) + } + } + + return (scope, redirectURIs.isEmpty ? nil : redirectURIs) + } +} + +/// Errors that can occur when building or parsing an AT Protocol loopback client ID. +public enum ATProtoLoopbackClientIDError: Error, LocalizedError, CustomStringConvertible, Sendable { + + /// The client ID does not start with the loopback origin (`http://localhost`). + case mustStartWithOrigin + + /// The client ID contains a hash (`#`) component. + case containsHashComponent + + /// The client ID contains a path component. + case containsPathComponent + + /// The client ID's query string contains a parameter other than `scope` or `redirect_uri`. + /// + /// - Parameter name: The name of the unexpected parameter. + case unexpectedQueryParameter(name: String) + + /// The client ID's query string contains more than one `scope` parameter. + case duplicateScopeParameter + + /// The scope is not a valid OAuth scope. + case invalidScope + + /// A redirect URI is not a valid loopback redirect URI. + /// + /// - Parameter value: The offending redirect URI value. + case invalidRedirectURI(_ value: String) + + /// The scope does not include the `atproto` scope value. + case missingATProtoScopeValue + + /// A non-default redirect URI list was empty. + case emptyRedirectURIs + + /// A localized message describing what error occurred. + public var errorDescription: String? { + switch self { + case .mustStartWithOrigin: + return "The loopback client ID must start with \"\(ATProtoLoopbackClient.clientIDOrigin)\"." + case .containsHashComponent: + return "The loopback client ID must not contain a hash component." + case .containsPathComponent: + return "The loopback client ID must not contain a path component." + case .unexpectedQueryParameter(let name): + return "The loopback client ID must not contain the query parameter \"\(name)\"." + case .duplicateScopeParameter: + return "The loopback client ID must not contain more than one \"scope\" parameter." + case .invalidScope: + return "The loopback client ID \"scope\" parameter is not a valid OAuth scope." + case .invalidRedirectURI(let value): + return "The loopback client ID redirect URI \"\(value)\" is not a valid loopback redirect URI." + case .missingATProtoScopeValue: + return "The loopback client ID scope must include the \"atproto\" scope value." + case .emptyRedirectURIs: + return "The loopback client ID redirect URI list must not be empty." + } + } + + /// A textual representation of the error. + public var description: String { + return errorDescription ?? String(describing: self) + } +} diff --git a/Sources/OAuthTypes/ATProtoLoopbackClientMetadata.swift b/Sources/OAuthTypes/ATProtoLoopbackClientMetadata.swift index f5a3ac4..e94a1ea 100644 --- a/Sources/OAuthTypes/ATProtoLoopbackClientMetadata.swift +++ b/Sources/OAuthTypes/ATProtoLoopbackClientMetadata.swift @@ -33,7 +33,7 @@ public func makeATProtoLoopbackClientMetadata(clientID: String) throws -> Client tokenEndpointAuthMethod: OAuthEndpointAuthMethod.none, applicationType: .native, clientID: OAuthClientID(validating: clientID), - isDPoPBoundAccessTokens: true, + isDPoPBoundAccessTokens: true ) return clientMetadata diff --git a/Sources/OAuthTypes/ATProtoLoopbackClientRedirectURIs.swift b/Sources/OAuthTypes/ATProtoLoopbackClientRedirectURIs.swift new file mode 100644 index 0000000..f4d953c --- /dev/null +++ b/Sources/OAuthTypes/ATProtoLoopbackClientRedirectURIs.swift @@ -0,0 +1,28 @@ +// +// ATProtoLoopbackClientRedirectURIs.swift +// ATOAuthKit +// +// Created by systemBlue on 2026-06-16. +// + +/// A namespace for the AT Protocol loopback client. +/// +/// A loopback client is a native client that has no client metadata document and instead encodes +/// its configuration in its `client_id` (an `http://localhost` URL). The members of this namespace +/// build and parse those client IDs and supply the AT Protocol defaults. +/// +/// This mirrors the `atproto-loopback-client-*` modules from `@atproto/oauth-types`. +public enum ATProtoLoopbackClient { + + /// The default redirect URIs for an AT Protocol loopback client. + /// + /// When a loopback `client_id` does not specify its own `redirect_uri` values, these two URIs + /// are used: the IPv4 loopback (`http://127.0.0.1/`) and the IPv6 loopback (`http://[::1]/`). + /// They are deliberately ordered IPv4 first, matching the AT Protocol reference implementation. + /// + /// This mirrors `DEFAULT_LOOPBACK_CLIENT_REDIRECT_URIS` from `@atproto/oauth-types`. + public static let defaultRedirectURIs: [String] = [ + "http://127.0.0.1/", + "http://[::1]/" + ] +} diff --git a/Sources/OAuthTypes/ATProtoOAuthScope.swift b/Sources/OAuthTypes/ATProtoOAuthScope.swift new file mode 100644 index 0000000..41b639b --- /dev/null +++ b/Sources/OAuthTypes/ATProtoOAuthScope.swift @@ -0,0 +1,129 @@ +// +// ATProtoOAuthScope.swift +// ATOAuthKit +// +// Created by systemBlue on 2026-06-16. +// + +import Foundation + +/// A structure representing an AT Protocol OAuth scope. +/// +/// An AT Protocol OAuth scope is a syntactically valid OAuth scope (a space-separated list of +/// printable ASCII tokens) that includes the `atproto` scope value. The `atproto` value tells the +/// authorization server that the client is taking part in AT Protocol OAuth; an authorization +/// request that omits it is rejected. +/// +/// This mirrors `AtprotoOAuthScope` from `@atproto/oauth-types`. +public struct ATProtoOAuthScope: Codable, CustomStringConvertible, Sendable { + + /// The scope value that identifies an AT Protocol OAuth scope. + /// + /// This will always have a value of `atproto`. + public static let atprotoScopeValue = "atproto" + + /// The default AT Protocol OAuth scope. + /// + /// This is the `atproto` value on its own, which grants read access to the account's identity + /// (the DID) only. + public static let `default` = ATProtoOAuthScope(validated: atprotoScopeValue) + + /// The validated raw scope string. + public let rawValue: String + + /// A textual representation of the scope. + public var description: String { + return rawValue + } + + /// Creates an instance from a value already known to be a valid AT Protocol OAuth scope. + /// + /// This bypasses validation and exists only to build compile-time constants such as + /// ``default``, where the input is statically known to be valid. + /// + /// - Parameter rawValue: The pre-validated raw value. + private init(validated rawValue: String) { + self.rawValue = rawValue + } + + /// Validates the specified raw value, then creates a new instance. + /// + /// The value is valid when it is a syntactically valid OAuth scope and includes the `atproto` + /// scope value as one of its space-separated tokens. + /// + /// - Parameter rawValue: The raw value to validate and use for the new instance. + /// + /// - Throws: ``ATProtoOAuthScopeError/invalidOAuthScope`` if the value is not a valid OAuth + /// scope, or ``ATProtoOAuthScopeError/missingATProtoScopeValue`` if it does not include the + /// `atproto` scope value. + public init(validating rawValue: String) throws { + guard OAuthScope.isValid(rawValue) else { + throw ATProtoOAuthScopeError.invalidOAuthScope + } + + guard isSpaceSeparatedValue(Self.atprotoScopeValue, in: rawValue) else { + throw ATProtoOAuthScopeError.missingATProtoScopeValue + } + + self.rawValue = rawValue + } + + /// Determines whether the string is a valid AT Protocol OAuth scope. + /// + /// - Parameter input: The string to check. + /// - Returns: `true` if the string is a valid AT Protocol OAuth scope, or `false` if not. + public static func isValid(_ input: String) -> Bool { + return OAuthScope.isValid(input) && isSpaceSeparatedValue(atprotoScopeValue, in: input) + } + + /// Creates a new instance by decoding from the given decoder. + /// + /// - Parameter decoder: The decoder to read data from. + /// - Throws: A `DecodingError` if the decoded string is not a valid AT Protocol OAuth scope. + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + + do { + try self.init(validating: value) + } catch { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ATProtoOAuthScope value: \(value)" + ) + } + } + + /// Encodes this value into the given encoder. + /// + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// Errors that can occur when validating an AT Protocol OAuth scope. +public enum ATProtoOAuthScopeError: Error, LocalizedError, CustomStringConvertible, Sendable { + + /// The value is not a syntactically valid OAuth scope. + case invalidOAuthScope + + /// The value does not include the `atproto` scope value. + case missingATProtoScopeValue + + /// A localized message describing what error occurred. + public var errorDescription: String? { + switch self { + case .invalidOAuthScope: + return "The value is not a valid OAuth scope." + case .missingATProtoScopeValue: + return "The OAuth scope must include the \"atproto\" scope value." + } + } + + /// A textual representation of the error. + public var description: String { + return errorDescription ?? String(describing: self) + } +} diff --git a/Sources/OAuthTypes/ATProtoTokenResponse.swift b/Sources/OAuthTypes/ATProtoTokenResponse.swift new file mode 100644 index 0000000..e1fb31c --- /dev/null +++ b/Sources/OAuthTypes/ATProtoTokenResponse.swift @@ -0,0 +1,150 @@ +// +// ATProtoTokenResponse.swift +// ATOAuthKit +// +// Created by systemBlue on 2026-06-16. +// + +/// A structure representing an AT Protocol OAuth token response. +/// +/// This is the base ``TokenResponse`` tightened for AT Protocol: the token type must be `DPoP`, the +/// `sub` (an ``ATProtoDID``) and `scope` (an ``ATProtoOAuthScope``) are required, and an `id_token` +/// must not be present, because OpenID Connect is not compatible with AT Protocol identities. +/// +/// This mirrors `atprotoOAuthTokenResponseSchema` from `@atproto/oauth-types`. +public struct ATProtoTokenResponse: Codable, Sendable { + + /// The access token of the response. + public let accessToken: String + + /// The token type of the response. + /// + /// For an AT Protocol token response this is always ``OAuthTokenType/dpop``. + public let tokenType: OAuthTokenType + + /// The DID of the account the token was issued for (the `sub` claim). + public let subject: ATProtoDID + + /// The granted scope, which always includes the `atproto` value. + public let scope: ATProtoOAuthScope + + /// The refresh token of the response. Optional. + public let refreshToken: String? + + /// The lifetime of the access token, in seconds. Optional. + public let expiresIn: Int? + + /// An array of additional authorization details associated with the token response. Optional. + public let authorizationDetails: [AuthorizationDetail]? + + /// Creates an AT Protocol token response. + /// + /// - Parameters: + /// - accessToken: The access token. + /// - subject: The DID of the account the token was issued for. + /// - scope: The granted AT Protocol scope. + /// - refreshToken: The refresh token, if any. + /// - expiresIn: The access token lifetime in seconds, if any. + /// - authorizationDetails: The authorization details, if any. + public init( + accessToken: String, + subject: ATProtoDID, + scope: ATProtoOAuthScope, + refreshToken: String? = nil, + expiresIn: Int? = nil, + authorizationDetails: [AuthorizationDetail]? = nil + ) { + self.accessToken = accessToken + self.tokenType = .dpop + self.subject = subject + self.scope = scope + self.refreshToken = refreshToken + self.expiresIn = expiresIn + self.authorizationDetails = authorizationDetails + } + + /// The keys used to encode and decode an AT Protocol token response. + enum CodingKeys: String, CodingKey { + + /// The `access_token` wire key. + case accessToken = "access_token" + + /// The `token_type` wire key. + case tokenType = "token_type" + + /// The `sub` wire key. + case subject = "sub" + + /// The `scope` wire key. + case scope + + /// The `refresh_token` wire key. + case refreshToken = "refresh_token" + + /// The `expires_in` wire key. + case expiresIn = "expires_in" + + /// The `id_token` wire key, which must be absent. + case idToken = "id_token" + + /// The `authorization_details` wire key. + case authorizationDetails = "authorization_details" + } + + /// Creates a new instance by decoding from the given decoder. + /// + /// - Parameter decoder: The decoder to read data from. + /// + /// - Throws: A `DecodingError` if the token type is not `DPoP`, an `id_token` is present, or a + /// required field is missing or invalid. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.accessToken = try container.decode(String.self, forKey: .accessToken) + + let tokenType = try container.decode(OAuthTokenType.self, forKey: .tokenType) + guard tokenType == .dpop else { + throw DecodingError.dataCorruptedError( + forKey: .tokenType, + in: container, + debugDescription: "An AT Protocol token response must have a \"DPoP\" token type." + ) + } + self.tokenType = tokenType + + // OpenID Connect is not compatible with AT Protocol identities, so an id_token is rejected. + guard !container.contains(.idToken) else { + throw DecodingError.dataCorruptedError( + forKey: .idToken, + in: container, + debugDescription: "An AT Protocol token response must not contain an \"id_token\"." + ) + } + + self.subject = try container.decode(ATProtoDID.self, forKey: .subject) + self.scope = try container.decode(ATProtoOAuthScope.self, forKey: .scope) + self.refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) + self.expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn) + self.authorizationDetails = try container.decodeIfPresent( + [AuthorizationDetail].self, + forKey: .authorizationDetails + ) + } + + /// Encodes this value into the given encoder. + /// + /// The `id_token` key is never written, since an AT Protocol token response must not carry one. + /// + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(accessToken, forKey: .accessToken) + try container.encode(tokenType, forKey: .tokenType) + try container.encode(subject, forKey: .subject) + try container.encode(scope, forKey: .scope) + try container.encodeIfPresent(refreshToken, forKey: .refreshToken) + try container.encodeIfPresent(expiresIn, forKey: .expiresIn) + try container.encodeIfPresent(authorizationDetails, forKey: .authorizationDetails) + } +} diff --git a/Sources/OAuthTypes/Constants.swift b/Sources/OAuthTypes/Constants.swift index e4328db..1c1a887 100644 --- a/Sources/OAuthTypes/Constants.swift +++ b/Sources/OAuthTypes/Constants.swift @@ -1,5 +1,5 @@ // -// Models.swift +// Constants.swift // ATOAuthKit // // Created by Christopher Jr Riley on 2025-07-28. diff --git a/Sources/OAuthTypes/HostnameIPAddress.swift b/Sources/OAuthTypes/HostnameIPAddress.swift new file mode 100644 index 0000000..d0020f7 --- /dev/null +++ b/Sources/OAuthTypes/HostnameIPAddress.swift @@ -0,0 +1,37 @@ +// +// HostnameIPAddress.swift +// ATOAuthKit +// +// Created by Christopher Jr Riley on 2025-07-24. +// + +import Foundation + +/// Determines whether the hostname is an IP address. +/// +/// - Parameter hostname: The hostname to check on. +/// - Returns: `true` if it's an IP address, or `false` if it's not. +public func isHostnameIPAddress(_ hostname: String) -> Bool { + // IPv4: any dotted-quad of digits, matching the reference `isHostnameIP` (`/^\d+\.\d+\.\d+\.\d+$/`). + // This is deliberately loose: a malformed quad such as "300.1.2.3" is still treated as an IP, so it + // is rejected where an IP is barred (for example, a discoverable client ID) instead of slipping + // through as a domain name. Whether each octet is in range is a separate concern this check does + // not decide. + let ipAddressV4Check = #"^\d+\.\d+\.\d+\.\d+$"# + if hostname.range(of: ipAddressV4Check, options: .regularExpression) != nil { + return true + } + + // IPv6: a bracketed hostname (e.g. "[::1]") is treated as an IP address. + if hostname.hasPrefix("[") && hostname.hasSuffix("]") { + return true + } + + // IPv6 regex: covers full, shorthand, and mixed notations. + let ipAddressV6Check = #"^(([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){1,7}:)|(([0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){1,5}(:[0-9A-Fa-f]{1,4}){1,2})|(([0-9A-Fa-f]{1,4}:){1,4}(:[0-9A-Fa-f]{1,4}){1,3})|(([0-9A-Fa-f]{1,4}:){1,3}(:[0-9A-Fa-f]{1,4}){1,4})|(([0-9A-Fa-f]{1,4}:){1,2}(:[0-9A-Fa-f]{1,4}){1,5})|([0-9A-Fa-f]{1,4}:((:[0-9A-Fa-f]{1,4}){1,6}))|(:((:[0-9A-Fa-f]{1,4}){1,7}|:))$"# + if hostname.range(of: ipAddressV6Check, options: .regularExpression) != nil { + return true + } + + return false +} diff --git a/Sources/OAuthTypes/Utilities.swift b/Sources/OAuthTypes/JWT.swift similarity index 75% rename from Sources/OAuthTypes/Utilities.swift rename to Sources/OAuthTypes/JWT.swift index 0822281..e259b31 100644 --- a/Sources/OAuthTypes/Utilities.swift +++ b/Sources/OAuthTypes/JWT.swift @@ -1,5 +1,5 @@ // -// Utilities.swift +// JWT.swift // ATOAuthKit // // Created by Christopher Jr Riley on 2025-07-24. @@ -7,62 +7,6 @@ import Foundation -/// Determines whether the hostname is an IP address. -/// -/// - Parameter hostname: The hostname to check on. -/// - Returns: `true` if it's an IP address, or `false` if it's not. -public func isHostnameIPAddress(_ hostname: String) -> Bool { - // IPv4 regex: '[0-255].[0-255].[0-255].[0-255]'. - let ipAddressV4Check = #"^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$"# - if hostname.range(of: ipAddressV4Check, options: .regularExpression) != nil { - return true - } - - // IPv6 regex: covers full, shorthand, and mixed notations. - let ipAddressV6Check = #"^(([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){1,7}:)|(([0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){1,5}(:[0-9A-Fa-f]{1,4}){1,2})|(([0-9A-Fa-f]{1,4}:){1,4}(:[0-9A-Fa-f]{1,4}){1,3})|(([0-9A-Fa-f]{1,4}:){1,3}(:[0-9A-Fa-f]{1,4}){1,4})|(([0-9A-Fa-f]{1,4}:){1,2}(:[0-9A-Fa-f]{1,4}){1,5})|([0-9A-Fa-f]{1,4}:((:[0-9A-Fa-f]{1,4}){1,6}))|(:((:[0-9A-Fa-f]{1,4}){1,7}|:))$"# - if hostname.range(of: ipAddressV6Check, options: .regularExpression) != nil { - return true - } - - return false -} - -/// An enumeration, representing loopback hosts. -public enum LoopbackHost: String, CaseIterable { - - /// - case localhost = "localhost" - - /// - case ipV4 = "127.0.0.1" - - /// - case ipV6 = "[::1]" -} - -/// Checks if a string is a recognized loopback host. -/// -/// - Parameter host: The string to check. -/// - Returns: `true` if the string is a loopback host, or `false` if it's not. -public func isLoopbackHost(_ host: String) -> Bool { - guard let urlHost = URL(string: host)?.host() else { return false } - return LoopbackHost.allCases.contains { $0.rawValue == urlHost } -} - -/// Determines whether the host of the `URL` is a loopback host. -/// -/// - Parameter input: The `URL` object to check. -/// - Returns: `true` if it's a loopback address, or `false` if not. -public func isLoopbackURL(_ input: URL) -> Bool { - guard let host = input.host else { - return false - } - - return isLoopbackHost(host) -} - -// MARK: ATOAuthKit-specific - - /// A protocol that defines validation for the basic "shape" of a JWT-like token string. /// /// This protocol provides a method to check if a string conforms to the structural requirements @@ -111,7 +55,7 @@ public extension JWTShapeValidating { /// to see if the shape of the JWT exists (that is, a Base64-encoded string with three sections /// (separated by a dot (`.`)). This does **not** validate the signature, nor does it decode /// the header and payload. -public struct SignedJWT: CustomStringConvertible, Codable, JWTShapeValidating { +public struct SignedJWT: CustomStringConvertible, Codable, JWTShapeValidating, Sendable { public let rawValue: String public var description: String { @@ -154,7 +98,7 @@ public struct SignedJWT: CustomStringConvertible, Codable, JWTShapeValidating { /// This includes the header and payload. This `struct`'s initializer will only check to see if /// the shape of the JWT exists (that is, a Base64-encoded string with two sections (separated /// by a dot (`.`)). This does **not** decode the header and payload. -public struct UnsignedJWT: CustomStringConvertible, Codable, JWTShapeValidating { +public struct UnsignedJWT: CustomStringConvertible, Codable, JWTShapeValidating, Sendable { public let rawValue: String public var description: String { @@ -193,7 +137,7 @@ public struct UnsignedJWT: CustomStringConvertible, Codable, JWTShapeValidating } /// An enum representing either a signed or unsigned JSON Web Token (JWT). -public enum JWT: Codable { +public enum JWT: Codable, Sendable { /// A signed JWT. case signed(SignedJWT) diff --git a/Sources/OAuthTypes/LoopbackHost.swift b/Sources/OAuthTypes/LoopbackHost.swift new file mode 100644 index 0000000..e917c65 --- /dev/null +++ b/Sources/OAuthTypes/LoopbackHost.swift @@ -0,0 +1,46 @@ +// +// LoopbackHost.swift +// ATOAuthKit +// +// Created by Christopher Jr Riley on 2025-07-24. +// + +import Foundation + +/// An enumeration, representing loopback hosts. +public enum LoopbackHost: String, CaseIterable, Sendable { + + /// + case localhost = "localhost" + + /// + case ipV4 = "127.0.0.1" + + /// + case ipV6 = "[::1]" +} + +/// Checks if a string is a recognized loopback host. +/// +/// The host is matched directly against `"localhost"`, `"127.0.0.1"`, and `"[::1]"`. An +/// unbracketed IPv6 loopback (`"::1"`, the form `URL/host()` produces) is normalized to its +/// bracketed equivalent before matching. +/// +/// - Parameter host: The hostname to check. +/// - Returns: `true` if the string is a loopback host, or `false` if it's not. +public func isLoopbackHost(_ host: String) -> Bool { + let normalizedHost = host == "::1" ? "[::1]" : host + return LoopbackHost.allCases.contains { $0.rawValue == normalizedHost } +} + +/// Determines whether the host of the `URL` is a loopback host. +/// +/// - Parameter input: The `URL` object to check. +/// - Returns: `true` if it's a loopback address, or `false` if not. +public func isLoopbackURL(_ input: URL) -> Bool { + guard let host = input.host else { + return false + } + + return isLoopbackHost(host) +} diff --git a/Sources/OAuthTypes/OAuthAccessToken.swift b/Sources/OAuthTypes/OAuthAccessToken.swift index c826ca8..080160a 100644 --- a/Sources/OAuthTypes/OAuthAccessToken.swift +++ b/Sources/OAuthTypes/OAuthAccessToken.swift @@ -6,7 +6,7 @@ // /// A structure representing an OAuth access token. -public struct OAuthAccessToken: Codable, CustomStringConvertible { +public struct OAuthAccessToken: Codable, CustomStringConvertible, Sendable { private let rawValue: String diff --git a/Sources/OAuthTypes/OAuthAuthorizationCodeGrantTokenRequest.swift b/Sources/OAuthTypes/OAuthAuthorizationCodeGrantTokenRequest.swift index a4fb80b..233175b 100644 --- a/Sources/OAuthTypes/OAuthAuthorizationCodeGrantTokenRequest.swift +++ b/Sources/OAuthTypes/OAuthAuthorizationCodeGrantTokenRequest.swift @@ -8,7 +8,7 @@ import Foundation /// A structure representing an OAuth 2.0 authorization code for an access token. -public struct AuthorizationCodeGrantTokenRequest: Codable { +public struct AuthorizationCodeGrantTokenRequest: Codable, Sendable { /// The OAuth 2.0 grant type. /// diff --git a/Sources/OAuthTypes/OAuthAuthorizationDetails.swift b/Sources/OAuthTypes/OAuthAuthorizationDetails.swift index d290f33..58fcb96 100644 --- a/Sources/OAuthTypes/OAuthAuthorizationDetails.swift +++ b/Sources/OAuthTypes/OAuthAuthorizationDetails.swift @@ -7,7 +7,7 @@ /// A structure representing the details of an authorization request for a specific resource or set /// of resources. -public struct AuthorizationDetail: Codable { +public struct AuthorizationDetail: Codable, Sendable { /// A string describing the type of resource being requested. public let type: String @@ -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 + } } diff --git a/Sources/OAuthTypes/OAuthAuthorizationRequestJar.swift b/Sources/OAuthTypes/OAuthAuthorizationRequestJar.swift index 26a2fa2..eff7ecc 100644 --- a/Sources/OAuthTypes/OAuthAuthorizationRequestJar.swift +++ b/Sources/OAuthTypes/OAuthAuthorizationRequestJar.swift @@ -6,7 +6,7 @@ // /// A structure representing an OAuth Authorization Request JAR. -public struct AuthorizationRequestJAR: Codable { +public struct AuthorizationRequestJAR: Codable, Sendable { /// The JWT request, either signed or unsigned. public let request: JWT diff --git a/Sources/OAuthTypes/OAuthAuthorizationRequestPAR.swift b/Sources/OAuthTypes/OAuthAuthorizationRequestPAR.swift index cc1d6aa..256120b 100644 --- a/Sources/OAuthTypes/OAuthAuthorizationRequestPAR.swift +++ b/Sources/OAuthTypes/OAuthAuthorizationRequestPAR.swift @@ -7,7 +7,7 @@ /// An enumeration representing a Pushed Authorization Request (PAR) in OAuth 2.0 or OpenID /// Connect flows. -public enum OAuthAuthorizationRequestPAR: Codable { +public enum OAuthAuthorizationRequestPAR: Codable, Sendable { /// A full set of parameters used in an OAuth 2.0 or OpenID Connect authorization request. case authorizationRequestParameters(AuthorizationRequestParameters) diff --git a/Sources/OAuthTypes/OAuthAuthorizationRequestParameters.swift b/Sources/OAuthTypes/OAuthAuthorizationRequestParameters.swift index 9287559..ea5cd7f 100644 --- a/Sources/OAuthTypes/OAuthAuthorizationRequestParameters.swift +++ b/Sources/OAuthTypes/OAuthAuthorizationRequestParameters.swift @@ -13,7 +13,7 @@ import Foundation /// This struct encapsulates all fields necessary for compliant authorization flows, including /// extensions such as PKCE, DPoP, and advanced OpenID Connect features. It can be used to /// construct and parse requests to authorization endpoints. -public struct AuthorizationRequestParameters: Codable { +public struct AuthorizationRequestParameters: Codable, Sendable { /// The unique identifier for the client making the request. public let clientID: OAuthClientID @@ -84,8 +84,9 @@ public struct AuthorizationRequestParameters: Codable { /// by [OAuth 2.0 Rich Authorization Requests](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar). /// Optional. /// - /// Allows clients to describe complex requirements for the requested token. - public let authorizationDetails: AuthorizationDetail? + /// Allows clients to describe complex requirements for the requested token. On the wire this is + /// always a JSON array (the reference `oauthAuthorizationDetailsSchema` is `z.array(...)`). + public let authorizationDetails: [AuthorizationDetail]? public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -130,7 +131,7 @@ public struct AuthorizationRequestParameters: Codable { self.idTokenHint = try container.decodeIfPresent(SignedJWT.self, forKey: .idTokenHint) self.display = try container.decodeIfPresent(AuthorizationRequestParameters.Display.self, forKey: .display) self.prompt = try container.decodeIfPresent(AuthorizationRequestParameters.Prompt.self, forKey: .prompt) - self.authorizationDetails = try container.decodeIfPresent(AuthorizationDetail.self, forKey: .authorizationDetails) + self.authorizationDetails = try container.decodeIfPresent([AuthorizationDetail].self, forKey: .authorizationDetails) } enum CodingKeys: String, CodingKey { @@ -155,7 +156,7 @@ public struct AuthorizationRequestParameters: Codable { } /// The display mode to be used for the authentication UI. - public enum Display: String, Codable { + public enum Display: String, Codable, Sendable { /// The authorization server should display authentication and consent UI as a full page. case page @@ -172,7 +173,7 @@ public struct AuthorizationRequestParameters: Codable { } /// A prompt to control how the authorization server interacts with the user. - public enum Prompt: String, Codable { + public enum Prompt: String, Codable, Sendable { /// No interactive prompt. /// diff --git a/Sources/OAuthTypes/OAuthAuthorizationRequestQuery.swift b/Sources/OAuthTypes/OAuthAuthorizationRequestQuery.swift index 8ad60fe..3b43846 100644 --- a/Sources/OAuthTypes/OAuthAuthorizationRequestQuery.swift +++ b/Sources/OAuthTypes/OAuthAuthorizationRequestQuery.swift @@ -6,43 +6,101 @@ // /// An enumeration representing the possible forms of an OAuth 2.0 Authorization Request query. -public enum AuthorizationRequestQuery: Codable { +/// +/// The query is an intersection of a `client_id` and one of three request forms, so `client_id` is +/// required on every form regardless of which one is used. It is woven into the JAR and request-URI +/// cases (the parameters form already carries it) and surfaced uniformly through ``clientID``. +/// +/// This mirrors `oauthAuthorizationRequestQuerySchema` from `@atproto/oauth-types`. +public enum AuthorizationRequestQuery: Codable, Sendable { /// An authorization request expressed directly as a set of parameters. case authorizationRequestParameters(AuthorizationRequestParameters) - /// A JWT Secured Authorization Request (JAR). - case authorizationRequestJAR(AuthorizationRequestJAR) + /// A JWT Secured Authorization Request (JAR), carrying the required `client_id`. + case authorizationRequestJAR(clientID: OAuthClientID, AuthorizationRequestJAR) - /// An authorization request via a URI. - case authorizationRequestURI(AuthorizationRequestURI) + /// An authorization request via a pushed-authorization-request URI, carrying the required + /// `client_id`. + case authorizationRequestURI(clientID: OAuthClientID, AuthorizationRequestURI) + /// The client identifier carried by the request, required on every form. + public var clientID: OAuthClientID { + switch self { + case .authorizationRequestParameters(let parameters): + return parameters.clientID + case .authorizationRequestJAR(let clientID, _): + return clientID + case .authorizationRequestURI(let clientID, _): + return clientID + } + } + + /// The keys used to encode and decode an authorization request query. + private enum CodingKeys: String, CodingKey { + + /// The `client_id` wire key, required on every form. + case clientID = "client_id" + + /// The `request` wire key that marks a JAR form. + case request + + /// The `request_uri` wire key that marks a request-URI form. + case requestURI = "request_uri" + } + + /// Creates a new instance by decoding from the given decoder. + /// + /// The form is chosen by which fields are present: a `request_uri` selects the request-URI + /// form, a `request` selects the JAR form, and anything else is decoded as the parameters form. + /// A `client_id` is required in every case. + /// + /// - Parameter decoder: The decoder to read data from. + /// + /// - Throws: A `DecodingError` if `client_id` is missing or invalid, the JAR `request` is not a + /// valid JWT, or the parameters form is otherwise invalid. public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - - if let value = try? container.decode(AuthorizationRequestParameters.self) { - self = .authorizationRequestParameters(value) - } else if let value = try? container.decode(AuthorizationRequestJAR.self) { - self = .authorizationRequestJAR(value) - } else if let value = try? container.decode(AuthorizationRequestURI.self) { - self = .authorizationRequestURI(value) + let container = try decoder.container(keyedBy: CodingKeys.self) + + // The intersection requires a client_id on every form, so decode it up front. + let clientID = try container.decode(OAuthClientID.self, forKey: .clientID) + + if container.contains(.requestURI) { + self = .authorizationRequestURI( + clientID: clientID, + try AuthorizationRequestURI(from: decoder) + ) + } else if container.contains(.request) { + let requestValue = try container.decode(String.self, forKey: .request) + guard let jar = AuthorizationRequestJAR(validating: requestValue) else { + throw DecodingError.dataCorruptedError( + forKey: .request, + in: container, + debugDescription: "Invalid JAR request value: \(requestValue)" + ) + } + self = .authorizationRequestJAR(clientID: clientID, jar) } else { - throw DecodingError.typeMismatch( - JWT.self, DecodingError.Context( - codingPath: decoder.codingPath, debugDescription: "Unknown AuthorizationRequestQuery type")) + self = .authorizationRequestParameters(try AuthorizationRequestParameters(from: decoder)) } } - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - + /// Encodes this value into the given encoder. + /// + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: any Encoder) throws { switch self { - case .authorizationRequestParameters(let value): - try container.encode(value) - case .authorizationRequestJAR(let value): - try container.encode(value) - case .authorizationRequestURI(let value): - try container.encode(value) + case .authorizationRequestParameters(let parameters): + // The parameters form already encodes its own client_id. + try parameters.encode(to: encoder) + case .authorizationRequestJAR(let clientID, let jar): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(clientID, forKey: .clientID) + try container.encode(jar.request, forKey: .request) + case .authorizationRequestURI(let clientID, let uri): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(clientID, forKey: .clientID) + try container.encode(uri.requestURI, forKey: .requestURI) } } } diff --git a/Sources/OAuthTypes/OAuthAuthorizationRequestURI.swift b/Sources/OAuthTypes/OAuthAuthorizationRequestURI.swift index 95ec8be..f73351f 100644 --- a/Sources/OAuthTypes/OAuthAuthorizationRequestURI.swift +++ b/Sources/OAuthTypes/OAuthAuthorizationRequestURI.swift @@ -6,7 +6,7 @@ // /// A structure representing an authorization request URI. -public struct AuthorizationRequestURI: Codable { +public struct AuthorizationRequestURI: Codable, Sendable { /// The request URI of the authorization request. public let requestURI: OAuthRequestURI diff --git a/Sources/OAuthTypes/OAuthAuthorizationServerMetadata.swift b/Sources/OAuthTypes/OAuthAuthorizationServerMetadata.swift index 279389d..795dd85 100644 --- a/Sources/OAuthTypes/OAuthAuthorizationServerMetadata.swift +++ b/Sources/OAuthTypes/OAuthAuthorizationServerMetadata.swift @@ -8,7 +8,7 @@ import Foundation /// A structure representing the metadata for an OAuth2 or OpenID Connect authorization server. -public struct AuthorizationServerMetadata: Codable { +public struct AuthorizationServerMetadata: Codable, Sendable { /// The unique identifier for the authorization server. public let issuer: IssuerIdentifier @@ -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) diff --git a/Sources/OAuthTypes/OAuthClientRredentials.swift b/Sources/OAuthTypes/OAuthClientCredentials.swift similarity index 94% rename from Sources/OAuthTypes/OAuthClientRredentials.swift rename to Sources/OAuthTypes/OAuthClientCredentials.swift index e996c54..0ee58d6 100644 --- a/Sources/OAuthTypes/OAuthClientRredentials.swift +++ b/Sources/OAuthTypes/OAuthClientCredentials.swift @@ -7,7 +7,7 @@ /// A structure representing a request body for OAuth 2.0 Client Credentials Bearer token /// exchanges for a JSON Web Token (JWT). -public struct ClientCredentialsJWTBearer: Codable { +public struct ClientCredentialsJWTBearer: Codable, Sendable { /// The unique identifier of the client application making the request. public let clientID: OAuthClientID @@ -47,7 +47,7 @@ public struct ClientCredentialsJWTBearer: Codable { /// A structure representing a request body containing confidential client credentials for /// OAuth authentication. -public struct ClientCredentialsSecretPost: Codable { +public struct ClientCredentialsSecretPost: Codable, Sendable { /// The unique identifier assigned to the OAuth client. public let clientID: OAuthClientID @@ -63,7 +63,7 @@ public struct ClientCredentialsSecretPost: Codable { /// A structure representing a request body containing a public OAuth client identifier, without a /// client secret. -public struct ClientCredentialsNone: Codable { +public struct ClientCredentialsNone: Codable, Sendable { /// The unique identifier assigned to the OAuth client. public let clientID: OAuthClientID @@ -74,7 +74,7 @@ public struct ClientCredentialsNone: Codable { } /// -public enum ClientCredentials: Codable { +public enum ClientCredentials: Codable, Sendable { /// case clientCredentialsJWTBearer(ClientCredentialsJWTBearer) diff --git a/Sources/OAuthTypes/OAuthClientCredentialsGrantTokenRequest.swift b/Sources/OAuthTypes/OAuthClientCredentialsGrantTokenRequest.swift index d5e73a4..93c8e09 100644 --- a/Sources/OAuthTypes/OAuthClientCredentialsGrantTokenRequest.swift +++ b/Sources/OAuthTypes/OAuthClientCredentialsGrantTokenRequest.swift @@ -6,7 +6,7 @@ // /// A structure representing client credentials grant token requests for OAuth 2.0. -public struct OAuthClientCredentialsGrantTokenRequest: Codable { +public struct OAuthClientCredentialsGrantTokenRequest: Codable, Sendable { /// The grant type itself. /// diff --git a/Sources/OAuthTypes/OAuthClientID.swift b/Sources/OAuthTypes/OAuthClientID.swift index cc9b339..3806287 100644 --- a/Sources/OAuthTypes/OAuthClientID.swift +++ b/Sources/OAuthTypes/OAuthClientID.swift @@ -6,7 +6,7 @@ // /// An ID tag for the OAuth client. -public struct OAuthClientID: Codable, CustomStringConvertible { +public struct OAuthClientID: Codable, CustomStringConvertible, Sendable { public var description: String { return rawValue diff --git a/Sources/OAuthTypes/OAuthClientIDDiscoverable.swift b/Sources/OAuthTypes/OAuthClientIDDiscoverable.swift index 6f4e99e..455cf38 100644 --- a/Sources/OAuthTypes/OAuthClientIDDiscoverable.swift +++ b/Sources/OAuthTypes/OAuthClientIDDiscoverable.swift @@ -8,7 +8,7 @@ import Foundation /// A structure representing a discoverable OAuth Client ID in an HTTPS URI form. -public struct ClientIDDiscoverable: Codable, CustomStringConvertible { +public struct ClientIDDiscoverable: Codable, CustomStringConvertible, Sendable { private let rawValue: String @@ -40,10 +40,10 @@ 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 } @@ -51,27 +51,44 @@ public struct ClientIDDiscoverable: Codable, CustomStringConvertible { 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 + // Compare the raw, un-normalized path against its canonical form to reject path traversal + // and other non-canonical paths (TS: `extractUrlPath(value) !== url.pathname`). + // `extractURLPath` reads the path straight from the string without letting `URL` or + // `URLComponents` normalize or percent-decode it, and `canonicalizedURLPath` removes dot + // segments while treating percent-encoded dots (`%2e`/`%2E`) the same as literal dots, the + // way the WHATWG URL parser does. Normalizing it ourselves keeps the result identical on + // every platform, rather than relying on `URL.standardized`, which leaves percent-encoded + // dot segments unresolved and would let `https://host/%2e%2e/...` pass. + guard let rawPath = extractURLPath(from: clientID) else { + throw OAuthClientIDDiscoverableError.invalidURL } - guard originalURLPath == urlComponents.path else { + let canonicalPath = canonicalizedURLPath(rawPath) + guard rawPath == canonicalPath else { throw OAuthClientIDDiscoverableError.incorrectCanonicalForm( - expectedValue: urlComponents.path, - foundValue: originalURLPath + expectedValue: canonicalPath, + foundValue: rawPath ) } } @@ -91,7 +108,7 @@ public struct ClientIDDiscoverable: Codable, CustomStringConvertible { } /// A structure containing a conventional client ID for OAuth purposes. -public struct ConventionalOAuthClientID: Codable, CustomStringConvertible { +public struct ConventionalOAuthClientID: Codable, CustomStringConvertible, Sendable { private let rawValue: String @@ -106,15 +123,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 } diff --git a/Sources/OAuthTypes/OAuthClientIDLoopback.swift b/Sources/OAuthTypes/OAuthClientIDLoopback.swift index 6fb2b13..815b437 100644 --- a/Sources/OAuthTypes/OAuthClientIDLoopback.swift +++ b/Sources/OAuthTypes/OAuthClientIDLoopback.swift @@ -8,7 +8,7 @@ import Foundation /// A structure representing an OAuth loopback client ID string. -public struct ClientIDLoopback: CustomStringConvertible { +public struct ClientIDLoopback: CustomStringConvertible, Sendable { private let rawValue: String diff --git a/Sources/OAuthTypes/OAuthClientMetadata.swift b/Sources/OAuthTypes/OAuthClientMetadata.swift index 80774ef..85ea74f 100644 --- a/Sources/OAuthTypes/OAuthClientMetadata.swift +++ b/Sources/OAuthTypes/OAuthClientMetadata.swift @@ -10,7 +10,7 @@ /// - SeeAlso: /// * https://openid.net/specs/openid-connect-registration-1_0.html /// * https://datatracker.ietf.org/doc/html/rfc7591 -public struct ClientMetadata: Codable { +public struct ClientMetadata: Codable, Sendable { /// An array of valid redirect URIs for this client, to which the authorization server can /// send responses. @@ -103,7 +103,7 @@ public struct ClientMetadata: Codable { public let authorizationDetailsTypes: [String]? /// Represents the type of application, which determines client behavior and allowed redirect URIs. - public enum ApplicationType: String, Codable { + public enum ApplicationType: String, Codable, Sendable { /// The client is a web-based application. case web = "web" @@ -113,7 +113,7 @@ public struct ClientMetadata: Codable { } /// Indicates the style of subject identifier used for user privacy and correlation. - public enum SubjectType: String, Codable { + public enum SubjectType: String, Codable, Sendable { /// Use a publicly shared subject identifier. case `public` diff --git a/Sources/OAuthTypes/OAuthCodeChallengeMethod.swift b/Sources/OAuthTypes/OAuthCodeChallengeMethod.swift index f31c0c0..b220043 100644 --- a/Sources/OAuthTypes/OAuthCodeChallengeMethod.swift +++ b/Sources/OAuthTypes/OAuthCodeChallengeMethod.swift @@ -7,7 +7,7 @@ /// An enumeration representing the code challenge methods supported in OAuth 2.0 and /// OpenID Connect flows. -public enum OAuthCodeChallengeMethod: String, Codable { +public enum OAuthCodeChallengeMethod: String, Codable, Sendable { /// The `S256` method. /// diff --git a/Sources/OAuthTypes/OAuthEndpointAuthMethod.swift b/Sources/OAuthTypes/OAuthEndpointAuthMethod.swift index 4f5ef55..5e23bac 100644 --- a/Sources/OAuthTypes/OAuthEndpointAuthMethod.swift +++ b/Sources/OAuthTypes/OAuthEndpointAuthMethod.swift @@ -7,7 +7,7 @@ /// An enumeration. representing the supported client authentication methods for OAuth 2.0 and /// OpenID Connect token and authorization endpoints. -public enum OAuthEndpointAuthMethod: String, Codable { +public enum OAuthEndpointAuthMethod: String, Codable, Sendable { /// Client authentication using HTTP Basic Authentication with the client ID and client secret. /// diff --git a/Sources/OAuthTypes/OAuthEndpointName.swift b/Sources/OAuthTypes/OAuthEndpointName.swift index d9a38ab..5c3bad7 100644 --- a/Sources/OAuthTypes/OAuthEndpointName.swift +++ b/Sources/OAuthTypes/OAuthEndpointName.swift @@ -10,7 +10,7 @@ /// `OAuthEndpointName` provides type-safe representations for each major OAuth endpoint. These endpoints /// are defined in the OAuth 2.0 specification and its extensions, and are commonly used in authentication /// and authorization flows. -public enum OAuthEndpointName: String { +public enum OAuthEndpointName: String, Sendable { /// The endpoint for obtaining an access token. /// diff --git a/Sources/OAuthTypes/OAuthGrantType.swift b/Sources/OAuthTypes/OAuthGrantType.swift index aeedced..72d0772 100644 --- a/Sources/OAuthTypes/OAuthGrantType.swift +++ b/Sources/OAuthTypes/OAuthGrantType.swift @@ -6,7 +6,7 @@ // /// An enumeration of OAuth 2.0 grant types used to obtain access tokens. -public enum OAuthGrantType: String, Codable { +public enum OAuthGrantType: String, Codable, Sendable { /// The "Authorization Code" grant type. /// diff --git a/Sources/OAuthTypes/OAuthIntrospectionResponse.swift b/Sources/OAuthTypes/OAuthIntrospectionResponse.swift new file mode 100644 index 0000000..2fea926 --- /dev/null +++ b/Sources/OAuthTypes/OAuthIntrospectionResponse.swift @@ -0,0 +1,246 @@ +// +// OAuthIntrospectionResponse.swift +// ATOAuthKit +// +// Created by systemBlue on 2026-06-16. +// + +/// A structure representing an OAuth token introspection response. +/// +/// An introspection response is a discriminated union on the `active` field: an inactive token +/// carries no other information, while an active token may carry the metadata in ``Active``. +/// +/// This mirrors `OAuthIntrospectionResponse` from `@atproto/oauth-types`. +/// +/// - SeeAlso: [RFC 7662, Section 2.2](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2) +public enum OAuthIntrospectionResponse: Codable, Sendable { + + /// The token is not active. + /// + /// On the wire this is `{ "active": false }`, with no other fields. + case inactive + + /// The token is active, along with its associated metadata. + case active(Active) + + /// The metadata of an active token. + public struct Active: Codable, Sendable { + + /// The scope associated with the token. Optional. + public let scope: String? + + /// The client identifier the token was issued to. Optional. + public let clientID: String? + + /// A human-readable identifier for the resource owner. Optional. + public let username: String? + + /// The type of the token. Optional. + public let tokenType: OAuthTokenType? + + /// An array of authorization details associated with the token. Optional. + public let authorizationDetails: [AuthorizationDetail]? + + /// The intended audience(s) for the token (the `aud` claim). Optional. + public let audience: Audience? + + /// The expiration time of the token, in seconds since the Unix epoch (the `exp` claim). + /// Optional. + public let expiresAt: Int? + + /// The time the token was issued, in seconds since the Unix epoch (the `iat` claim). + /// Optional. + public let issuedAt: Int? + + /// The issuer of the token (the `iss` claim). Optional. + public let issuer: String? + + /// The unique identifier of the token (the `jti` claim). Optional. + public let jwtID: String? + + /// The time before which the token must not be accepted, in seconds since the Unix epoch + /// (the `nbf` claim). Optional. + public let notBefore: Int? + + /// The subject of the token (the `sub` claim). Optional. + public let subject: String? + + /// Creates the metadata of an active token. + /// + /// - Parameters: + /// - scope: The scope associated with the token. + /// - clientID: The client identifier the token was issued to. + /// - username: A human-readable identifier for the resource owner. + /// - tokenType: The type of the token. + /// - authorizationDetails: The authorization details associated with the token. + /// - audience: The intended audience(s) for the token. + /// - expiresAt: The expiration time, in seconds since the Unix epoch. + /// - issuedAt: The issued-at time, in seconds since the Unix epoch. + /// - issuer: The issuer of the token. + /// - jwtID: The unique identifier of the token. + /// - notBefore: The not-before time, in seconds since the Unix epoch. + /// - subject: The subject of the token. + public init( + scope: String? = nil, + clientID: String? = nil, + username: String? = nil, + tokenType: OAuthTokenType? = nil, + authorizationDetails: [AuthorizationDetail]? = nil, + audience: Audience? = nil, + expiresAt: Int? = nil, + issuedAt: Int? = nil, + issuer: String? = nil, + jwtID: String? = nil, + notBefore: Int? = nil, + subject: String? = nil + ) { + self.scope = scope + self.clientID = clientID + self.username = username + self.tokenType = tokenType + self.authorizationDetails = authorizationDetails + self.audience = audience + self.expiresAt = expiresAt + self.issuedAt = issuedAt + self.issuer = issuer + self.jwtID = jwtID + self.notBefore = notBefore + self.subject = subject + } + + /// The keys used to encode and decode active token metadata. + enum CodingKeys: String, CodingKey { + + /// The `scope` wire key. + case scope + + /// The `client_id` wire key. + case clientID = "client_id" + + /// The `username` wire key. + case username + + /// The `token_type` wire key. + case tokenType = "token_type" + + /// The `authorization_details` wire key. + case authorizationDetails = "authorization_details" + + /// The `aud` wire key. + case audience = "aud" + + /// The `exp` wire key. + case expiresAt = "exp" + + /// The `iat` wire key. + case issuedAt = "iat" + + /// The `iss` wire key. + case issuer = "iss" + + /// The `jti` wire key. + case jwtID = "jti" + + /// The `nbf` wire key. + case notBefore = "nbf" + + /// The `sub` wire key. + case subject = "sub" + } + } + + /// The intended audience of a token. + /// + /// The `aud` claim may be a single string or a non-empty array of strings, so this models both. + public enum Audience: Codable, Sendable { + + /// A single audience value. + case single(String) + + /// Multiple audience values. Never empty. + case multiple([String]) + + /// Creates a new instance by decoding from the given decoder. + /// + /// - Parameter decoder: The decoder to read data from. + /// - Throws: A `DecodingError` if the value is neither a string nor a non-empty array of + /// strings. + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(String.self) { + self = .single(value) + } else if let values = try? container.decode([String].self) { + // The `aud` claim's array form is a non-empty tuple (`[string, ...string[]]`), so an + // empty array is not a valid audience. + guard !values.isEmpty else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "The \"aud\" claim must not be an empty array." + ) + } + self = .multiple(values) + } else { + throw DecodingError.typeMismatch( + Audience.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected a string or an array of strings for \"aud\"." + ) + ) + } + } + + /// Encodes this value into the given encoder. + /// + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + } + } + } + + /// The key used to read the `active` discriminator. + private enum DiscriminatorKeys: String, CodingKey { + + /// The `active` wire key. + case active + } + + /// Creates a new instance by decoding from the given decoder. + /// + /// - Parameter decoder: The decoder to read data from. + /// - Throws: A `DecodingError` if the `active` field is missing or the active metadata is + /// invalid. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminatorKeys.self) + let isActive = try container.decode(Bool.self, forKey: .active) + + if isActive { + self = .active(try Active(from: decoder)) + } else { + self = .inactive + } + } + + /// Encodes this value into the given encoder. + /// + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: DiscriminatorKeys.self) + + switch self { + case .inactive: + try container.encode(false, forKey: .active) + case .active(let active): + try container.encode(true, forKey: .active) + try active.encode(to: encoder) + } + } +} diff --git a/Sources/OAuthTypes/OAuthIssuerIdentifier.swift b/Sources/OAuthTypes/OAuthIssuerIdentifier.swift index 0a0197e..15dbe15 100644 --- a/Sources/OAuthTypes/OAuthIssuerIdentifier.swift +++ b/Sources/OAuthTypes/OAuthIssuerIdentifier.swift @@ -8,7 +8,7 @@ import Foundation /// A structure representing the issuer identifier for OAuth 2.0. -public struct IssuerIdentifier: Codable, CustomStringConvertible { +public struct IssuerIdentifier: Codable, CustomStringConvertible, Sendable { public let rawValue: String @@ -29,32 +29,55 @@ 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 } + + // The rebuild above uses `urlComponents.path`, which keeps dot segments in place, so a + // non-canonical path such as "/a/../b" would slip through. The WHATWG parser resolves dot + // segments when it builds `url.pathname`, so compare the raw path against its canonical form, + // the same hardening the discoverable client-ID check uses. An empty path (an origin-only + // issuer) has nothing to resolve and is left to the checks above. + if let rawPath = extractURLPath(from: rawValue), !rawPath.isEmpty { + guard rawPath == canonicalizedURLPath(rawPath) else { + throw OAuthIssuerIdentifierError.notInCanonicalForm + } + } } public init(from decoder: any Decoder) throws { diff --git a/Sources/OAuthTypes/OAuthPARResponse.swift b/Sources/OAuthTypes/OAuthPARResponse.swift index 926ffc5..9a94b3a 100644 --- a/Sources/OAuthTypes/OAuthPARResponse.swift +++ b/Sources/OAuthTypes/OAuthPARResponse.swift @@ -6,32 +6,44 @@ // /// -public struct OAuthPARResponse: Codable { +public struct OAuthPARResponse: Codable, Sendable { /// 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 } } diff --git a/Sources/OAuthTypes/OAuthPasswordGrantTokenRequest.swift b/Sources/OAuthTypes/OAuthPasswordGrantTokenRequest.swift index 005168b..2e9c3f5 100644 --- a/Sources/OAuthTypes/OAuthPasswordGrantTokenRequest.swift +++ b/Sources/OAuthTypes/OAuthPasswordGrantTokenRequest.swift @@ -6,7 +6,7 @@ // /// A structure representing a password grant token request for OAuth 2.0. -public struct OAuthPasswordGrantTokenRequest: Codable { +public struct OAuthPasswordGrantTokenRequest: Codable, Sendable { /// The type of token request. /// diff --git a/Sources/OAuthTypes/OAuthProtectedResourceMetadata.swift b/Sources/OAuthTypes/OAuthProtectedResourceMetadata.swift index b14a850..963d95e 100644 --- a/Sources/OAuthTypes/OAuthProtectedResourceMetadata.swift +++ b/Sources/OAuthTypes/OAuthProtectedResourceMetadata.swift @@ -8,7 +8,7 @@ /// A structure representing metadata describing a protected OAuth 2.0 resource. /// /// - SeeAlso: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#name-protected-resource-metadata-r -public struct ProtectedResourceMetadata: Codable { +public struct ProtectedResourceMetadata: Codable, Sendable { /// The resource's unique identifier. /// @@ -54,7 +54,6 @@ public struct ProtectedResourceMetadata: Codable { resourcePolicyURI: URI.WebURI? = nil, resourceTermsOfServiceURI: URI.WebURI? = nil ) throws { -#if !DEBUG if String(describing: resource).contains("?") { throw OAuthProtectedResourceMetadataError.containsQuery } @@ -62,7 +61,7 @@ public struct ProtectedResourceMetadata: Codable { if String(describing: resource).contains("#") { throw OAuthProtectedResourceMetadataError.containsFragment } -#endif + self.resource = resource self.authorizationServers = authorizationServers self.jwksURI = jwksURI @@ -78,7 +77,7 @@ 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 } @@ -86,7 +85,7 @@ public struct ProtectedResourceMetadata: Codable { 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) @@ -125,7 +124,7 @@ public struct ProtectedResourceMetadata: Codable { } /// Supported methods for sending a Bearer token to the resource. - public enum BearerMethodsSupported: String, Codable { + public enum BearerMethodsSupported: String, Codable, Sendable { /// Send the Bearer token in the `Authorization` HTTP header. case header diff --git a/Sources/OAuthTypes/OAuthRedirectURI.swift b/Sources/OAuthTypes/OAuthRedirectURI.swift index e60eb6c..baf28cf 100644 --- a/Sources/OAuthTypes/OAuthRedirectURI.swift +++ b/Sources/OAuthTypes/OAuthRedirectURI.swift @@ -6,7 +6,7 @@ // /// A structure representing a loopback redirect URI. -public struct OAuthLoopbackRedirectURI: Codable, CustomStringConvertible { +public struct OAuthLoopbackRedirectURI: Codable, CustomStringConvertible, Sendable { public let rawValue: String public var description: String { @@ -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 } @@ -39,7 +44,7 @@ public struct OAuthLoopbackRedirectURI: Codable, CustomStringConvertible { } /// An enumeration that defines the possible types of OAuth redirect URIs. -public enum OAuthRedirectURI: Codable { +public enum OAuthRedirectURI: Codable, Sendable { /// A loopback redirect URI using the `http://localhost` scheme. case oauthLoopbackRedirectURI(OAuthLoopbackRedirectURI) @@ -47,6 +52,10 @@ public enum OAuthRedirectURI: Codable { /// A secure HTTPS loopback redirect URI. case oauthHTTPSRedirectURI(URI.LoopbackRedirectURI) + /// An `https:` redirect URI pointing at a public web address, validated by + /// ``URI/validateHTTPSURI(uriString:)``. + case oauthWebRedirectURI(URI.WebURI) + /// A private-use URI scheme (custom protocol handler). case oauthPrivateUseRedirectURI(URI.PrivateUseURI) @@ -58,6 +67,8 @@ public enum OAuthRedirectURI: Codable { self = .oauthLoopbackRedirectURI(value) } else if let value = try? URI.LoopbackRedirectURI(validating: stringValue) { self = .oauthHTTPSRedirectURI(value) + } else if stringValue.hasPrefix("https://"), let value = try? URI.WebURI(validating: stringValue) { + self = .oauthWebRedirectURI(value) } else if let value = try? URI.PrivateUseURI(validating: stringValue) { self = .oauthPrivateUseRedirectURI(value) } else { @@ -72,6 +83,8 @@ public enum OAuthRedirectURI: Codable { try container.encode(value.rawValue) case .oauthHTTPSRedirectURI(let value): try container.encode(value.rawValue) + case .oauthWebRedirectURI(let value): + try container.encode(value.rawValue) case .oauthPrivateUseRedirectURI(let value): try container.encode(value.rawValue) } diff --git a/Sources/OAuthTypes/OAuthRefreshToken.swift b/Sources/OAuthTypes/OAuthRefreshToken.swift index 5fd1f90..4653733 100644 --- a/Sources/OAuthTypes/OAuthRefreshToken.swift +++ b/Sources/OAuthTypes/OAuthRefreshToken.swift @@ -6,7 +6,7 @@ // /// A structure representing refresh tokens in OAuth. -public struct OAuthRefreshToken: Codable, CustomStringConvertible { +public struct OAuthRefreshToken: Codable, CustomStringConvertible, Sendable { private let rawValue: String public var description: String { diff --git a/Sources/OAuthTypes/OAuthRefreshTokenGrantTokenRequest.swift b/Sources/OAuthTypes/OAuthRefreshTokenGrantTokenRequest.swift index 25b591b..4c1f370 100644 --- a/Sources/OAuthTypes/OAuthRefreshTokenGrantTokenRequest.swift +++ b/Sources/OAuthTypes/OAuthRefreshTokenGrantTokenRequest.swift @@ -6,7 +6,7 @@ // /// A structure representing a refresh token grant request. -public struct RefreshTokenGrantTokenRequest: Codable { +public struct RefreshTokenGrantTokenRequest: Codable, Sendable { /// The grant type. /// diff --git a/Sources/OAuthTypes/OAuthRequestURI.swift b/Sources/OAuthTypes/OAuthRequestURI.swift index acb32aa..0a6bec3 100644 --- a/Sources/OAuthTypes/OAuthRequestURI.swift +++ b/Sources/OAuthTypes/OAuthRequestURI.swift @@ -6,7 +6,7 @@ // /// A structure representing an OAuth request URI. -public struct OAuthRequestURI: Codable, CustomStringConvertible { +public struct OAuthRequestURI: Codable, CustomStringConvertible, Sendable { public let rawValue: String diff --git a/Sources/OAuthTypes/OAuthResponseMode.swift b/Sources/OAuthTypes/OAuthResponseMode.swift index b69edbc..6569b84 100644 --- a/Sources/OAuthTypes/OAuthResponseMode.swift +++ b/Sources/OAuthTypes/OAuthResponseMode.swift @@ -7,7 +7,7 @@ /// An enumeration representing the method used to return OAuth authorization responses to /// the client. -public enum OAuthResponseMode: String, Codable { +public enum OAuthResponseMode: String, Codable, Sendable { /// The response parameters are encoded in the query string of the redirect URI. case query diff --git a/Sources/OAuthTypes/OAuthResponseType.swift b/Sources/OAuthTypes/OAuthResponseType.swift index 05a7fd7..361cf7d 100644 --- a/Sources/OAuthTypes/OAuthResponseType.swift +++ b/Sources/OAuthTypes/OAuthResponseType.swift @@ -10,7 +10,7 @@ /// - SeeAlso:\ /// \* OAuth2: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-10#section-4.1.1\ /// \* OpenID Connect: https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html -public enum OAuthResponseType: String, Codable { +public enum OAuthResponseType: String, Codable, Sendable { // OAuth2 response types /// An authorization code to be exchanged for tokens. diff --git a/Sources/OAuthTypes/OAuthScope.swift b/Sources/OAuthTypes/OAuthScope.swift index c0f8874..990c743 100644 --- a/Sources/OAuthTypes/OAuthScope.swift +++ b/Sources/OAuthTypes/OAuthScope.swift @@ -7,12 +7,12 @@ 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 /// pattern, making it easier to check user input or API responses when working with OAuth. -public struct OAuthScope: Codable, CustomStringConvertible { +public struct OAuthScope: Codable, CustomStringConvertible, Sendable { public let rawValue: String @@ -33,7 +33,7 @@ public struct OAuthScope: Codable, CustomStringConvertible { let regex = try NSRegularExpression(pattern: pattern) let range = NSRange(rawValue.startIndex.. Bool { + // `init?(validating:)` is failable and throwing, but it only throws if the fixed regular + // expression fails to compile, which never happens. `try?` collapses both the thrown and the + // `nil` outcomes into a single optional. + return (try? OAuthScope(validating: input)) != nil + } +} diff --git a/Sources/OAuthTypes/OAuthTokenIdentification.swift b/Sources/OAuthTypes/OAuthTokenIdentification.swift index 799e62e..8b6dba3 100644 --- a/Sources/OAuthTypes/OAuthTokenIdentification.swift +++ b/Sources/OAuthTypes/OAuthTokenIdentification.swift @@ -7,16 +7,21 @@ /// An enumeration represents a request to identify an OAuth token for introspection or /// revocation purposes. -public struct TokenIdentification: Codable { +public struct TokenIdentification: Codable, Sendable { /// 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 { + public enum Token: Codable, Sendable { /// An OAuth access token. case accessToken(OAuthAccessToken) @@ -51,7 +56,7 @@ public struct TokenIdentification: Codable { } /// A representation of valid hints for token type when identifying an OAuth token. - public enum TokenTypeHint: String, Codable { + public enum TokenTypeHint: String, Codable, Sendable { /// Indicates that the provided token is an access token. case accessToken = "access_token" diff --git a/Sources/OAuthTypes/OAuthTokenRequest.swift b/Sources/OAuthTypes/OAuthTokenRequest.swift index 16c1f0e..f3e0443 100644 --- a/Sources/OAuthTypes/OAuthTokenRequest.swift +++ b/Sources/OAuthTypes/OAuthTokenRequest.swift @@ -6,7 +6,7 @@ // /// An enumeration representing all supported OAuth 2.0 token request types. -public enum TokenRequest: Codable { +public enum TokenRequest: Codable, Sendable { /// Represents an OAuth 2.0 Authorization Code Grant token request. case oauthAuthorizationCodeGrantTokenRequest(AuthorizationCodeGrantTokenRequest) diff --git a/Sources/OAuthTypes/OAuthTokenResponse.swift b/Sources/OAuthTypes/OAuthTokenResponse.swift index 606b38e..aec6595 100644 --- a/Sources/OAuthTypes/OAuthTokenResponse.swift +++ b/Sources/OAuthTypes/OAuthTokenResponse.swift @@ -8,7 +8,7 @@ import Foundation /// A structure representing a token response. -public struct TokenResponse: Codable { +public struct TokenResponse: Codable, Sendable { /// The access token of the response. public let accessToken: String @@ -22,14 +22,14 @@ 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? - /// Additional authorization details associated with the token response. Optional. - public let authorizationDetails: AuthorizationDetail? + /// An array of additional authorization details associated with the token response. Optional. + public let authorizationDetails: [AuthorizationDetail]? enum CodingKeys: String, CodingKey { case accessToken = "access_token" diff --git a/Sources/OAuthTypes/OAuthTokenType.swift b/Sources/OAuthTypes/OAuthTokenType.swift index 2213950..a0a0864 100644 --- a/Sources/OAuthTypes/OAuthTokenType.swift +++ b/Sources/OAuthTypes/OAuthTokenType.swift @@ -8,7 +8,7 @@ /// An enumeration of supported OAuth token types. /// /// This enum provides a case-insensitive input with a normalized output. -public enum OAuthTokenType: String, Codable, CaseIterable { +public enum OAuthTokenType: String, Codable, CaseIterable, Sendable { /// Demonstrating Proof-of-Possession token type. case dpop = "DPoP" @@ -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) + } } diff --git a/Sources/OAuthTypes/OAuthTypesLabsErrors.swift b/Sources/OAuthTypes/OAuthTypesLabsErrors.swift index 55aeb5d..11f6085 100644 --- a/Sources/OAuthTypes/OAuthTypesLabsErrors.swift +++ b/Sources/OAuthTypes/OAuthTypesLabsErrors.swift @@ -8,7 +8,7 @@ import Foundation /// Errors that can occur with respect to URIs. -public enum OAuthTypesLabsURIError: Error, LocalizedError, CustomStringConvertible { +public enum OAuthTypesLabsURIError: Error, LocalizedError, CustomStringConvertible, Sendable { /// The URL/URI lacks the "http" protocol. case noHTTPProtocol @@ -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 @@ -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'." @@ -78,7 +78,7 @@ public enum OAuthTypesLabsURIError: Error, LocalizedError, CustomStringConvertib /// Errors that can occur with respect to authorization responses. /// /// - SeeAlso: https://openid.net/specs/openid-connect-core-1_0.html#AuthError -public enum OAuthTypesLabsAuthorizationResponseError: Error, LocalizedError, CustomStringConvertible { +public enum OAuthTypesLabsAuthorizationResponseError: Error, LocalizedError, CustomStringConvertible, Sendable { /// The authorization server needs user interaction to proceed. /// @@ -151,7 +151,7 @@ public enum OAuthTypesLabsAuthorizationResponseError: Error, LocalizedError, Cus /// Errors that can occur with respect to authorization response. /// /// - SeeAlso: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#name-error-response-2 -public enum OAuthAuthorizationResponseError: Error, LocalizedError, CustomStringConvertible { +public enum OAuthAuthorizationResponseError: Error, LocalizedError, CustomStringConvertible, Sendable { /// The request is invalid due to missing or incorrect parameters. case invalidRequest @@ -203,7 +203,7 @@ public enum OAuthAuthorizationResponseError: Error, LocalizedError, CustomString } /// Errors that can occur with respect to redirect URIs. -public enum OAuthRedirectURIError: Error, LocalizedError, CustomStringConvertible { +public enum OAuthRedirectURIError: Error, LocalizedError, CustomStringConvertible, Sendable { /// Use of `localhost` hostname has been detected in the URI. case localhostDetected @@ -226,7 +226,7 @@ public enum OAuthRedirectURIError: Error, LocalizedError, CustomStringConvertibl } /// Errors that can occur with related to ``ClientIDLoop``. -public enum OAuthClientIDLoopbackError: Error, LocalizedError, CustomStringConvertible { +public enum OAuthClientIDLoopbackError: Error, LocalizedError, CustomStringConvertible, Sendable { /// The client ID is invalid. /// @@ -268,10 +268,10 @@ public enum OAuthClientIDLoopbackError: Error, LocalizedError, CustomStringConve } /// Errors that can occur with respect to redirect URIs. -public enum OAuthIssuerIdentifierError: Error, LocalizedError, CustomStringConvertible { +public enum OAuthIssuerIdentifierError: Error, LocalizedError, CustomStringConvertible, Sendable { /// Issuer URL contained a slash (`/`) at the end. - case issurURLEndsWithSlash + case issuerURLEndsWithSlash /// The URL provided was invalid. case invalidURL @@ -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." @@ -306,7 +306,7 @@ public enum OAuthIssuerIdentifierError: Error, LocalizedError, CustomStringConve } /// Errors that can occur with respect to authorization server metadata. -public enum OAuthAuthorizationServerMetadataError: Error, CustomStringConvertible { +public enum OAuthAuthorizationServerMetadataError: Error, CustomStringConvertible, Sendable { /// `pushedAuthorizationRequestEndpoint` is required when /// `arePushedAuthorizationRequestsRequired`" is `true`. @@ -335,10 +335,10 @@ public enum OAuthAuthorizationServerMetadataError: Error, CustomStringConvertibl } /// Errors that can occur with respect to discoverable client IDs. -public enum OAuthClientIDDiscoverableError: Error, LocalizedError, CustomStringConvertible { +public enum OAuthClientIDDiscoverableError: Error, LocalizedError, CustomStringConvertible, Sendable { /// 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 @@ -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." @@ -406,7 +406,7 @@ public enum OAuthClientIDDiscoverableError: Error, LocalizedError, CustomStringC } /// Errors that can occur with respect to protected resource metadata. -public enum OAuthProtectedResourceMetadataError: Error, LocalizedError, CustomStringConvertible { +public enum OAuthProtectedResourceMetadataError: Error, LocalizedError, CustomStringConvertible, Sendable { /// The resource Web URI contains at least one query component. case containsQuery @@ -429,7 +429,7 @@ public enum OAuthProtectedResourceMetadataError: Error, LocalizedError, CustomSt } /// Errors that can occur with respect to validations. -public enum ValidationError: Error, LocalizedError, CustomStringConvertible { +public enum ValidationError: Error, LocalizedError, CustomStringConvertible, Sendable { /// The value found was incorrect. /// diff --git a/Sources/OAuthTypes/OIDCClaimsParameter.swift b/Sources/OAuthTypes/OIDCClaimsParameter.swift index 69736ed..8403d2e 100644 --- a/Sources/OAuthTypes/OIDCClaimsParameter.swift +++ b/Sources/OAuthTypes/OIDCClaimsParameter.swift @@ -6,7 +6,7 @@ // /// The client metadata for each OpenID Connect claim. -public enum OpenIDConnectClaimsParameter: String, CaseIterable, Codable { +public enum OpenIDConnectClaimsParameter: String, CaseIterable, Codable, Sendable { // https://openid.net/specs/openid-provider-authentication-policy-extension-1_0.html#rfc.section.5.2 /// The date and time when the user last authenticated (as a Unix timestamp). @@ -25,7 +25,7 @@ public enum OpenIDConnectClaimsParameter: String, CaseIterable, Codable { /// This lets clients enforce minimum assurance levels case authenticationContextClassReference = "acr" - // Profile-specifc + // Profile-specific /// The full name of the user. case name @@ -36,7 +36,7 @@ public enum OpenIDConnectClaimsParameter: String, CaseIterable, Codable { /// The given name of the user. /// - /// Otehrwise known as the "first name." + /// Otherwise known as the "first name." case givenName = "given_name" /// The middle name of the user. @@ -66,7 +66,7 @@ public enum OpenIDConnectClaimsParameter: String, CaseIterable, Codable { /// The time zone of the user. /// /// This would typically be in the IANA format. - case timeZone = "zoneInfo" + case timeZone = "zoneinfo" /// The user's preferred language. case locale diff --git a/Sources/OAuthTypes/OIDCEntityType.swift b/Sources/OAuthTypes/OIDCEntityType.swift index bb1f2f6..1e760be 100644 --- a/Sources/OAuthTypes/OIDCEntityType.swift +++ b/Sources/OAuthTypes/OIDCEntityType.swift @@ -10,7 +10,7 @@ /// When processing OpenID Connect data, it helps to distinguish a `userinfo` object (a plain /// JSON profile) from an `id_token` (JWT token). This distinction helps to apply the correct /// validation, decoding, and trust rules. -public enum OpenIDConnectEntityType: String, Codable { +public enum OpenIDConnectEntityType: String, Codable, Sendable { /// A user profile object. case userInfo = "userinfo" diff --git a/Sources/OAuthTypes/OIDCUserInfo.swift b/Sources/OAuthTypes/OIDCUserInfo.swift index d4dfa74..878c226 100644 --- a/Sources/OAuthTypes/OIDCUserInfo.swift +++ b/Sources/OAuthTypes/OIDCUserInfo.swift @@ -8,7 +8,7 @@ import Foundation /// A structure representing the user information for OpenID Connect. -public struct OpenIDConnectUserInfo: Codable { +public struct OpenIDConnectUserInfo: Codable, Sendable { /// The end user within the identity provider (the `sub` claim). /// diff --git a/Sources/OAuthTypes/OpenIDClaimsProperties.swift b/Sources/OAuthTypes/OpenIDClaimsProperties.swift index c2c876c..ba10b41 100644 --- a/Sources/OAuthTypes/OpenIDClaimsProperties.swift +++ b/Sources/OAuthTypes/OpenIDClaimsProperties.swift @@ -9,7 +9,7 @@ /// /// Use this struct to define additional requirements or constraints for a specific claim, /// such as whether the claim is essential, or acceptable value(s) for the claim. -public struct OpenIDConnectClaimsProperties: Codable { +public struct OpenIDConnectClaimsProperties: Codable, Sendable { /// Determines whether the claim is essential or not. public let isEssential: Bool? @@ -37,7 +37,7 @@ public struct OpenIDConnectClaimsProperties: Codable { /// This enum models the potential types an OpenID Connect claim value can take. /// The value may be a string, a number (floating-point), or a boolean. /// Used for claims that allow multiple possible values or types. -public enum OpenIDConnectClaimsValue: Codable { +public enum OpenIDConnectClaimsValue: Codable, Sendable { /// A "string" value. case string(String) diff --git a/Sources/OAuthTypes/SpaceSeparatedValue.swift b/Sources/OAuthTypes/SpaceSeparatedValue.swift new file mode 100644 index 0000000..de5a33d --- /dev/null +++ b/Sources/OAuthTypes/SpaceSeparatedValue.swift @@ -0,0 +1,26 @@ +// +// SpaceSeparatedValue.swift +// ATOAuthKit +// +// Created by systemBlue on 2026-06-16. +// + +/// Determines whether a value appears as a whole, space-separated token within a string. +/// +/// This mirrors the `@atproto/oauth-types` `isSpaceSeparatedValue` helper: it returns `true` only +/// when `value` occurs in `input` bounded by the start of the string or a space on its left and the +/// end of the string or a space on its right. So `"atproto"` matches `"atproto transition:generic"` +/// but not `"atprotos"`. +/// +/// - Parameters: +/// - value: The token to look for. A value that is empty or that contains a space can never be a +/// single space-separated token, so it returns `false`. +/// - input: The space-separated string to search. +/// - Returns: `true` if `value` is one of the space-separated tokens in `input`, or `false` if not. +public func isSpaceSeparatedValue(_ value: String, in input: String) -> Bool { + guard !value.isEmpty, !value.contains(" ") else { return false } + + // Padding both sides lets a single "contains" check cover the start-, middle-, and end-of-string + // cases at once: a real token is always surrounded by the padded spaces or interior spaces. + return " \(input) ".contains(" \(value) ") +} diff --git a/Sources/OAuthTypes/URI.swift b/Sources/OAuthTypes/URI.swift index cb33354..da56352 100644 --- a/Sources/OAuthTypes/URI.swift +++ b/Sources/OAuthTypes/URI.swift @@ -20,7 +20,7 @@ public enum URI { } /// A structure representing a potentially dangerous URI. - public struct DangerousURI: CustomStringConvertible { + public struct DangerousURI: CustomStringConvertible, Sendable { public let rawValue: String @@ -47,7 +47,7 @@ public enum URI { } /// A structure representing a loopback redirect URI. - public struct LoopbackRedirectURI: Codable, CustomStringConvertible { + public struct LoopbackRedirectURI: Codable, CustomStringConvertible, Sendable { public let rawValue: String public var description: String { @@ -64,7 +64,11 @@ public enum URI { throw OAuthTypesLabsURIError.noHTTPProtocol } - guard isLoopbackHost(rawValue) else { + guard let parsedHost = URLComponents(string: rawValue)?.host, !parsedHost.isEmpty else { + throw OAuthTypesLabsURIError.invalidLoopbackURI + } + + guard isLoopbackHost(parsedHost) else { throw OAuthTypesLabsURIError.invalidLoopbackURI } @@ -85,27 +89,37 @@ public enum URI { throw OAuthTypesLabsURIError.noHTTPSProtocol } - guard isLoopbackHost(uriString) else { - throw OAuthTypesLabsURIError.invalidLoopbackURI + guard let parsedHost = URLComponents(string: uriString)?.host, !parsedHost.isEmpty else { + throw OAuthTypesLabsURIError.noURIHostname } - guard let uriHost = URL(string: uriString)?.host() else { - throw OAuthTypesLabsURIError.noURIHostname + // The WHATWG URL parser lowercases the host, so the loopback, IP, and ".local" comparisons + // below must run against a lowercased host. URLComponents leaves the host's case untouched, + // so without this a host like "LOCALHOST" or "printer.LOCAL" would slip past those checks. + let loweredHost = parsedHost.lowercased() + + // Re-bracket an unbracketed IPv6 hostname so it matches the form the comparison helpers expect. + let uriHost = loweredHost.contains(":") && !loweredHost.hasPrefix("[") ? "[\(loweredHost)]" : loweredHost + + // Disallow loopback URLs with the "https:" protocol. + guard !isLoopbackHost(uriHost) else { + throw OAuthTypesLabsURIError.invalidLoopbackURI } - if !isHostnameIPAddress(uriString) { + if !isHostnameIPAddress(uriHost) { + // The hostname is a domain name. if !uriHost.contains(".") { - throw OAuthTypesLabsURIError.lessThanTwoSegementsInURI + throw OAuthTypesLabsURIError.lessThanTwoSegmentsInURI } - if uriHost.contains(".local") { - throw OAuthTypesLabsURIError.noURIHostname + if uriHost.hasSuffix(".local") { + throw OAuthTypesLabsURIError.endsInLocal } } } /// A structure representing a Web URI. - public struct WebURI: CustomStringConvertible, Codable { + public struct WebURI: CustomStringConvertible, Codable, Sendable { public let rawValue: String @@ -146,7 +160,7 @@ public enum URI { } /// - public struct PrivateUseURI: Codable, CustomStringConvertible { + public struct PrivateUseURI: Codable, CustomStringConvertible, Sendable { public let rawValue: String public var description: String { diff --git a/Sources/OAuthTypes/URLPath.swift b/Sources/OAuthTypes/URLPath.swift new file mode 100644 index 0000000..89da0f6 --- /dev/null +++ b/Sources/OAuthTypes/URLPath.swift @@ -0,0 +1,95 @@ +// +// URLPath.swift +// ATOAuthKit +// +// Created by systemBlue on 2026-06-16. +// + +import Foundation + +/// Extracts the raw, un-normalized path from an `http`/`https` URL string. +/// +/// Unlike `URL` or `URLComponents`, this does not normalize or percent-decode the value, so the +/// returned path still contains any dot segments and percent-encoding exactly as written. That +/// makes it suitable for canonical-form checks, where the raw path is compared against a normalized +/// one. This mirrors the `@atproto/oauth-types` `extractUrlPath` helper. +/// +/// - Parameter url: The URL string. It is expected to use the `http://` or `https://` protocol and +/// to contain a host. +/// - Returns: The raw path substring, or `nil` if the string does not use an `http`/`https` +/// protocol or has no host. +public func extractURLPath(from url: String) -> String? { + let schemeLength: Int + if url.hasPrefix("https://") { + schemeLength = 8 + } else if url.hasPrefix("http://") { + schemeLength = 7 + } else { + return nil + } + + // Safe to offset past the scheme: `hasPrefix` guarantees those ASCII characters exist. + let afterScheme = url.index(url.startIndex, offsetBy: schemeLength) + + // The path ends at the first "?" or "#", whichever comes first; otherwise at the end. + let pathEnd = url[afterScheme...].firstIndex { $0 == "?" || $0 == "#" } ?? url.endIndex + + // The path begins at the first "/" in the authority/path region. + let pathStart = url[afterScheme.. String { + let separatorNormalized = path.replacingOccurrences(of: "\\", with: "/") + let segments = separatorNormalized.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + var output: [String] = [] + + for segment in segments { + let decoded = segment + .replacingOccurrences(of: "%2e", with: ".") + .replacingOccurrences(of: "%2E", with: ".") + + if decoded == "." { + continue + } else if decoded == ".." { + // Pop the previous segment, but never the leading "" that anchors an absolute path. + if output.count > 1 { + output.removeLast() + } + } else { + output.append(segment) + } + } + + let joined = output.joined(separator: "/") + + // A fully-collapsed absolute path (e.g. "/a/..") leaves only the leading anchor, which joins to + // "". The path is always absolute and never ends in a trailing slash (bare authority and + // trailing slashes are rejected upstream), so its canonical form is the root, "/". + return joined.isEmpty ? "/" : joined +} diff --git a/Tests/ATOAuthKitTests/ATProtoDIDTests.swift b/Tests/ATOAuthKitTests/ATProtoDIDTests.swift new file mode 100644 index 0000000..ca5cf99 --- /dev/null +++ b/Tests/ATOAuthKitTests/ATProtoDIDTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +import OAuthTypes + +@Suite("AT Protocol DIDs") +struct ATProtoDIDTests { + + @Test( + "Accepts did:plc and did:web identifiers", + arguments: [ + "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "did:web:example.com", + "did:web:pds.example.com" + ] + ) + func acceptsValidDIDs(_ did: String) throws { + let value = try ATProtoDID(validating: did) + #expect(value.rawValue == did) + #expect(ATProtoDID.isValid(did)) + } + + @Test( + "Rejects did:plc identifiers that are not 24 base32 characters", + arguments: [ + "did:plc:short", + "did:plc:ewvi7nxzyoun6zhxrhs64oi", // 23 characters + "did:plc:ewvi7nxzyoun6zhxrhs64oizz", // 25 characters + "did:plc:EWVI7nxzyoun6zhxrhs64oiz", // uppercase + "did:plc:1bcdefghijklmnopqrstuvwx" // "1" and "8/9/0" are outside base32 + ] + ) + func rejectsInvalidPLC(_ did: String) { + #expect(throws: ATProtoDIDError.self) { + try ATProtoDID(validating: did) + } + } + + @Test( + "Rejects did:web identifiers that are not bare hostnames", + arguments: [ + "did:web:localhost", // no dot + "did:web:example.com/path", // path component + "did:web:.example.com", // leading dot + "did:web:example.com.", // trailing dot + "did:web:example.com:3000", // literal port + "did:web:example.com%3A3000" // percent-encoded port; bare hostnames only + ] + ) + func rejectsInvalidWeb(_ did: String) { + #expect(throws: ATProtoDIDError.self) { + try ATProtoDID(validating: did) + } + } + + @Test( + "Rejects unsupported DID methods", + arguments: [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "did:ion:EiClkZMDxPKqC9c-umQfTkR8" + ] + ) + func rejectsUnsupportedMethod(_ did: String) { + #expect(throws: ATProtoDIDError.self) { + try ATProtoDID(validating: did) + } + } + + @Test( + "Rejects malformed DIDs", + arguments: [ + "notadid", + "did:", + "did:plc", + "did:plc:", + "" + ] + ) + func rejectsMalformed(_ did: String) { + #expect(throws: ATProtoDIDError.self) { + try ATProtoDID(validating: did) + } + } + + @Test("Round-trips through Codable") + func codableRoundTrip() throws { + let did = try ATProtoDID(validating: "did:web:example.com") + let data = try JSONEncoder().encode(did) + #expect(String(data: data, encoding: .utf8) == "\"did:web:example.com\"") + + let decoded = try JSONDecoder().decode(ATProtoDID.self, from: data) + #expect(decoded.rawValue == did.rawValue) + } +} diff --git a/Tests/ATOAuthKitTests/ATProtoLoopbackClientTests.swift b/Tests/ATOAuthKitTests/ATProtoLoopbackClientTests.swift new file mode 100644 index 0000000..273e754 --- /dev/null +++ b/Tests/ATOAuthKitTests/ATProtoLoopbackClientTests.swift @@ -0,0 +1,144 @@ +import Testing +import OAuthTypes + +@Suite("AT Protocol loopback client redirect URIs") +struct ATProtoLoopbackClientRedirectURIsTests { + + @Test("Provides the IPv4-first default loopback redirect URIs") + func defaultRedirectURIs() { + #expect(ATProtoLoopbackClient.defaultRedirectURIs == ["http://127.0.0.1/", "http://[::1]/"]) + } +} + +@Suite("AT Protocol loopback client ID building") +struct ATProtoLoopbackClientBuildTests { + + @Test("Builds the bare origin when there is no configuration") + func buildsBareOrigin() throws { + #expect(try ATProtoLoopbackClient.makeClientID() == "http://localhost") + #expect(try ATProtoLoopbackClient.makeClientID(.init()) == "http://localhost") + } + + @Test("Omits the scope when it is the default atproto scope") + func omitsDefaultScope() throws { + #expect(try ATProtoLoopbackClient.makeClientID(.init(scope: "atproto")) == "http://localhost") + } + + @Test("Omits redirect URIs when they equal the defaults, regardless of order") + func omitsDefaultRedirectURIs() throws { + let reversed = ["http://[::1]/", "http://127.0.0.1/"] + #expect(try ATProtoLoopbackClient.makeClientID(.init(redirectURIs: reversed)) == "http://localhost") + } + + @Test("Encodes a non-default scope that round-trips through parsing") + func encodesNonDefaultScope() throws { + let clientID = try ATProtoLoopbackClient.makeClientID(.init(scope: "atproto transition:generic")) + #expect(clientID.hasPrefix("http://localhost?")) + + let parsed = try ATProtoLoopbackClient.parseClientID(clientID) + #expect(parsed.scope.rawValue == "atproto transition:generic") + } + + @Test("Encodes non-default redirect URIs that round-trip through parsing") + func encodesNonDefaultRedirectURIs() throws { + let clientID = try ATProtoLoopbackClient.makeClientID(.init(redirectURIs: ["http://127.0.0.1:8080/"])) + let parsed = try ATProtoLoopbackClient.parseClientID(clientID) + #expect(parsed.redirectURIs.map(\.rawValue) == ["http://127.0.0.1:8080/"]) + } + + @Test("Rejects a non-default scope that is not an atproto scope") + func rejectsNonATProtoScope() { + #expect(throws: ATProtoLoopbackClientIDError.self) { + try ATProtoLoopbackClient.makeClientID(.init(scope: "transition:generic")) + } + } + + @Test("Rejects an explicitly empty redirect URI list") + func rejectsEmptyRedirectURIs() { + #expect(throws: ATProtoLoopbackClientIDError.self) { + try ATProtoLoopbackClient.makeClientID(.init(redirectURIs: [])) + } + } + + @Test("Rejects an invalid redirect URI") + func rejectsInvalidRedirectURI() { + // "localhost" is not allowed as a loopback redirect host (RFC 8252). + #expect(throws: ATProtoLoopbackClientIDError.self) { + try ATProtoLoopbackClient.makeClientID(.init(redirectURIs: ["http://localhost/"])) + } + } +} + +@Suite("AT Protocol loopback client ID parsing") +struct ATProtoLoopbackClientParseTests { + + @Test( + "Defaults the scope and redirect URIs when absent", + arguments: ["http://localhost", "http://localhost/"] + ) + func defaultsWhenAbsent(_ clientID: String) throws { + let parsed = try ATProtoLoopbackClient.parseClientID(clientID) + #expect(parsed.scope.rawValue == "atproto") + #expect(parsed.redirectURIs.map(\.rawValue) == ["http://127.0.0.1/", "http://[::1]/"]) + } + + @Test("Round-trips the default redirect URIs through build and parse") + func roundTripsDefaultRedirectURIs() throws { + // Building with the defaults collapses to the bare origin, and parsing the bare origin + // restores the defaults, so a consumer that hands the defaults back gets them unchanged. + let clientID = try ATProtoLoopbackClient.makeClientID( + .init(redirectURIs: ATProtoLoopbackClient.defaultRedirectURIs) + ) + let parsed = try ATProtoLoopbackClient.parseClientID(clientID) + #expect(parsed.redirectURIs.map(\.rawValue) == ATProtoLoopbackClient.defaultRedirectURIs) + } + + @Test("Parses an explicit atproto scope") + func parsesExplicitScope() throws { + let parsed = try ATProtoLoopbackClient.parseClientID("http://localhost?scope=atproto%20transition:generic") + #expect(parsed.scope.rawValue == "atproto transition:generic") + } + + @Test("Requires the scope to include the atproto value") + func requiresATProtoScope() { + #expect(throws: ATProtoLoopbackClientIDError.self) { + try ATProtoLoopbackClient.parseClientID("http://localhost?scope=transition:generic") + } + } + + @Test("Rejects an invalid redirect URI instead of silently dropping it") + func rejectsInvalidRedirectURI() { + // The base loopback parser drops invalid redirect URIs; the strict atproto parser rejects. + #expect(throws: ATProtoLoopbackClientIDError.self) { + try ATProtoLoopbackClient.parseClientID("http://localhost?redirect_uri=http://localhost/") + } + } + + @Test("Rejects an unexpected query parameter") + func rejectsUnexpectedParameter() { + #expect(throws: ATProtoLoopbackClientIDError.self) { + try ATProtoLoopbackClient.parseClientID("http://localhost?foo=bar") + } + } + + @Test("Rejects a duplicate scope parameter") + func rejectsDuplicateScope() { + #expect(throws: ATProtoLoopbackClientIDError.self) { + try ATProtoLoopbackClient.parseClientID("http://localhost?scope=atproto&scope=atproto") + } + } + + @Test( + "Rejects structurally invalid client IDs", + arguments: [ + "https://localhost", + "http://localhost/path", + "http://localhost#fragment" + ] + ) + func rejectsStructurallyInvalid(_ clientID: String) { + #expect(throws: ATProtoLoopbackClientIDError.self) { + try ATProtoLoopbackClient.parseClientID(clientID) + } + } +} diff --git a/Tests/ATOAuthKitTests/ATProtoOAuthScopeTests.swift b/Tests/ATOAuthKitTests/ATProtoOAuthScopeTests.swift new file mode 100644 index 0000000..7104ff8 --- /dev/null +++ b/Tests/ATOAuthKitTests/ATProtoOAuthScopeTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +import OAuthTypes + +@Suite("AT Protocol OAuth scopes") +struct ATProtoOAuthScopeTests { + + @Test( + "Accepts OAuth scopes that include the atproto value", + arguments: [ + "atproto", + "atproto transition:generic", + "transition:generic atproto", + "atproto transition:chat.bsky" + ] + ) + func acceptsATProtoScopes(_ raw: String) throws { + let scope = try ATProtoOAuthScope(validating: raw) + #expect(scope.rawValue == raw) + #expect(ATProtoOAuthScope.isValid(raw)) + } + + @Test( + "Rejects valid OAuth scopes that omit the atproto value", + arguments: [ + "transition:generic", + "atprotos", + "atproto2", + "openid profile" + ] + ) + func rejectsMissingATProtoValue(_ raw: String) { + #expect(throws: ATProtoOAuthScopeError.self) { + try ATProtoOAuthScope(validating: raw) + } + #expect(ATProtoOAuthScope.isValid(raw) == false) + } + + @Test("Rejects values that are not valid OAuth scopes") + func rejectsInvalidOAuthScope() { + // A double quote is outside the OAuth scope grammar, so this fails the scope check before + // the atproto check, even though the "atproto" token is present. + do { + _ = try ATProtoOAuthScope(validating: "atproto \"bad") + Issue.record("Expected an error to be thrown.") + } catch ATProtoOAuthScopeError.invalidOAuthScope { + // Expected. + } catch { + Issue.record("Expected .invalidOAuthScope, got \(error)") + } + } + + @Test("Reports a missing atproto value distinctly from an invalid scope") + func reportsMissingValueDistinctly() { + do { + _ = try ATProtoOAuthScope(validating: "transition:generic") + Issue.record("Expected an error to be thrown.") + } catch ATProtoOAuthScopeError.missingATProtoScopeValue { + // Expected. + } catch { + Issue.record("Expected .missingATProtoScopeValue, got \(error)") + } + } + + @Test("Exposes atproto as the default scope") + func defaultScope() { + #expect(ATProtoOAuthScope.default.rawValue == "atproto") + #expect(ATProtoOAuthScope.atprotoScopeValue == "atproto") + } + + @Test("Round-trips through Codable") + func codableRoundTrip() throws { + let scope = try ATProtoOAuthScope(validating: "atproto transition:generic") + let data = try JSONEncoder().encode(scope) + #expect(String(data: data, encoding: .utf8) == "\"atproto transition:generic\"") + + let decoded = try JSONDecoder().decode(ATProtoOAuthScope.self, from: data) + #expect(decoded.rawValue == scope.rawValue) + } + + @Test("Decoding a non-atproto scope fails") + func decodingNonATProtoScopeFails() { + #expect(throws: (any Error).self) { + try JSONDecoder().decode(ATProtoOAuthScope.self, from: Data("\"transition:generic\"".utf8)) + } + } +} diff --git a/Tests/ATOAuthKitTests/ATProtoTokenResponseTests.swift b/Tests/ATOAuthKitTests/ATProtoTokenResponseTests.swift new file mode 100644 index 0000000..4430857 --- /dev/null +++ b/Tests/ATOAuthKitTests/ATProtoTokenResponseTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing +import OAuthTypes + +@Suite("AT Protocol token responses") +struct ATProtoTokenResponseTests { + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + @Test("Decodes a well-formed DPoP token response") + func decodesValid() throws { + let json = Data(""" + { + "access_token": "abc123", + "token_type": "DPoP", + "sub": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "scope": "atproto transition:generic", + "refresh_token": "refresh123", + "expires_in": 3600 + } + """.utf8) + + let response = try decoder.decode(ATProtoTokenResponse.self, from: json) + #expect(response.accessToken == "abc123") + #expect(response.tokenType == .dpop) + #expect(response.subject.rawValue == "did:plc:ewvi7nxzyoun6zhxrhs64oiz") + #expect(response.scope.rawValue == "atproto transition:generic") + #expect(response.refreshToken == "refresh123") + #expect(response.expiresIn == 3600) + } + + @Test("Accepts a case-insensitive DPoP token type") + func acceptsCaseInsensitiveDPoP() throws { + let json = Data(""" + {"access_token":"a","token_type":"dpop","sub":"did:web:example.com","scope":"atproto"} + """.utf8) + + let response = try decoder.decode(ATProtoTokenResponse.self, from: json) + #expect(response.tokenType == .dpop) + } + + @Test("Rejects a Bearer token type") + func rejectsBearer() { + let json = Data(""" + {"access_token":"a","token_type":"Bearer","sub":"did:web:example.com","scope":"atproto"} + """.utf8) + + #expect(throws: (any Error).self) { + try self.decoder.decode(ATProtoTokenResponse.self, from: json) + } + } + + @Test("Rejects a response that contains an id_token") + func rejectsIDToken() { + let json = Data(""" + {"access_token":"a","token_type":"DPoP","sub":"did:web:example.com","scope":"atproto","id_token":"aaa.bbb.ccc"} + """.utf8) + + #expect(throws: (any Error).self) { + try self.decoder.decode(ATProtoTokenResponse.self, from: json) + } + } + + @Test("Rejects a scope that omits the atproto value") + func rejectsNonATProtoScope() { + let json = Data(""" + {"access_token":"a","token_type":"DPoP","sub":"did:web:example.com","scope":"transition:generic"} + """.utf8) + + #expect(throws: (any Error).self) { + try self.decoder.decode(ATProtoTokenResponse.self, from: json) + } + } + + @Test("Rejects a subject that is not an atproto DID") + func rejectsNonDIDSubject() { + let json = Data(""" + {"access_token":"a","token_type":"DPoP","sub":"not-a-did","scope":"atproto"} + """.utf8) + + #expect(throws: (any Error).self) { + try self.decoder.decode(ATProtoTokenResponse.self, from: json) + } + } + + @Test("Requires the subject and scope") + func requiresSubjectAndScope() { + let json = Data(""" + {"access_token":"a","token_type":"DPoP"} + """.utf8) + + #expect(throws: (any Error).self) { + try self.decoder.decode(ATProtoTokenResponse.self, from: json) + } + } + + @Test("Decodes authorization_details as an array") + func decodesAuthorizationDetailsArray() throws { + let json = Data(""" + { + "access_token": "a", + "token_type": "DPoP", + "sub": "did:web:example.com", + "scope": "atproto", + "authorization_details": [{"type": "payment"}, {"type": "account"}] + } + """.utf8) + + let response = try decoder.decode(ATProtoTokenResponse.self, from: json) + #expect(response.authorizationDetails?.map(\.type) == ["payment", "account"]) + } + + @Test("Encodes without an id_token and round-trips") + func encodesAndRoundTrips() throws { + let response = try ATProtoTokenResponse( + accessToken: "abc123", + subject: ATProtoDID(validating: "did:plc:ewvi7nxzyoun6zhxrhs64oiz"), + scope: ATProtoOAuthScope(validating: "atproto"), + expiresIn: 3600 + ) + + let data = try encoder.encode(response) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(object?["token_type"] as? String == "DPoP") + #expect(object?["sub"] as? String == "did:plc:ewvi7nxzyoun6zhxrhs64oiz") + #expect(object?["id_token"] == nil) + + let decoded = try decoder.decode(ATProtoTokenResponse.self, from: data) + #expect(decoded.accessToken == response.accessToken) + #expect(decoded.subject.rawValue == response.subject.rawValue) + #expect(decoded.scope.rawValue == response.scope.rawValue) + } +} diff --git a/Tests/ATOAuthKitTests/AuthorizationRequestQueryTests.swift b/Tests/ATOAuthKitTests/AuthorizationRequestQueryTests.swift new file mode 100644 index 0000000..7bc88bf --- /dev/null +++ b/Tests/ATOAuthKitTests/AuthorizationRequestQueryTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +import OAuthTypes + +@Suite("Authorization request queries") +struct AuthorizationRequestQueryTests { + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + @Test("Decodes the parameters form and exposes its client_id") + func decodesParametersForm() throws { + let json = Data(""" + {"client_id":"https://app.example.com/oauth-client-metadata.json","response_type":"code"} + """.utf8) + + let query = try decoder.decode(AuthorizationRequestQuery.self, from: json) + guard case .authorizationRequestParameters(let parameters) = query else { + Issue.record("Expected the parameters form, got \(query)") + return + } + #expect(parameters.responseType == .authorizationCodeGrant) + #expect(query.clientID.rawValue == "https://app.example.com/oauth-client-metadata.json") + } + + @Test("Decodes the JAR form with a top-level client_id") + func decodesJARForm() throws { + let json = Data(""" + {"client_id":"client-123","request":"aaa.bbb.ccc"} + """.utf8) + + let query = try decoder.decode(AuthorizationRequestQuery.self, from: json) + guard case .authorizationRequestJAR(let clientID, _) = query else { + Issue.record("Expected the JAR form, got \(query)") + return + } + #expect(clientID.rawValue == "client-123") + #expect(query.clientID.rawValue == "client-123") + } + + @Test("Decodes the request-URI form with a top-level client_id") + func decodesURIForm() throws { + let json = Data(""" + {"client_id":"client-123","request_uri":"urn:ietf:params:oauth:request_uri:abc"} + """.utf8) + + let query = try decoder.decode(AuthorizationRequestQuery.self, from: json) + guard case .authorizationRequestURI(let clientID, let uri) = query else { + Issue.record("Expected the request-URI form, got \(query)") + return + } + #expect(clientID.rawValue == "client-123") + #expect(uri.requestURI.rawValue == "urn:ietf:params:oauth:request_uri:abc") + } + + @Test( + "Requires a client_id on every form", + arguments: [ + "{\"response_type\":\"code\"}", + "{\"request\":\"aaa.bbb.ccc\"}", + "{\"request_uri\":\"urn:ietf:params:oauth:request_uri:abc\"}" + ] + ) + func requiresClientID(_ body: String) { + #expect(throws: (any Error).self) { + try self.decoder.decode(AuthorizationRequestQuery.self, from: Data(body.utf8)) + } + } + + @Test("Round-trips the JAR form, keeping client_id at the top level") + func roundTripsJARForm() throws { + let json = Data(""" + {"client_id":"client-123","request":"aaa.bbb.ccc"} + """.utf8) + + let query = try decoder.decode(AuthorizationRequestQuery.self, from: json) + let reencoded = try encoder.encode(query) + let object = try JSONSerialization.jsonObject(with: reencoded) as? [String: Any] + #expect(object?["client_id"] as? String == "client-123") + #expect(object?["request"] as? String == "aaa.bbb.ccc") + } + + @Test("Decodes authorization_details as an array on the parameters form") + func decodesAuthorizationDetailsArray() throws { + // The reference `authorization_details` is `z.array(...)`, so the wire value is always a + // JSON array, even with a single entry. + let json = Data(""" + { + "client_id":"https://app.example.com/oauth-client-metadata.json", + "response_type":"code", + "authorization_details":[ + {"type":"first","actions":["read"]}, + {"type":"second"} + ] + } + """.utf8) + + let query = try decoder.decode(AuthorizationRequestQuery.self, from: json) + guard case .authorizationRequestParameters(let parameters) = query else { + Issue.record("Expected the parameters form, got \(query)") + return + } + #expect(parameters.authorizationDetails?.count == 2) + #expect(parameters.authorizationDetails?.first?.type == "first") + #expect(parameters.authorizationDetails?.first?.actions == ["read"]) + } +} diff --git a/Tests/ATOAuthKitTests/CanonicalFormTests.swift b/Tests/ATOAuthKitTests/CanonicalFormTests.swift new file mode 100644 index 0000000..e0a10e7 --- /dev/null +++ b/Tests/ATOAuthKitTests/CanonicalFormTests.swift @@ -0,0 +1,148 @@ +import Testing +@testable import OAuthTypes + +@Suite("Raw URL path extraction") +struct ExtractURLPathTests { + + @Test( + "Extracts the raw path, stopping at a query or fragment", + arguments: [ + (url: "https://example.com/a/b", path: "/a/b"), + (url: "https://example.com/a/b?x=1", path: "/a/b"), + (url: "https://example.com/a/b#frag", path: "/a/b"), + (url: "https://example.com/a/b?x=1#frag", path: "/a/b"), + (url: "https://example.com/", path: "/"), + (url: "https://example.com", path: ""), + (url: "http://example.com/p", path: "/p"), + (url: "https://example.com:8443/p?q=1", path: "/p") + ] + ) + func extractsRawPath(url: String, path: String) { + #expect(extractURLPath(from: url) == path) + } + + @Test("Does not decode percent-encoded dot segments") + func keepsPercentEncoding() { + #expect(extractURLPath(from: "https://example.com/%2e%2e/a") == "/%2e%2e/a") + } + + @Test( + "Returns nil for a non-http(s) protocol or a missing host", + arguments: [ + "ftp://example.com/p", + "https://", + "mailto:someone@example.com" + ] + ) + func returnsNilForInvalid(_ url: String) { + #expect(extractURLPath(from: url) == nil) + } +} + +@Suite("Canonical-form hardening") +struct CanonicalFormHardeningTests { + + @Test( + "Rejects discoverable client IDs with percent-encoded dot segments", + arguments: [ + "https://example.com/%2e%2e/oauth-client-metadata.json", + "https://example.com/%2E%2E/oauth-client-metadata.json", + "https://example.com/%2e/oauth-client-metadata.json", + "https://example.com/foo/%2e%2e/oauth-client-metadata.json", + "https://example.com/foo/%2E/oauth-client-metadata.json" + ] + ) + func rejectsEncodedDotSegments(_ clientID: String) { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: clientID) + } + #expect(ClientIDDiscoverable.isDiscoverable(clientID: clientID) == false) + } + + @Test( + "Still rejects literal dot segments", + arguments: [ + "https://example.com/a/../oauth-client-metadata.json", + "https://example.com/./oauth-client-metadata.json" + ] + ) + func rejectsLiteralDotSegments(_ clientID: String) { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: clientID) + } + } + + @Test( + "Still accepts canonical discoverable client IDs", + arguments: [ + "https://example.com/oauth-client-metadata.json", + "https://example.com/some/nested/path.json", + "https://example.com:8443/oauth-client-metadata.json" + ] + ) + func acceptsCanonical(_ clientID: String) throws { + #expect(throws: Never.self) { + try ClientIDDiscoverable(validating: clientID) + } + } + + @Test("Folds backslashes to slashes the way the WHATWG parser does for special schemes") + func foldsBackslashes() { + // A backslash-hidden ".." resolves like "/.." would. The raw path keeps the backslash, so + // it differs from this canonical form and is rejected upstream. + #expect(canonicalizedURLPath("/foo\\..\\..\\oauth-client-metadata.json") == "/oauth-client-metadata.json") + #expect(canonicalizedURLPath("/foo\\bar") == "/foo/bar") + } + + @Test( + "Rejects discoverable client IDs that hide dot segments behind backslashes", + arguments: [ + "https://example.com/foo\\..\\..\\oauth-client-metadata.json", + "https://example.com/\\..\\oauth-client-metadata.json" + ] + ) + func rejectsBackslashDotSegments(_ clientID: String) { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: clientID) + } + #expect(ClientIDDiscoverable.isDiscoverable(clientID: clientID) == false) + } + + @Test("Rejects an issuer identifier with percent-encoded dot segments") + func rejectsEncodedDotSegmentIssuer() { + #expect(throws: OAuthIssuerIdentifierError.self) { + try IssuerIdentifier(validating: "https://auth.example.com/%2e%2e") + } + } + + @Test( + "Rejects an issuer identifier with literal dot segments", + arguments: [ + "https://auth.example.com/a/../b", + "https://auth.example.com/./issuer", + "https://auth.example.com/issuer/.." + ] + ) + func rejectsLiteralDotSegmentIssuer(_ issuer: String) { + // The WHATWG parser resolves these in url.pathname, so they are not in canonical form. + #expect(throws: OAuthIssuerIdentifierError.self) { + try IssuerIdentifier(validating: issuer) + } + } + + @Test("Reports a fully-collapsed path's canonical form as \"/\", not an empty string") + func reportsRootForFullyCollapsedPath() { + do { + _ = try ClientIDDiscoverable(validating: "https://example.com/a/..") + Issue.record("Expected the non-canonical path to be rejected") + } catch let error as OAuthClientIDDiscoverableError { + guard case .incorrectCanonicalForm(let expectedValue, _) = error else { + Issue.record("Expected incorrectCanonicalForm, got \(error)") + return + } + #expect(expectedValue == "/") + } catch { + Issue.record("Expected OAuthClientIDDiscoverableError, got \(error)") + } + } +} diff --git a/Tests/ATOAuthKitTests/ClientIdentifierTests.swift b/Tests/ATOAuthKitTests/ClientIdentifierTests.swift new file mode 100644 index 0000000..926cde3 --- /dev/null +++ b/Tests/ATOAuthKitTests/ClientIdentifierTests.swift @@ -0,0 +1,294 @@ +import Testing +import OAuthTypes + +@Suite("Discoverable OAuth client IDs") +struct ClientIDDiscoverableTests { + + @Test( + "Accepts well-formed discoverable client IDs", + arguments: [ + "https://app.example.com/oauth-client-metadata.json", + "https://app.example.com/client-metadata.json", + "https://example.com/some/nested/path.json", + "https://example.com:8443/oauth-client-metadata.json", + "https://example.com/oauth-client-metadata.json?foo=bar" + ] + ) + func acceptsValidDiscoverable(_ clientID: String) throws { + #expect(throws: Never.self) { + try ClientIDDiscoverable(validating: clientID) + } + #expect(ClientIDDiscoverable.isDiscoverable(clientID: clientID)) + } + + @Test( + "Rejects discoverable client IDs that embed credentials", + arguments: [ + "https://user@example.com/oauth-client-metadata.json", + "https://user:pass@example.com/oauth-client-metadata.json", + "https://:pass@example.com/oauth-client-metadata.json" + ] + ) + func rejectsCredentials(_ clientID: String) { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: clientID) + } + #expect(ClientIDDiscoverable.isDiscoverable(clientID: clientID) == false) + } + + @Test("Rejects a discoverable client ID with a fragment") + func rejectsFragment() { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: "https://example.com/oauth-client-metadata.json#section") + } + } + + @Test( + "Rejects discoverable client IDs without a path component", + arguments: [ + "https://example.com/", + "https://example.com" + ] + ) + func rejectsRootPath(_ clientID: String) { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: clientID) + } + } + + @Test("Rejects a discoverable client ID whose host is an IP address") + func rejectsIPHost() { + // A non-loopback IP reaches the discoverable IP-address guard. A loopback IP such as + // 127.0.0.1 is rejected earlier by the https URI layer (matching the TypeScript + // httpsUriSchema, which forbids loopback hosts before the IP-address refinement runs). + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: "https://8.8.8.8/oauth-client-metadata.json") + } + } + + @Test("Rejects a discoverable client ID whose host is a malformed (out-of-range) IP") + func rejectsMalformedIPHost() { + // The reference's loose IPv4 check treats "300.1.2.3" as an IP, so it is barred as a client + // ID host rather than passing as a domain name. + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: "https://300.1.2.3/oauth-client-metadata.json") + } + #expect(ClientIDDiscoverable.isDiscoverable(clientID: "https://300.1.2.3/oauth-client-metadata.json") == false) + } + + @Test("Accepts a non-root path that does not end in a slash") + func acceptsNonRootPathWithoutTrailingSlash() throws { + #expect(throws: Never.self) { + try ClientIDDiscoverable(validating: "https://example.com/foo/oauth-client-metadata.json") + } + } + + @Test("Rejects a non-root path that ends in a trailing slash") + func rejectsNonRootTrailingSlash() { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: "https://example.com/foo/") + } + } + + @Test( + "Rejects a path that is not in canonical form", + arguments: [ + "https://example.com/a/../oauth-client-metadata.json", + "https://example.com/./oauth-client-metadata.json" + ] + ) + func rejectsNonCanonicalPath(_ clientID: String) { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ClientIDDiscoverable(validating: clientID) + } + } +} + +@Suite("Conventional OAuth client IDs") +struct ConventionalOAuthClientIDTests { + + @Test("Accepts the canonical conventional client ID") + func acceptsCanonical() throws { + #expect(throws: Never.self) { + try ConventionalOAuthClientID(validating: "https://app.example.com/oauth-client-metadata.json") + } + } + + @Test("Rejects a conventional client ID that contains a port") + func rejectsPort() { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ConventionalOAuthClientID(validating: "https://app.example.com:8443/oauth-client-metadata.json") + } + } + + @Test("Rejects a conventional client ID that contains a query string") + func rejectsQuery() { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ConventionalOAuthClientID(validating: "https://app.example.com/oauth-client-metadata.json?foo=bar") + } + } + + @Test( + "Rejects a conventional client ID whose path is not /oauth-client-metadata.json", + arguments: [ + "https://app.example.com/client-metadata.json", + "https://app.example.com/other.json" + ] + ) + func rejectsWrongPath(_ clientID: String) { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ConventionalOAuthClientID(validating: clientID) + } + } + + @Test("Rejects a conventional client ID that embeds credentials (discoverable intersection)") + func rejectsCredentials() { + #expect(throws: OAuthClientIDDiscoverableError.self) { + try ConventionalOAuthClientID(validating: "https://user:pass@app.example.com/oauth-client-metadata.json") + } + } +} + +@Suite("OAuth issuer identifiers") +struct IssuerIdentifierTests { + + @Test( + "Accepts canonical issuer identifiers", + arguments: [ + "https://auth.example.com", + "https://auth.example.com/issuer" + ] + ) + func acceptsCanonical(_ issuer: String) throws { + #expect(throws: Never.self) { + try IssuerIdentifier(validating: issuer) + } + } + + @Test("Rejects an issuer identifier that ends with a trailing slash") + func rejectsTrailingSlash() { + #expect(throws: OAuthIssuerIdentifierError.self) { + try IssuerIdentifier(validating: "https://auth.example.com/") + } + } + + @Test( + "Rejects issuer identifiers that embed credentials", + arguments: [ + "https://user@auth.example.com", + "https://user:pass@auth.example.com", + "https://:pass@auth.example.com" + ] + ) + func rejectsCredentials(_ issuer: String) { + #expect(throws: OAuthIssuerIdentifierError.self) { + try IssuerIdentifier(validating: issuer) + } + } + + @Test( + "Rejects issuer identifiers with a query or fragment", + arguments: [ + "https://auth.example.com?foo=bar", + "https://auth.example.com#frag", + "https://auth.example.com/issuer?foo=bar" + ] + ) + func rejectsQueryOrFragment(_ issuer: String) { + #expect(throws: OAuthIssuerIdentifierError.self) { + try IssuerIdentifier(validating: issuer) + } + } + + @Test( + "Rejects issuer identifiers whose host is not lowercased", + arguments: [ + "https://AUTH.EXAMPLE.com", + "https://AUTH.EXAMPLE.com/issuer" + ] + ) + func rejectsMixedCaseHost(_ issuer: String) { + #expect(throws: OAuthIssuerIdentifierError.self) { + try IssuerIdentifier(validating: issuer) + } + } + + @Test( + "Accepts an issuer identifier with a non-default port", + arguments: [ + "https://auth.example.com:8080", + "https://auth.example.com:8080/issuer" + ] + ) + func acceptsNonDefaultPort(_ issuer: String) throws { + #expect(throws: Never.self) { + try IssuerIdentifier(validating: issuer) + } + } + + @Test("Rejects an issuer identifier that spells out the scheme's default port") + func rejectsExplicitDefaultPort() { + #expect(throws: OAuthIssuerIdentifierError.self) { + try IssuerIdentifier(validating: "https://auth.example.com:443") + } + } +} + +@Suite("Loopback OAuth client IDs") +struct ClientIDLoopbackTests { + + @Test( + "Parses loopback client IDs with no query string", + arguments: [ + "http://localhost", + "http://localhost/" + ] + ) + func parsesNoQuery(_ clientID: String) throws { + let result = try ClientIDLoopback.parse(outhLoopbackClientID: clientID) + #expect(result.scope == nil) + #expect(result.redirectURIs == nil) + } + + @Test( + "Parses loopback client IDs with an empty query string", + arguments: [ + "http://localhost?", + "http://localhost/?" + ] + ) + func parsesEmptyQuery(_ clientID: String) throws { + let result = try ClientIDLoopback.parse(outhLoopbackClientID: clientID) + #expect(result.scope == nil) + #expect(result.redirectURIs == nil) + } + + @Test("Parses a loopback client ID after a single slash and query") + func parsesSlashThenQuery() throws { + let result = try ClientIDLoopback.parse(outhLoopbackClientID: "http://localhost/?scope=atproto") + #expect(result.scope != nil) + } + + @Test("Rejects a loopback client ID that contains a path component") + func rejectsPathComponent() { + #expect(throws: OAuthClientIDLoopbackError.self) { + try ClientIDLoopback.parse(outhLoopbackClientID: "http://localhost/path") + } + } + + @Test("Treats a second question mark as part of the query value") + func multipleQuestionMarks() { + // "http://localhost?a=1?b=2" slices to the query "a=1?b=2"; the name "a" is + // not an allowed loopback parameter, so parsing rejects it. + #expect(throws: OAuthClientIDLoopbackError.self) { + try ClientIDLoopback.parse(outhLoopbackClientID: "http://localhost?a=1?b=2") + } + } + + @Test("Rejects a loopback client ID that does not start with the prefix") + func rejectsWrongPrefix() { + #expect(throws: OAuthClientIDLoopbackError.self) { + try ClientIDLoopback.parse(outhLoopbackClientID: "https://localhost") + } + } +} diff --git a/Tests/ATOAuthKitTests/OAuthIntrospectionResponseTests.swift b/Tests/ATOAuthKitTests/OAuthIntrospectionResponseTests.swift new file mode 100644 index 0000000..ae27658 --- /dev/null +++ b/Tests/ATOAuthKitTests/OAuthIntrospectionResponseTests.swift @@ -0,0 +1,140 @@ +import Foundation +import Testing +import OAuthTypes + +@Suite("OAuth introspection responses") +struct OAuthIntrospectionResponseTests { + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + @Test("Decodes an inactive response") + func decodesInactive() throws { + let response = try decoder.decode(OAuthIntrospectionResponse.self, from: Data("{\"active\":false}".utf8)) + guard case .inactive = response else { + Issue.record("Expected .inactive, got \(response)") + return + } + } + + @Test("Encodes an inactive response as a bare active flag") + func encodesInactive() throws { + let data = try encoder.encode(OAuthIntrospectionResponse.inactive) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(object?["active"] as? Bool == false) + #expect(object?.count == 1) + } + + @Test("Decodes an active response with a single audience") + func decodesActiveSingleAudience() throws { + let json = Data(""" + { + "active": true, + "scope": "atproto", + "client_id": "https://app.example.com/oauth-client-metadata.json", + "token_type": "DPoP", + "aud": "https://pds.example.com", + "exp": 1700000000, + "iat": 1699996400, + "sub": "did:plc:ewvi7nxzyoun6zhxrhs64oiz" + } + """.utf8) + + let response = try decoder.decode(OAuthIntrospectionResponse.self, from: json) + guard case .active(let active) = response else { + Issue.record("Expected .active, got \(response)") + return + } + + #expect(active.scope == "atproto") + #expect(active.clientID == "https://app.example.com/oauth-client-metadata.json") + #expect(active.tokenType == .dpop) + #expect(active.expiresAt == 1700000000) + #expect(active.issuedAt == 1699996400) + #expect(active.subject == "did:plc:ewvi7nxzyoun6zhxrhs64oiz") + + guard case .single(let audience)? = active.audience else { + Issue.record("Expected a single audience, got \(String(describing: active.audience))") + return + } + #expect(audience == "https://pds.example.com") + } + + @Test("Decodes an active response with multiple audiences") + func decodesActiveMultipleAudiences() throws { + let json = Data(""" + {"active":true,"aud":["https://a.example.com","https://b.example.com"]} + """.utf8) + + let response = try decoder.decode(OAuthIntrospectionResponse.self, from: json) + guard case .active(let active) = response, + case .multiple(let audiences)? = active.audience else { + Issue.record("Expected multiple audiences, got \(response)") + return + } + #expect(audiences == ["https://a.example.com", "https://b.example.com"]) + } + + @Test("Decodes authorization_details as an array") + func decodesAuthorizationDetailsArray() throws { + let json = Data(""" + {"active":true,"authorization_details":[{"type":"payment"},{"type":"account"}]} + """.utf8) + + let response = try decoder.decode(OAuthIntrospectionResponse.self, from: json) + guard case .active(let active) = response else { + Issue.record("Expected .active, got \(response)") + return + } + #expect(active.authorizationDetails?.map(\.type) == ["payment", "account"]) + } + + @Test("Rejects an empty aud array") + func rejectsEmptyAudienceArray() { + let json = Data("{\"active\":true,\"aud\":[]}".utf8) + #expect(throws: DecodingError.self) { + try self.decoder.decode(OAuthIntrospectionResponse.self, from: json) + } + } + + @Test("Round-trips a response with multiple audiences") + func roundTripsMultipleAudiences() throws { + let active = OAuthIntrospectionResponse.Active( + audience: .multiple(["https://a.example.com", "https://b.example.com"]) + ) + let data = try encoder.encode(OAuthIntrospectionResponse.active(active)) + let decoded = try decoder.decode(OAuthIntrospectionResponse.self, from: data) + + guard case .active(let decodedActive) = decoded, + case .multiple(let audiences)? = decodedActive.audience else { + Issue.record("Expected multiple audiences, got \(decoded)") + return + } + #expect(audiences == ["https://a.example.com", "https://b.example.com"]) + } + + @Test("Round-trips an active response") + func roundTripsActive() throws { + let active = OAuthIntrospectionResponse.Active( + scope: "atproto", + clientID: "client", + tokenType: .dpop, + audience: .single("https://pds.example.com"), + expiresAt: 1700000000, + subject: "did:web:example.com" + ) + let response = OAuthIntrospectionResponse.active(active) + + let data = try encoder.encode(response) + let decoded = try decoder.decode(OAuthIntrospectionResponse.self, from: data) + + guard case .active(let decodedActive) = decoded else { + Issue.record("Expected .active, got \(decoded)") + return + } + #expect(decodedActive.scope == "atproto") + #expect(decodedActive.tokenType == .dpop) + #expect(decodedActive.expiresAt == 1700000000) + #expect(decodedActive.subject == "did:web:example.com") + } +} diff --git a/Tests/ATOAuthKitTests/SendableConformanceTests.swift b/Tests/ATOAuthKitTests/SendableConformanceTests.swift new file mode 100644 index 0000000..3086cde --- /dev/null +++ b/Tests/ATOAuthKitTests/SendableConformanceTests.swift @@ -0,0 +1,46 @@ +import Testing +import OAuthTypes + +@Suite("Sendable conformance") +struct SendableConformanceTests { + + /// Accepts only `Sendable` types, so each call below is a compile-time proof of conformance. + /// + /// - Parameter type: The type to check. + private func requireSendable(_ type: T.Type) {} + + @Test("The public OAuthTypes surface is Sendable") + func publicSurfaceIsSendable() { + // New Phase 1 types. + requireSendable(ATProtoOAuthScope.self) + requireSendable(ATProtoDID.self) + requireSendable(ATProtoTokenResponse.self) + requireSendable(ATProtoLoopbackClient.Configuration.self) + requireSendable(ATProtoLoopbackClient.Parameters.self) + requireSendable(OAuthIntrospectionResponse.self) + requireSendable(OAuthIntrospectionResponse.Active.self) + requireSendable(OAuthIntrospectionResponse.Audience.self) + + // Existing types that gained Sendable in the sweep. + requireSendable(OAuthScope.self) + requireSendable(OAuthClientID.self) + requireSendable(OAuthRedirectURI.self) + requireSendable(OAuthTokenType.self) + requireSendable(TokenResponse.self) + requireSendable(AuthorizationRequestParameters.self) + requireSendable(AuthorizationRequestQuery.self) + requireSendable(ClientMetadata.self) + requireSendable(AuthorizationServerMetadata.self) + requireSendable(ProtectedResourceMetadata.self) + requireSendable(ClientIDDiscoverable.self) + requireSendable(IssuerIdentifier.self) + requireSendable(JWT.self) + requireSendable(SignedJWT.self) + requireSendable(OpenIDConnectUserInfo.self) + + // Error types crossing isolation boundaries. + requireSendable(ATProtoOAuthScopeError.self) + requireSendable(ATProtoDIDError.self) + requireSendable(ATProtoLoopbackClientIDError.self) + } +} diff --git a/Tests/ATOAuthKitTests/URIValidationTests.swift b/Tests/ATOAuthKitTests/URIValidationTests.swift new file mode 100644 index 0000000..4478419 --- /dev/null +++ b/Tests/ATOAuthKitTests/URIValidationTests.swift @@ -0,0 +1,222 @@ +import Testing +import OAuthTypes + +@Suite("HTTPS URI validation") +struct HTTPSURIValidationTests { + + @Test( + "Accepts well-formed https URIs with a public hostname or IP address", + arguments: [ + "https://example.com", + "https://sub.example.com", + "https://example.co.uk", + "https://8.8.8.8", + "https://[2001:db8::1]" + ] + ) + func acceptsValidHTTPSURIs(uriString: String) throws { + try URI.validateHTTPSURI(uriString: uriString) + } + + @Test( + "Rejects loopback hosts over https", + arguments: [ + "https://127.0.0.1", + "https://localhost", + "https://[::1]" + ] + ) + func rejectsLoopbackHTTPSURIs(uriString: String) { + #expect(throws: OAuthTypesLabsURIError.self) { + try URI.validateHTTPSURI(uriString: uriString) + } + } + + @Test( + "Rejects https URIs that fail the domain refinements", + arguments: [ + "https://example", + "https://foo.local", + "http://example.com", + "ftp://evil.com" + ] + ) + func rejectsInvalidHTTPSURIs(uriString: String) { + #expect(throws: OAuthTypesLabsURIError.self) { + try URI.validateHTTPSURI(uriString: uriString) + } + } + + @Test( + "Rejects mixed-case .local and loopback hosts the way the lowercasing parser would", + arguments: [ + "https://foo.LOCAL", + "https://printer.Local", + "https://LOCALHOST" + ] + ) + func rejectsMixedCaseHosts(uriString: String) { + // The WHATWG parser lowercases the host before applying these refinements, so a + // mixed-case ".local" or loopback host must be rejected just like its lowercase form. + #expect(throws: OAuthTypesLabsURIError.self) { + try URI.validateHTTPSURI(uriString: uriString) + } + } +} + +@Suite("Loopback redirect URI validation") +struct LoopbackRedirectURIValidationTests { + + @Test( + "Accepts http loopback URIs, including localhost", + arguments: [ + "http://127.0.0.1/", + "http://[::1]/", + "http://localhost/", + "http://localhost:8080/callback" + ] + ) + func acceptsLoopbackURIs(rawValue: String) throws { + _ = try URI.LoopbackRedirectURI(validating: rawValue) + } + + @Test( + "Rejects non-loopback hosts and non-http schemes", + arguments: [ + "http://example.com/", + "https://127.0.0.1/", + "ftp://evil.com" + ] + ) + func rejectsNonLoopbackURIs(rawValue: String) { + #expect(throws: OAuthTypesLabsURIError.self) { + try URI.LoopbackRedirectURI(validating: rawValue) + } + } +} + +@Suite("OAuth loopback redirect URI validation") +struct OAuthLoopbackRedirectURIValidationTests { + + @Test( + "Accepts loopback IP redirect URIs", + arguments: [ + "http://127.0.0.1/", + "http://[::1]/", + "http://127.0.0.1:8080/callback" + ] + ) + func acceptsLoopbackIPURIs(rawValue: String) throws { + _ = try OAuthLoopbackRedirectURI(validating: rawValue) + } + + @Test( + "Excludes the localhost hostname", + arguments: [ + "http://localhost/", + "http://localhost:8080/callback" + ] + ) + func excludesLocalhost(rawValue: String) { + #expect(throws: OAuthRedirectURIError.self) { + try OAuthLoopbackRedirectURI(validating: rawValue) + } + } + + @Test( + "Rejects non-loopback hosts and non-http schemes", + arguments: [ + "ftp://evil.com", + "http://example.com/", + "https://127.0.0.1/" + ] + ) + func rejectsNonLoopbackURIs(rawValue: String) { + // These reach the chained loopback URI schema (`URI.LoopbackRedirectURI`), which throws + // before the `localhost` exclusion can run: the non-http schemes fail the protocol guard + // and the non-loopback host fails the loopback guard. + #expect(throws: OAuthTypesLabsURIError.self) { + try OAuthLoopbackRedirectURI(validating: rawValue) + } + } +} + +@Suite("Hostname helper functions") +struct HostnameHelperTests { + + @Test( + "Recognizes IP-address hostnames, including bracketed IPv6", + arguments: [ + "127.0.0.1", + "8.8.8.8", + "[::1]", + "[2001:db8::1]" + ] + ) + func recognizesIPAddresses(hostname: String) { + #expect(isHostnameIPAddress(hostname)) + } + + @Test( + "Does not treat domain names as IP addresses", + arguments: [ + "example.com", + "localhost", + "foo.local" + ] + ) + func rejectsDomainNames(hostname: String) { + #expect(!isHostnameIPAddress(hostname)) + } + + @Test( + "Treats any dotted-quad of digits as an IP, the way the reference does", + arguments: [ + "300.1.2.3", + "999.999.999.999", + "0.00.000.0" + ] + ) + func treatsMalformedQuadAsIP(hostname: String) { + // The reference isHostnameIP is `/^\d+\.\d+\.\d+\.\d+$/`: out-of-range octets still count as + // an IP so they are rejected where an IP is barred, not let through as a domain name. + #expect(isHostnameIPAddress(hostname)) + } + + @Test( + "Does not treat near-IP shapes as IPs", + arguments: [ + "1.2.3", + "1.2.3.4.5", + "1.2.3.x" + ] + ) + func rejectsNonQuadShapes(hostname: String) { + #expect(!isHostnameIPAddress(hostname)) + } + + @Test( + "Recognizes loopback hostnames", + arguments: [ + "localhost", + "127.0.0.1", + "[::1]", + "::1" + ] + ) + func recognizesLoopbackHosts(hostname: String) { + #expect(isLoopbackHost(hostname)) + } + + @Test( + "Does not treat non-loopback hostnames as loopback", + arguments: [ + "example.com", + "8.8.8.8", + "http://127.0.0.1/" + ] + ) + func rejectsNonLoopbackHosts(hostname: String) { + #expect(!isLoopbackHost(hostname)) + } +} diff --git a/Tests/ATOAuthKitTests/WireFormatTests.swift b/Tests/ATOAuthKitTests/WireFormatTests.swift new file mode 100644 index 0000000..bfd7a4a --- /dev/null +++ b/Tests/ATOAuthKitTests/WireFormatTests.swift @@ -0,0 +1,250 @@ +import Foundation +import Testing +import OAuthTypes + +@Suite("OAuth wire-format round trips") +struct WireFormatTests { + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + // MARK: - Token response expires_in + + @Test("Token response decodes expires_in as a lifetime in seconds") + func tokenResponseExpiresInIsSeconds() throws { + let json = Data(""" + {"access_token":"abc123","token_type":"DPoP","expires_in":3600} + """.utf8) + + let response = try decoder.decode(TokenResponse.self, from: json) + + #expect(response.expiresIn == 3600) + #expect(response.accessToken == "abc123") + #expect(response.tokenType == .dpop) + } + + @Test("Token response omits expires_in when absent") + func tokenResponseExpiresInOptional() throws { + let json = Data(""" + {"access_token":"abc123","token_type":"Bearer"} + """.utf8) + + let response = try decoder.decode(TokenResponse.self, from: json) + + #expect(response.expiresIn == nil) + } + + // MARK: - Authorization details "datatypes" + + @Test("Authorization detail maps the lowercase datatypes wire key") + func authorizationDetailDatatypesKey() throws { + let json = Data(""" + {"type":"payment","datatypes":["account","balance"]} + """.utf8) + + let detail = try decoder.decode(AuthorizationDetail.self, from: json) + #expect(detail.dataTypes == ["account", "balance"]) + + let reencoded = try encoder.encode(detail) + let object = try JSONSerialization.jsonObject(with: reencoded) as? [String: Any] + #expect(object?["datatypes"] != nil) + #expect(object?["dataTypes"] == nil) + } + + // MARK: - Token response authorization_details array + + @Test("Token response decodes authorization_details as an array") + func tokenResponseAuthorizationDetailsArray() throws { + let json = Data(""" + {"access_token":"abc123","token_type":"DPoP","authorization_details":[{"type":"payment","datatypes":["account"]}]} + """.utf8) + + let response = try decoder.decode(TokenResponse.self, from: json) + #expect(response.authorizationDetails?.count == 1) + #expect(response.authorizationDetails?.first?.type == "payment") + #expect(response.authorizationDetails?.first?.dataTypes == ["account"]) + + let reencoded = try encoder.encode(response) + let object = try JSONSerialization.jsonObject(with: reencoded) as? [String: Any] + #expect(object?["authorization_details"] is [Any]) + } + + // MARK: - OIDC zoneinfo claim + + @Test("zoneinfo claim decodes from the lowercase wire value") + func zoneInfoClaimIsLowercase() throws { + let decoded = try decoder.decode(OpenIDConnectClaimsParameter.self, from: Data("\"zoneinfo\"".utf8)) + #expect(decoded == .timeZone) + + let encoded = try encoder.encode(OpenIDConnectClaimsParameter.timeZone) + #expect(String(data: encoded, encoding: .utf8) == "\"zoneinfo\"") + + #expect(throws: (any Error).self) { + _ = try decoder.decode(OpenIDConnectClaimsParameter.self, from: Data("\"zoneInfo\"".utf8)) + } + } + + // MARK: - Token identification optional hint + + @Test("Token identification decodes without token_type_hint") + func tokenIdentificationHintOptional() throws { + let json = Data(""" + {"token":"access-token-value"} + """.utf8) + + let identification = try decoder.decode(TokenIdentification.self, from: json) + #expect(identification.tokenTypeHint == nil) + } + + @Test("Token identification still decodes an explicit token_type_hint") + func tokenIdentificationHintPresent() throws { + let json = Data(""" + {"token":"access-token-value","token_type_hint":"refresh_token"} + """.utf8) + + let identification = try decoder.decode(TokenIdentification.self, from: json) + #expect(identification.tokenTypeHint == .refreshToken) + } + + // MARK: - Token type case-insensitivity + + @Test("DPoP token type decodes case-insensitively", arguments: ["DPoP", "dpop", "DPOP", "dPoP"]) + func dpopTokenTypeDecodesCaseInsensitively(rawValue: String) throws { + let decoded = try decoder.decode(OAuthTokenType.self, from: Data("\"\(rawValue)\"".utf8)) + #expect(decoded == .dpop) + } + + @Test("Bearer token type decodes case-insensitively", arguments: ["Bearer", "bearer", "BEARER", "bEaReR"]) + func bearerTokenTypeDecodesCaseInsensitively(rawValue: String) throws { + let decoded = try decoder.decode(OAuthTokenType.self, from: Data("\"\(rawValue)\"".utf8)) + #expect(decoded == .bearer) + } + + @Test("Token type encodes the canonical form") + func tokenTypeEncodesCanonical() throws { + #expect(String(data: try encoder.encode(OAuthTokenType.dpop), encoding: .utf8) == "\"DPoP\"") + #expect(String(data: try encoder.encode(OAuthTokenType.bearer), encoding: .utf8) == "\"Bearer\"") + } + + @Test("Token type rejects an unknown value") + func tokenTypeRejectsUnknown() { + #expect(throws: (any Error).self) { + _ = try decoder.decode(OAuthTokenType.self, from: Data("\"MAC\"".utf8)) + } + } + + // MARK: - PAR response positive expires_in + + @Test("PAR response decodes a positive expires_in") + func parResponsePositiveExpiresIn() throws { + let json = Data(""" + {"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":90} + """.utf8) + + let response = try decoder.decode(OAuthPARResponse.self, from: json) + #expect(response.expiresIn == 90) + } + + @Test("PAR response rejects a non-positive expires_in", arguments: [0, -1, -3600]) + func parResponseRejectsNonPositiveExpiresIn(expiresIn: Int) { + let json = Data(""" + {"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":\(expiresIn)} + """.utf8) + + #expect(throws: (any Error).self) { + _ = try self.decoder.decode(OAuthPARResponse.self, from: json) + } + } + + // MARK: - Authorization server metadata default auth methods + + @Test("Server metadata defaults token_endpoint_auth_methods_supported when absent") + func serverMetadataDefaultsAuthMethods() throws { + let json = Data(""" + { + "issuer":"https://issuer.example.com", + "authorization_endpoint":"https://issuer.example.com/authorize", + "token_endpoint":"https://issuer.example.com/token" + } + """.utf8) + + let metadata = try decoder.decode(AuthorizationServerMetadata.self, from: json) + #expect(metadata.tokenEndpointAuthMethodsSupported == ["client_secret_basic"]) + } + + @Test("Server metadata keeps an explicit token_endpoint_auth_methods_supported") + func serverMetadataKeepsExplicitAuthMethods() throws { + let json = Data(""" + { + "issuer":"https://issuer.example.com", + "authorization_endpoint":"https://issuer.example.com/authorize", + "token_endpoint":"https://issuer.example.com/token", + "token_endpoint_auth_methods_supported":["private_key_jwt","none"] + } + """.utf8) + + let metadata = try decoder.decode(AuthorizationServerMetadata.self, from: json) + #expect(metadata.tokenEndpointAuthMethodsSupported == ["private_key_jwt", "none"]) + } + + @Test("Server metadata rejects an explicit null token_endpoint_auth_methods_supported") + func serverMetadataRejectsExplicitNullAuthMethods() { + let json = Data(""" + { + "issuer":"https://issuer.example.com", + "authorization_endpoint":"https://issuer.example.com/authorize", + "token_endpoint":"https://issuer.example.com/token", + "token_endpoint_auth_methods_supported":null + } + """.utf8) + + #expect(throws: (any Error).self) { + _ = try self.decoder.decode(AuthorizationServerMetadata.self, from: json) + } + } + + // MARK: - Redirect URI https support + + @Test("Redirect URI decodes an https web address") + func redirectURIDecodesHTTPS() throws { + let decoded = try decoder.decode( + OAuthRedirectURI.self, + from: Data("\"https://app.example.com/callback\"".utf8) + ) + + guard case .oauthWebRedirectURI(let value) = decoded else { + Issue.record("Expected .oauthWebRedirectURI, got \(decoded)") + return + } + #expect(String(describing: value) == "https://app.example.com/callback") + } + + // MARK: - Protected resource metadata query/fragment rejection + + @Test("Protected resource metadata accepts a clean resource URL") + func protectedResourceAcceptsCleanResource() throws { + let json = Data(""" + {"resource":"https://resource.example.com"} + """.utf8) + + let metadata = try decoder.decode(ProtectedResourceMetadata.self, from: json) + #expect(String(describing: metadata.resource) == "https://resource.example.com") + } + + @Test( + "Protected resource metadata rejects query or fragment in debug builds too", + arguments: [ + "https://resource.example.com/path?token=1", + "https://resource.example.com/path#section" + ] + ) + func protectedResourceRejectsQueryOrFragment(resource: String) { + let json = Data(""" + {"resource":"\(resource)"} + """.utf8) + + #expect(throws: (any Error).self) { + _ = try self.decoder.decode(ProtectedResourceMetadata.self, from: json) + } + } +}