Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e03b55a
Fix misspelled filename and doc comments
systemblueio Jun 5, 2026
5ecc727
Fix inverted guards in URI, client ID, and issuer validators
systemblueio Jun 5, 2026
cd71f07
Fix wire-format mismatches in the Codable types
systemblueio Jun 5, 2026
211ff0c
Add a test suite for the OAuthTypes validators and wire formats
systemblueio Jun 5, 2026
0a52af3
feat: add Sendable conformance across the OAuthTypes public surface
systemblueio Jun 16, 2026
cf2e216
feat: port the missing AT Protocol OAuthTypes modules
systemblueio Jun 16, 2026
96780c2
fix: require client_id on AuthorizationRequestQuery
systemblueio Jun 16, 2026
dc7335a
fix: harden the discoverable client ID canonical check against percen…
systemblueio Jun 16, 2026
b197d8c
test: add Phase 1 OAuthTypes test suites
systemblueio Jun 16, 2026
8c764b6
ci: add a Linux build-and-test workflow
systemblueio Jun 16, 2026
8b27bd7
fix: drop a trailing comma so the loopback metadata builds on Swift 6.0
systemblueio Jun 16, 2026
400619e
test: build the scope error-case tests on Swift 6.0
systemblueio Jun 16, 2026
c441e42
refactor: split Utilities into single-concern files
systemblueio Jun 16, 2026
a8c20d2
refactor: move canonical-form path helper out of the client-ID validator
systemblueio Jun 16, 2026
65c5af7
refactor: rewrite extractURLPath with native String indices
systemblueio Jun 16, 2026
4f39231
fix: report "/" for a fully-collapsed canonical path
systemblueio Jun 16, 2026
f01c5a5
fix: decode TokenResponse.authorizationDetails as an array
systemblueio Jun 16, 2026
9f1538d
fix: accept https redirect URIs in OAuthRedirectURI
systemblueio Jun 16, 2026
ecbb108
fix: fold backslashes in the discoverable client ID canonical check
systemblueio Jun 16, 2026
68efac5
fix: align authorization_details and aud with the oauth-types schema
systemblueio Jun 16, 2026
c57377a
refactor: simplify the OAuthScope validity checks
systemblueio Jun 16, 2026
211eb04
test: pin the did:web port rejection and default redirect URI round-trip
systemblueio Jun 16, 2026
f1fd74f
fix: decode authorization_details as an array on the authorization re…
systemblueio Jun 16, 2026
184be64
fix: lowercase the host before the loopback and .local checks
systemblueio Jun 17, 2026
6d47b9d
fix: resolve dot segments in the issuer canonical-form check
systemblueio Jun 17, 2026
29f3d4a
fix: treat any dotted-quad of digits as an IPv4 host
systemblueio Jun 17, 2026
38e718e
refactor: align new OAuthTypes public surface with the API design gui…
systemblueio Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ let package = Package(
),
.testTarget(
name: "ATOAuthKitTests",
dependencies: ["ATOAuthKit"]
dependencies: ["ATOAuthKit", "OAuthTypes"]
),
]
)
178 changes: 178 additions & 0 deletions Sources/OAuthTypes/ATProtoDID.swift
Original file line number Diff line number Diff line change
@@ -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[..<methodEnd])
let identifier = String(afterScheme[afterScheme.index(after: methodEnd)...])

guard !method.isEmpty, !identifier.isEmpty else {
throw ATProtoDIDError.invalidDID
}

switch method {
case "plc":
guard Self.isValidPLCIdentifier(identifier) else {
throw ATProtoDIDError.invalidPLCIdentifier
}
case "web":
guard Self.isValidWebIdentifier(identifier) else {
throw ATProtoDIDError.invalidWebIdentifier
}
default:
throw ATProtoDIDError.unsupportedMethod(method)
}

self.rawValue = rawValue
}

/// Determines whether the string is a valid AT Protocol DID.
///
/// - Parameter input: The string to check.
/// - Returns: `true` if the string is a valid `did:plc` or `did:web` DID, or `false` if not.
public static func isValid(_ input: String) -> 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:<method>:<id>` 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)
}
}
Loading