Skip to content

[Feature] Complete OAuthTypes: AT Protocol modules, Sendable, client_id, canonical hardening (Phase 1)#4

Draft
systemblueio wants to merge 27 commits into
ATProtoKit:mainfrom
systemblueio:feat/oauth-types-phase1
Draft

[Feature] Complete OAuthTypes: AT Protocol modules, Sendable, client_id, canonical hardening (Phase 1)#4
systemblueio wants to merge 27 commits into
ATProtoKit:mainfrom
systemblueio:feat/oauth-types-phase1

Conversation

@systemblueio

@systemblueio systemblueio commented Jun 16, 2026

Copy link
Copy Markdown

What this does

Phase 1 of the OAuthTypes completion (#2): the remaining AT Protocol modules, Sendable across the public surface, the client_id requirement on the authorization request query, and a hardened canonical-form check.

  • New modules, each with a conversion doc, following @atproto/oauth-types@0.7.x: ATProtoOAuthScope, the loopback client redirect URIs and client-id build/parse, ATProtoTokenResponse, OAuthIntrospectionResponse, and a small supporting ATProtoDID.
  • Sendable: every public OAuthTypes value type conforms to Sendable (checked, not @unchecked), proven by a compile-time test.
  • client_id on every form: AuthorizationRequestQuery models the TypeScript intersection, so client_id is required and woven into the JAR and request-URI cases (the JAR form decodes {client_id, request}, not a bare string), surfaced through a computed clientID.
  • Canonical-form hardening: the discoverable client-ID check reads the raw path with extractURLPath and normalizes dot segments itself, treating %2e/%2E as dots and folding backslashes to /, matching the WHATWG URL parser. The previous check leaned on URL.standardized, which leaves %2e and \ unresolved, so https://host/%2e%2e/... and https://host/a\..\.. could pass and behaved differently across platforms.
  • Wire-format fixes found while porting: authorization_details decodes as an array (it is z.array(...)) on ATProtoTokenResponse and the introspection response, and an empty aud array is rejected (the claim is string | [string, ...string[]]).

README and DocC files are left untouched per #2, and every public, internal, and private declaration 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 main after.

A few calls for you

  • Base loopback leniency: ATProtoLoopbackClient.parseClientID rejects an invalid redirect_uri to match parseOAuthLoopbackClientId, rather than building on ClientIDLoopback.parse, which silently drops them. Worth tightening the base the same way if you agree.
  • ATProtoDID validates did:plc (24 base32) and did: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.
  • Swift 6.0 floor: one call used a trailing comma (a 6.1 feature), so it did not build on the declared 6.0 floor. I dropped the comma; raising the tools version to 6.1 is the alternative if you would rather keep them.

Follow-up review pass

A second pass against @atproto/oauth-types surfaced four more reference mismatches in the validators, each fixed here with accept and reject tests:

  • authorization_details on the request parameters decoded as a single object, but oauthAuthorizationRequestParametersSchema references oauthAuthorizationDetailsSchema (z.array(...)), so a real request with authorization_details failed to decode. Now [AuthorizationDetail]?, matching the token and introspection responses.
  • validateHTTPSURI compared the host case-sensitively, so https://printer.LOCAL slipped past the .local check; new URL() lowercases the host, so the host is lowercased before the loopback, IP, and .local checks.
  • The issuer canonical-form check rebuilt from urlComponents.path, which keeps dot segments, so https://auth.example.com/a/../b was accepted; it now resolves dot segments with the same extractURLPath + canonicalizedURLPath the discoverable check uses, matching new URL(value).href.
  • The IPv4 host check used a strict 0-255 regex, so a malformed quad like 300.1.2.3 passed as a domain name and a https://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.sub is now subject (full word, matching OAuthIntrospectionResponse.Active.subject; the sub wire key is unchanged), ATProtoLoopbackClient.buildClientID is now makeClientID (the make factory convention, matching makeATProtoLoopbackClientMetadata), 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://localhost redirect URIs. OAuthRedirectURI accepts one through its oauthHTTPSRedirectURI case, but oauthRedirectUriSchema is https web / http loopback (explicit IP, localhost excluded per RFC 8252) / private-use. Dropping that case would match the reference.
  • Redirect-URI case names. The OAuthRedirectURI cases repeat the type name (oauthWebRedirectURI); .web / .loopback / .privateUse would read cleaner per the Swift API Design Guidelines.
  • OpenIDConnectUserInfo.audience. Typed [String]?, but oidcUserinfoSchema types aud as string | [string, ...string[]], so a single-string aud fails to decode. Could reuse OAuthIntrospectionResponse.Audience.

Happy to implement any of these if you would like.

Verification

  • macOS (Swift 6.3): swift build clean, zero warnings; swift test green: 127 tests in 20 suites.
  • Linux (Swift 6.0 and 6.1): green via the added GitHub Actions workflow (run). Android and Windows are not in CI; this target uses only Foundation and the standard library, no platform-specific APIs.

systemblueio and others added 12 commits June 4, 2026 21:41
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.
@systemblueio

Copy link
Copy Markdown
Author

Reopening to keep refining this; it stays in draft until it is genuinely solid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant