[Feature] Complete OAuthTypes: AT Protocol modules, Sendable, client_id, canonical hardening (Phase 1)#4
Draft
systemblueio wants to merge 27 commits into
Draft
Conversation
Renames OAuthClientRredentials.swift to OAuthClientCredentials.swift, corrects the Constants.swift file header, and fixes doc-comment spelling in OAuthScope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several validators had inverted guard logic or checked the full URI string where the parsed hostname was required, so valid production input was rejected and invalid input accepted: - validateHTTPSURI required a loopback host (the spec rejects loopback for https) and never parsed the hostname before checking it. - ClientIDDiscoverable rejected URLs without embedded credentials and accepted URLs with them; bare hosts, trailing-slash paths, and uncanonical paths (/a/../) also validated. - ConventionalOAuthClientID rejected IDs without a port or query and accepted IDs with them; it now also runs the discoverable validation first, matching the schema intersection in the reference. - IssuerIdentifier rejected clean canonical issuers and accepted query/fragment, credentialed, mixed-case-host, and explicit default-port URLs; the port also rendered as Optional(...) in the canonical comparison. - LoopbackRedirectURI and OAuthLoopbackRedirectURI checked the full URI string for loopback, so valid loopback URIs always threw, and OAuthLoopbackRedirectURI accepted any non-localhost string. - Bracketed IPv6 hostnames ([::1]) are now recognized as IP addresses. Also renames the misspelled error cases invalidURL, issuerURLEndsWithSlash, and lessThanTwoSegmentsInURI. All behavior is matched against the @atproto/oauth-types TypeScript schemas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aligns the JSON encoding of several types with the wire format the @atproto/oauth-types schemas define: - TokenResponse.expiresIn decoded expires_in (a lifetime in seconds) as a Date; it is now an Int. - AuthorizationDetail.dataTypes now maps to the wire key datatypes. - OpenIDConnectClaimsParameter.timeZone raw value corrected from zoneInfo to the standard zoneinfo claim. - TokenIdentification.tokenTypeHint is now optional and maps to token_type_hint, matching the schema. - AuthorizationServerMetadata defaults token_endpoint_auth_methods_supported to ["client_secret_basic"] only when the key is absent; an explicit null fails to decode and the previous [""] sentinel is gone. - ProtectedResourceMetadata validated the resource for query and fragment characters only in release builds; the checks now always run. - OAuthPARResponse rejects a non-positive expires_in instead of coercing it with abs(), and decodes the request_uri and expires_in keys it previously missed. - OAuthTokenType decodes case-insensitively (DPoP, dpop, Bearer, bearer) and encodes the canonical casing, matching the case-insensitive schema regex. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds URIValidationTests, ClientIdentifierTests, and WireFormatTests (56 tests across 9 suites, Swift Testing) covering each validator and Codable fix with vectors derived from the @atproto/oauth-types schemas, and wires OAuthTypes into the test target. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Marks the public OAuthTypes value types as Sendable so the surface can be used from strict-concurrency contexts. Every type is a value type with Sendable members, so these are checked conformances, not @unchecked. AuthorizationRequestQuery gains Sendable in a later commit, alongside its reshape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports the atproto modules OAuthTypes was missing, following @atproto/oauth-types@0.7.x: ATProtoOAuthScope, the loopback client redirect URIs and client-id build/parse, ATProtoTokenResponse, ATProtoDID, and OAuthIntrospectionResponse. Adds the isSpaceSeparatedValue and extractURLPath helpers and OAuthScope.isValid, plus a conversion doc per module. ATProtoDID validates did:plc and did:web syntax only; identity resolution is out of scope for OAuthTypes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TypeScript schema is an intersection of a client_id with one of three request forms, so client_id is required on every form. The query now decodes a required client_id, weaves it into the JAR and request-URI cases, and exposes it through a computed clientID. This also decodes the JAR form as the {client_id, request} object instead of a bare string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-encoded dot segments The canonical check compared URLComponents.path against URL.standardized, which does not resolve percent-encoded dot segments, so https://host/%2e%2e/... could pass (and behaved differently across platforms). Replaces it with extractURLPath plus a dot-segment resolver that treats %2e and %2E as dots, matching the WHATWG URL parser and giving the same result on every platform. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Swift Testing suites for the AT Protocol scope, loopback client, DID, token response, and introspection response, the client_id requirement, the canonical-form hardening, and a compile-time Sendable check. swift test: 111 tests in 20 suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds and tests the package on Linux (Swift 6.0 and 6.1) so the cross-platform requirement is proven rather than assumed. Pins actions/checkout to a commit SHA and disables credential persistence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
makeATProtoLoopbackClientMetadata ended its ClientMetadata initializer call with a trailing comma. Trailing commas in arbitrary argument lists are a Swift 6.1 feature, but the package declares swift-tools-version 6.0, so the build failed on Swift 6.0 (Linux CI) while passing on 6.1+. Removing the comma keeps the package building on its declared minimum. The alternative is to raise the tools version to 6.1, which is the maintainer's call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two scope tests captured the error returned by #expect(throws:), which is a Swift 6.1 swift-testing addition; on Swift 6.0 the macro returns Void, so the test target failed to compile in Linux CI. Rewrote them with do/catch and Issue.record, which distinguishes the error cases on both 6.0 and 6.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Utilities.swift held five unrelated concerns. Each now lives in its own file: - HostnameIPAddress.swift isHostnameIPAddress - LoopbackHost.swift LoopbackHost, isLoopbackHost, isLoopbackURL - JWT.swift JWTShapeValidating, SignedJWT, UnsignedJWT, JWT - SpaceSeparatedValue.swift isSpaceSeparatedValue - URLPath.swift extractURLPath Pure relocation, no behavior change.
canonicalizedURLPath is general RFC 3986 dot-segment resolution, not a client-ID rule. It now sits next to extractURLPath in URLPath.swift, so the raw URL path canonical-form logic lives in one place and ClientIDDiscoverable just calls it. Stays module-internal; no API change.
Drops the Array(url) copy and the manual index loop with -1 sentinels in favor of String slicing and firstIndex. Same behavior, fewer moving parts.
canonicalizedURLPath collapsed "/a/.." to "", so incorrectCanonicalForm reported expectedValue "" — misleading. WHATWG resolves it to "/", and paths here are always absolute with no trailing slash, so a full collapse means root. Accept/reject is unchanged; only the diagnostic improves.
The oauth-types schema sets authorization_details to z.array(...), but the field was a single AuthorizationDetail?, so a compliant token response with an array failed to decode. Make it [AuthorizationDetail]?.
The reference oauthRedirectUriSchema accepts an https web address via httpsUriSchema, but the enum had no https case, so every https redirect URI failed to decode. Add an oauthWebRedirectURI case backed by validateHTTPSURI. The existing oauthHTTPSRedirectURI case is left untouched to avoid a public API break; it still wraps an http loopback value despite its name.
For an http/https URL the WHATWG parser treats a backslash as a path separator and emits it as "/", so a backslash-hidden ".." resolves the same as "/.." does. canonicalizedURLPath only split on "/", so a client ID like https://host/foo\..\..\meta.json was canonicalized verbatim and accepted as canonical, diverging from the reference (extractUrlPath(value) !== url.pathname, which rejects it) on any platform whose URL parser is lenient about backslashes. Fold backslashes to "/" before segmenting. The raw path keeps them verbatim, so any backslash now makes the value differ from its canonical form and is rejected. Add a direct canonicalizedURLPath test and a discoverable-client-ID rejection test for backslash-hidden dot segments.
The base TokenResponse was fixed to decode authorization_details as an array, but ATProtoTokenResponse and OAuthIntrospectionResponse.Active still typed it as a single AuthorizationDetail?, so a compliant payload with the array form (the only valid shape, oauthAuthorizationDetailsSchema = z.array(...)) failed to decode. Type both as [AuthorizationDetail]?. The introspection aud claim is `string | [string, ...string[]]`, a non-empty tuple, so reject an empty aud array rather than accepting it as .multiple([]). Add decode tests for the array form on both types, an empty-aud rejection test, and a multiple-audience round-trip.
OAuthScope.isValid flattened a double optional with `?? nil`, but `try?` on a failable, throwing init already yields a single optional, so drop it to match the sibling ATProtoDID.isValid / ATProtoOAuthScope.isValid form. Also replace the unused `match` binding in init(validating:) with a boolean test, clearing a compiler warning.
ATProtoDID accepts bare hostnames only, so add did:web:example.com:3000 and the percent-encoded did:web:example.com%3A3000 as rejected cases to pin that the port narrowing is deliberate. Add a build-then-parse round-trip asserting the default redirect URIs come back unchanged.
…quest Why: the reference oauthAuthorizationRequestParametersSchema types authorization_details with oauthAuthorizationDetailsSchema, which is z.array(oauthAuthorizationDetailSchema). The Swift port modeled it as a single AuthorizationDetail, so any real authorization request that carries authorization_details (always a JSON array per RFC 9396) fails to decode with a typeMismatch. This is the same wire-format class already fixed on the token and introspection responses, missed on the request parameters. Fix: type the field as [AuthorizationDetail]? and decode it as an array. The synthesized encode follows the property type. Testing: added a query test that decodes a two-entry authorization_details array on the parameters form; full suite green.
Why: validateHTTPSURI read the host from URLComponents, which preserves its case, then compared it case-sensitively against the loopback hosts and the ".local" suffix. The reference parses with new URL(), which lowercases the host, so a mixed-case host such as "printer.LOCAL" or "foo.Local" passed validation here while the reference rejects it. mDNS ".local" hosts resolve on the local network, so letting them through is a (minor) SSRF- adjacent gap in client-ID and issuer validation, which both route through this function. Fix: lowercase the host once, then run the loopback, IP, and .local checks against the lowercased value, matching the parser. Testing: added rejection cases for https://foo.LOCAL, https://printer.Local, and https://LOCALHOST; full suite green.
Why: the reference oauthIssuerIdentifierSchema compares the input against new URL(value).href (or .origin), and the WHATWG parser resolves dot segments when it builds url.pathname. The Swift check rebuilt the canonical form from urlComponents.path, which keeps dot segments in place, so a non-canonical issuer such as https://auth.example.com/a/../b was accepted here while the reference rejects it. Canonical form is the issuer mix-up defense (RFC 9207), and the sibling discoverable client-ID check was already hardened the same way; this leaves the issuer consistent with it. Fix: after the existing rebuild, compare the raw path against its canonicalized form (extractURLPath + canonicalizedURLPath), skipping an empty origin-only path. Testing: added rejection cases for literal dot-segment issuers; the existing origin-only, /issuer, and non-default-port accept cases still pass; full suite green.
Why: the reference isHostnameIP is `/^\d+\.\d+\.\d+\.\d+$/`, which is deliberately loose. The Swift port used a strict 0-255 regex, so a malformed quad like "300.1.2.3" was not recognized as an IP and slipped through as a domain name, meaning a discoverable client ID such as https://300.1.2.3/oauth-client-metadata.json was accepted here while the reference rejects it (the host is an IP). Range-checking each octet is a separate concern this host check does not own. Fix: match the reference's broad dotted-quad regex so out-of-range octets still count as an IP and are barred where IPs are. Testing: added helper tests for malformed quads and near-IP shapes, and a discoverable client-ID rejection for https://300.1.2.3/...; full suite green.
…delines The maintainer's standard (issue ATProtoKit#2): the Swift API Design Guidelines plus the ATProtoKit API_GUIDELINES.md (do not shorten words; docs <=100 chars; make/build/create per Swift conventions). Aligning the Phase-1 additions: - ATProtoTokenResponse.sub -> subject. "sub" is a shortened word (the guidelines require full terms, e.g. getRepository not getRepo) and the sibling OAuthIntrospectionResponse.Active already names the same claim subject. The sub wire key is unchanged (CodingKeys: subject = "sub"). - ATProtoLoopbackClient.buildClientID -> makeClientID. The Swift API Design Guidelines start factory methods with make, and the base code already uses makeATProtoLoopbackClientMetadata; makeClientID/parseClientID also read as a clean inverse pair. - Reflowed three doc comments in ATProtoLoopbackClientID to the 100-char documentation limit. Testing: renamed call sites in the suites; zero-warning build, 127 tests in 20 suites green.
Author
|
Reopening to keep refining this; it stays in draft until it is genuinely solid. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Phase 1 of the OAuthTypes completion (#2): the remaining AT Protocol modules,
Sendableacross the public surface, theclient_idrequirement on the authorization request query, and a hardened canonical-form check.@atproto/oauth-types@0.7.x:ATProtoOAuthScope, the loopback client redirect URIs and client-id build/parse,ATProtoTokenResponse,OAuthIntrospectionResponse, and a small supportingATProtoDID.OAuthTypesvalue type conforms toSendable(checked, not@unchecked), proven by a compile-time test.client_idon every form:AuthorizationRequestQuerymodels the TypeScript intersection, soclient_idis required and woven into the JAR and request-URI cases (the JAR form decodes{client_id, request}, not a bare string), surfaced through a computedclientID.extractURLPathand normalizes dot segments itself, treating%2e/%2Eas dots and folding backslashes to/, matching the WHATWG URL parser. The previous check leaned onURL.standardized, which leaves%2eand\unresolved, sohttps://host/%2e%2e/...andhttps://host/a\..\..could pass and behaved differently across platforms.authorization_detailsdecodes as an array (it isz.array(...)) onATProtoTokenResponseand the introspection response, and an emptyaudarray is rejected (the claim isstring | [string, ...string[]]).README and DocC files are left untouched per #2, and every
public,internal, andprivatedeclaration carries inline docs.Stacked on #3
This sits on the validator fixes in #3, so its diff carries those commits until #3 merges. Please take #3 first; I will rebase this onto
mainafter.A few calls for you
ATProtoLoopbackClient.parseClientIDrejects an invalidredirect_urito matchparseOAuthLoopbackClientId, rather than building onClientIDLoopback.parse, which silently drops them. Worth tightening the base the same way if you agree.ATProtoDIDvalidatesdid:plc(24 base32) anddid:web(bare hostname, no port or path) syntax only, standing in for@atproto/did. It does not resolve identities; that belongs to a later phase, and I am happy to wire it to ATProtoKit's DID type when it lands.Follow-up review pass
A second pass against
@atproto/oauth-typessurfaced four more reference mismatches in the validators, each fixed here with accept and reject tests:authorization_detailson the request parameters decoded as a single object, butoauthAuthorizationRequestParametersSchemareferencesoauthAuthorizationDetailsSchema(z.array(...)), so a real request withauthorization_detailsfailed to decode. Now[AuthorizationDetail]?, matching the token and introspection responses.validateHTTPSURIcompared the host case-sensitively, sohttps://printer.LOCALslipped past the.localcheck;new URL()lowercases the host, so the host is lowercased before the loopback, IP, and.localchecks.urlComponents.path, which keeps dot segments, sohttps://auth.example.com/a/../bwas accepted; it now resolves dot segments with the sameextractURLPath+canonicalizedURLPaththe discoverable check uses, matchingnew URL(value).href.300.1.2.3passed as a domain name and ahttps://300.1.2.3/...client ID was accepted; it now matches the reference's broad^\d+\.\d+\.\d+\.\d+$, so out-of-range octets are still barred as IP hosts.A pass over the new public surface against the API design guidelines (#2) also landed:
ATProtoTokenResponse.subis nowsubject(full word, matchingOAuthIntrospectionResponse.Active.subject; thesubwire key is unchanged),ATProtoLoopbackClient.buildClientIDis nowmakeClientID(themakefactory convention, matchingmakeATProtoLoopbackClientMetadata), and three doc comments were reflowed to the 100-character limit.Base-code items for your call
A few things turned up in base code that I left unchanged for you to decide on, rather than touch your public API:
http://localhostredirect URIs.OAuthRedirectURIaccepts one through itsoauthHTTPSRedirectURIcase, butoauthRedirectUriSchemais https web / http loopback (explicit IP,localhostexcluded per RFC 8252) / private-use. Dropping that case would match the reference.OAuthRedirectURIcases repeat the type name (oauthWebRedirectURI);.web/.loopback/.privateUsewould read cleaner per the Swift API Design Guidelines.OpenIDConnectUserInfo.audience. Typed[String]?, butoidcUserinfoSchematypesaudasstring | [string, ...string[]], so a single-stringaudfails to decode. Could reuseOAuthIntrospectionResponse.Audience.Happy to implement any of these if you would like.
Verification
swift buildclean, zero warnings;swift testgreen: 127 tests in 20 suites.