BC5 OAuth Ask #1: resource-first discovery (5 SDKs)#369
Conversation
There was a problem hiding this comment.
Pull request overview
Implements BC5 OAuth “resource-first discovery” (RFC 9728 + RFC 8414) across Go, TypeScript, Python, Ruby, and Kotlin, backed by shared cross-SDK conformance fixtures and SSRF-hardening requirements. This establishes the first PR in a stacked series for the BC5 OAuth go-live SDK contract and standardizes error taxonomy + endpoint optionality (notably authorization_endpoint).
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Changes:
- Add resource-first discovery ops (
discoverProtectedResource,discoverFromResource) + stage-sensitive fallback, plus model updates (nullable/optionalauthorization_endpoint, device/grant fields). - Add SSRF hardening for both discovery hops (HTTPS-only origins, redirect suppression, bounded/streaming body reads, standardized non-2xx →
api_error). - Introduce shared
conformance/oauthfixture set + JSON Schema validation, and wireoauth-fixtures-checkintomake conformance.
Reviewed changes
Copilot reviewed 72 out of 73 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| typescript/tests/oauth/resource-discovery.test.ts | New fixture-driven TS tests for resource-first discovery + SSRF behaviors. |
| typescript/tests/oauth/oauth.test.ts | Align TS discovery non-2xx classification to api_error. |
| typescript/tests/oauth/interactive-login.test.ts | Add issuer-binding correctness + fatal-after-selection tests for resource-first login. |
| typescript/src/oauth/types.ts | Extend OAuth types: optional auth endpoint, protected-resource metadata, selection/fallback types. |
| typescript/src/oauth/interactive-login.ts | Add resource-first discovery mode + mutual exclusivity with legacy baseUrl; assert auth-code capability. |
| typescript/src/oauth/index.ts | Re-export new resource-first discovery APIs and types. |
| typescript/README.md | Document BC5 resource-first discovery API + selection/fallback semantics and migration notes. |
| SPEC.md | Define the cross-SDK contract for resource-first discovery, origin-root profile, fallback state machine, and SSRF rules. |
| ruby/test/test_helper.rb | Update Ruby test helpers to stub hop-1 protected-resource discovery (not AS discovery). |
| ruby/test/basecamp/services/authorization_service_test.rb | Expand Ruby authorization service tests for soft fallback, committed-issuer behavior, and cross-origin BC5 issuer. |
| ruby/test/basecamp/oauth_test.rb | Align Ruby non-2xx discovery classification to api_error (+ http_status assertion). |
| ruby/test/basecamp/oauth_ssrf_test.rb | New Ruby SSRF tests for bounded streaming reads + redirect-middleware rejection + redirect suppression. |
| ruby/test/basecamp/oauth_resource_discovery_test.rb | New Ruby fixture-driven tests for cross-SDK conformance scenarios. |
| ruby/README.md | Document Ruby resource-first discovery API + fallback/raise semantics and SSRF hardening. |
| ruby/lib/basecamp/services/authorization_service.rb | Move authorization service out of generated/ and implement resource-first discovery + issuer-scoped cross-origin allowance. |
| ruby/lib/basecamp/security.rb | Add origin-root validation helper (require_origin_root!) and document cross-origin trust expansion. |
| ruby/lib/basecamp/oauth/resource.rb | Implement RFC 9728 protected-resource fetch + code-point binding + structural validation of authorization_servers. |
| ruby/lib/basecamp/oauth/protected_resource_metadata.rb | Add Ruby model preserving absent vs empty authorization_servers. |
| ruby/lib/basecamp/oauth/fetcher.rb | Add shared SSRF-hardened fetcher with redirect suppression + bounded streaming read cap. |
| ruby/lib/basecamp/oauth/discovery.rb | Update RFC 8414 discovery to issuer-bind, relax auth endpoint requirement, validate endpoints + grant types. |
| ruby/lib/basecamp/oauth/discovery_selection_error.rb | Add typed hard selection error for “fatal-after-selection” cases. |
| ruby/lib/basecamp/oauth/discovery_result.rb | Add result type representing selected vs soft fallback outcomes. |
| ruby/lib/basecamp/oauth/config.rb | Update Ruby OAuth config model: optional auth endpoint + device/grant fields. |
| ruby/lib/basecamp/oauth.rb | Add protected-resource discovery + orchestrator implementing selection and stage-sensitive fallback. |
| ruby/lib/basecamp/http.rb | Allow one additional discovered issuer origin for credentialed get_absolute requests (in addition to Launchpad). |
| ruby/lib/basecamp/generated/services/authorization_service.rb | Delete generated authorization service (replaced by handwritten runtime service). |
| python/tests/oauth/test_resource_discovery.py | New Python fixture-driven resource-first discovery tests + origin-root and absent-vs-empty assertions. |
| python/tests/oauth/test_discovery.py | Update Python localhost discovery test to satisfy issuer-binding requirement. |
| python/src/basecamp/oauth/errors.py | Add DiscoverySelectionError with typed reason tokens and taxonomy mapping. |
| python/src/basecamp/oauth/discovery.py | Implement RFC 9728 + RFC 8414 composable ops + orchestrator; add SSRF-hardened streaming fetch. |
| python/src/basecamp/oauth/config.py | Expand Python OAuth models for optional endpoints, protected-resource metadata, and fallback result types. |
| python/src/basecamp/oauth/init.py | Re-export new discovery APIs, models, and require_origin_root. |
| python/src/basecamp/_security.py | Add Python require_origin_root implementing the origin-root profile (incl. IPv6 + default port dropping). |
| python/README.md | Document Python resource-first discovery + migration notes for optional authorization_endpoint. |
| Makefile | Add oauth-fixtures-check (pinned check-jsonschema via uvx) and run it as part of conformance. |
| kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt | Add Kotlin commonTest scenarios mirroring shared OAuth fixtures + SSRF assertions. |
| kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt | Include new DiscoverySelection exception in Kotlin error-code mapping tests. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt | Extract shared URL parsing + origin-root/secure-endpoint helpers (Ktor parser based). |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Pagination.kt | Remove local URL parsing helpers in favor of Urls.kt shared helpers. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Exchange.kt | Enforce secure token endpoint before credential POST (localhost exempt). |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt | Implement resource-first discovery + SSRF hardening and bounded reads for Kotlin. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt | Add Kotlin DiscoverySelection exception type with taxonomy-aligned codes. |
| kotlin/README.md | Document Kotlin resource-first discovery + nullable authorizationEndpoint consumer guidance. |
| go/README.md | Document Go resource-first discovery APIs, options, and migration notes. |
| go/pkg/basecamp/oauth/types.go | Expand Go OAuth types for optional endpoints, protected-resource metadata, fallback result, and hard-selection errors. |
| go/pkg/basecamp/oauth/resource_discovery_test.go | Add Go fixture-driven conformance tests + origin-root profile unit tests. |
| go/pkg/basecamp/oauth/oauth_test.go | Update Go discovery tests for issuer binding and trailing-slash origin-root acceptance. |
| go/pkg/basecamp/oauth/discovery.go | Implement Go resource-first discovery with origin-root validation, SSRF hardening, bounded reads, and redirect suppression. |
| conformance/oauth/schema.json | Add JSON Schema for OAuth discovery fixtures. |
| conformance/oauth/README.md | Document fixture intent, validation, and placeholder substitution rules. |
| conformance/oauth/fixtures/01-two-hop-happy-path.json | Add fixture scenario 01. |
| conformance/oauth/fixtures/02-no-as-advertised-absent.json | Add fixture scenario 02. |
| conformance/oauth/fixtures/03-no-as-advertised-empty-array.json | Add fixture scenario 03. |
| conformance/oauth/fixtures/04-only-launchpad.json | Add fixture scenario 04. |
| conformance/oauth/fixtures/05-resource-mismatch.json | Add fixture scenario 05. |
| conformance/oauth/fixtures/06-hop1-transport-failure.json | Add fixture scenario 06. |
| conformance/oauth/fixtures/07-issuer-binding-mismatch.json | Add fixture scenario 07. |
| conformance/oauth/fixtures/08-as-metadata-500.json | Add fixture scenario 08. |
| conformance/oauth/fixtures/09-ambiguous-issuers.json | Add fixture scenario 09. |
| conformance/oauth/fixtures/10-expected-issuer-selected.json | Add fixture scenario 10. |
| conformance/oauth/fixtures/11-expected-issuer-unavailable.json | Add fixture scenario 11. |
| conformance/oauth/fixtures/12-empty-string-endpoint.json | Add fixture scenario 12. |
| conformance/oauth/fixtures/13-device-only-as.json | Add fixture scenario 13. |
| conformance/oauth/fixtures/14-origin-root-http-nonlocalhost.json | Add fixture scenario 14. |
| conformance/oauth/fixtures/15-origin-root-malformed-port.json | Add fixture scenario 15. |
| conformance/oauth/fixtures/16-origin-root-path-rejected.json | Add fixture scenario 16. |
| conformance/oauth/fixtures/17-origin-root-ipv6-localhost-accept.json | Add fixture scenario 17. |
| conformance/oauth/fixtures/18-ssrf-oversized-body.json | Add fixture scenario 18. |
| conformance/oauth/fixtures/19-ssrf-redirect-not-followed.json | Add fixture scenario 19. |
| conformance/oauth/fixtures/20-invalid-issuer-origin.json | Add fixture scenario 20. |
| conformance/oauth/fixtures/21-authorization-servers-not-array.json | Add fixture scenario 21. |
| conformance/oauth/fixtures/22-grant-types-not-array.json | Add fixture scenario 22. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b0121732bb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
All reported issues were addressed across 73 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
b012173 to
336df9d
Compare
Review round 2 — pushed (history folded, 6 commits/branch preserved)Addressed the genuine blockers from the automated + manual review. Fixes are folded into their owning per-SDK commits (discovery-layer → PR #369, device-layer → PR #370); both branch tips pass full Discovery (PR #369):
Device flow (PR #370):
Dismissed — false positive: the Ruby IPv6 claim is incorrect under the project Ruby — Trivy (Go SDK) check: the failure is a scanner-version/DB discrepancy on an unchanged Still under the agreed merge hold pending bc3 #9471. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 336df9d781
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
336df9d to
64e2ef3
Compare
c9a5fb3 to
3b2d1c3
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ccdae8b82a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Kotlin only checked its four modeled endpoint fields, so an unmodeled key such as revocation_endpoint present with null or an empty string slipped through ignoreUnknownKeys during decode — accepting metadata that SPEC §16 requires be rejected (any present *_endpoint must be a non-empty string). Iterate every *_endpoint key on the parsed JsonObject before decoding and reject a present null, empty string, or non-string value uniformly, matching Go's rejectEmptyEndpoints and the TS/Python/Ruby generic loops. Drops the now-redundant modeled-field empty-string loop in bindAsMetadata. Adds shared fixture 38 (unmodeled revocation_endpoint: "") exercising all four fixture-driven SDKs, plus Kotlin embedded mirrors for the empty and null variants.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 59ed7e4778
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
DiscoverFromResource's hop-1 failure branch converted every non-usage error into a soft resource_discovery_failed fallback (nil error), so a caller who cancelled the context or hit their deadline received a fallback result and a correct consumer would then proceed to a credentialed Launchpad request — exactly what the abort was meant to prevent. Check the parent ctx.Err() before returning the soft fallback and propagate the cancellation. fetchDiscoveryDocument derives its own per-fetch timeout as a CHILD context, so a non-nil parent Err() is unambiguously caller-initiated; the SDK's own timeout leaves the parent untouched and still soft-falls-back. Hop 2 already surfaces cancellation through *SelectionError's Unwrap chain and never contacts Launchpad, so this is scoped to hop 1. Adds tests for both the caller-cancellation and SDK-own-timeout paths. The other four SDKs are unaffected: TS's AbortController is internal-timeout only (no caller signal), Python/Ruby are synchronous (interrupts propagate as BaseException past the OAuthError rescues), and Kotlin's hop-1 catch is typed to BasecampException while fetchDiscoveryDocument rethrows CancellationException.
The TypeScript hop failure path interpolated the entire bounded response body into the BasecampError message. The bounded read still permits up to maxBodyBytes (1 MiB default), so a large or hostile non-2xx body could produce an enormous error string — log spam, memory pressure, and potential echoing of sensitive server content. Cap the embedded body with the existing truncateErrorMessage helper (500 chars), matching Go's truncateBody, Python's truncate, and Ruby's Security.truncate. Kotlin already omits the body from its discovery error message entirely. Adds a test asserting a 200 KB body yields a bounded message.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b7765336a1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
req.options.timeout bounds only each individual socket read, and Faraday resets it on every on_data chunk — so a slow-drip peer trickling one byte before each read timeout can hold a discovery fetch open arbitrarily long while staying under max_body_bytes. The SSRF-hardened path had no deadline over the whole streaming read, unlike the other four SDKs. Add a monotonic (CLOCK_MONOTONIC) wall-clock deadline threaded into the shared bounded_reader: it raises the moment the whole read outlives timeout, mapped to a retryable network OauthError. This matches Python's monotonic deadline over httpx.stream, Go's context.WithTimeout, TypeScript's abort timer, and Kotlin's Ktor requestTimeoutMillis. Adds a slow-drip streaming test asserting the read aborts on the deadline before the in-cap body completes.
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21cacc85b8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The hop-1 fix guarded the soft-fallback path; the committed-issuer AS metadata fetch had the same gap: a caller cancelling (or its deadline expiring) during hop 2 was reclassified as ErrASFetchFailed/api_error rather than surfacing as cancellation. The cause was still reachable via the SelectionError unwrap chain, but the top-level classification misrepresented a caller abort as a server-side metadata failure. Check the parent ctx.Err() before wrapping as ErrASFetchFailed, mirroring the hop-1 guard. Adds a hop-2 cancellation test asserting context.Canceled surfaces and the error is not misclassified as ErrASFetchFailed.
… guard The DiscoverySelectionError comment claimed capability AND expected-issuer are consumer/usage-shaped, but only capability_unavailable maps to validation; expected_issuer_unavailable is api_error (an unadvertised issuer is a metadata fault, matching the other four SDKs). Correct the comment to match the code. Also guard SlowDripAdapter#call against a nil on_data, matching the sibling StreamingAdapter, so the test adapter degrades to a buffered response instead of a NoMethodError if reused without a streaming callback.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d3dfa5c095
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…g in Python
A caller passing timeout=float("inf") (or nan/0/negative/non-numeric) disabled
BOTH httpx's bound and the wall-clock deadline (time.monotonic() > inf never
trips), so a slow-drip endpoint could hold the SSRF-hardened fetch open
indefinitely despite the bounded-timeout contract. Add _normalize_timeout,
mirroring the existing _normalize_body_cap, and apply it at the fetch chokepoint
before both httpx and the deadline.
Also corrects the DiscoverySelectionError docstring: it claimed
expected_issuer_unavailable is consumer/usage-shaped, but only
capability_unavailable maps to validation — every other reason is api_error,
matching the code and the other four SDKs.
The Ruby initializers normalized max_body_bytes but stored @timeout raw, so a Float::INFINITY (or nil/non-numeric/non-positive) timeout made the new monotonic wall-clock deadline infinite (now + inf never trips) — the same slow-drip hang just fixed in Python. Add a shared Fetcher.normalize_timeout and apply it in both Discovery and Resource before building the client and computing the deadline. Adds a test covering both initializers.
There was a problem hiding this comment.
1 issue found across 7 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="ruby/lib/basecamp/oauth/discovery.rb">
<violation number="1" location="ruby/lib/basecamp/oauth/discovery.rb:29">
P3: Complex timeout values now fail construction with `NoMethodError` instead of being treated as invalid and normalized to the default. Restrict the accepted timeout type to real positive numerics (or make `normalize_timeout` safely reject values without `positive?`).</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
_normalize_timeout passed an int straight to math.isfinite/float, both of which raise OverflowError on an int too large to convert to float (e.g. 10**400) — leaking an unexpected exception instead of falling back to the default. Convert under try/except OverflowError first, then finite/positive-check the float.
normalize_timeout called finite?/positive? on any Numeric, but Complex defines neither (complex numbers have no ordering), so a Complex timeout raised NoMethodError during construction. Gate on real? first so Complex normalizes to the default; Integer, Float, and Rational are all real and answer both.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8f63c4905b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…rically
RegistrationEndpoint was typed string while the other optional endpoints
(AuthorizationEndpoint, DeviceAuthorizationEndpoint) are *string — it could not
represent absent-vs-present and diverged from the cross-SDK nullable contract.
Type it *string and map it through directly.
Also normalize the origin-root port numerically. net/url's Port() returns the
RAW token, so a leading-zero default like https://host:000443 passed the range
check (atoi -> 443) yet the string default-port drop ('000443' != '443') left a
non-canonical ':000443' origin. Because isLaunchpadIssuer compares normalized
origins, a Launchpad advertisement spelled that way would be misread as a
distinct BC5 issuer (only-Launchpad metadata could hard-fail as committed, or
create false ambiguity beside BC5). Parse the port once, reuse the int for both
the range check and canonical rendering (leading-zero default drops;
leading-zero non-default renders without zeros). The other four SDKs' parsers
return an int port and normalize this for free. Adds origin-root test cases.
The DiscoverySelectionError comment claimed capability AND expected-issuer are consumer/usage-shaped, but only capability_unavailable maps to validation; expected_issuer_unavailable is api_error. Correct the comment to match the code and the other four SDKs (same fix as the Ruby and Python docstrings).
Resource-first OAuth discovery
Why this exists
Basecamp is introducing a first-party OAuth 2.1 authorization server. Until now these SDKs spoke to a single, well-known OAuth host (37signals' Launchpad). As Basecamp moves toward per-service authorization servers, an SDK can no longer assume where a given API's OAuth endpoints live — it has to discover them.
This PR makes that discovery a first-class, standards-based capability so application developers never hardcode OAuth URLs again. Point an SDK at a Basecamp API host and it resolves the correct authorization server for you — correctly even during the upcoming migration window, when both the legacy host and the new first-party server may be advertised at the same time.
What you get — one uniform API across Go, TypeScript, Python, Ruby, and Kotlin
Three composable operations, identical in shape and behavior in every SDK:
discover(issuer)— RFC 8414 Authorization Server Metadata.discoverProtectedResource(origin)— RFC 9728 Protected Resource Metadata.discoverFromResource(origin)— the orchestrator: start from an API host, read its protected-resource metadata, and select the correct authorization server using a documented selection-and-fallback policy.Where this fits — part 1 of 2
Both land ahead of the server-side rollout so the client ecosystem is ready the moment the new authorization server goes live. Nothing here waits on that rollout to be useful (see Compatibility).
Compatibility — safe to adopt today
discover(...)usage is unchanged; the resource-first API is opt-in.discoverFromResourcetransparently falls back to today's Launchpad flow. Verified live: a Basecamp API host with no resource metadata yet resolves to a softresource_discovery_failedfallback and proceeds against Launchpad, anddiscover("https://launchpad.37signals.com")binds and resolves against the current production metadata.authorization_endpointis now optional in the discovered config, because some authorization servers (e.g. device-only) legitimately omit it. Authorization-code callers should assert it is present before use;token_endpointremains required.Design intent
expectedIssuerselects by exact match. Otherwise a documented Basecamp-profile rule selects the single non-Launchpad issuer, treats two or more as an ambiguous-issuer error rather than guessing, and falls back to Launchpad when none is advertised. A soft fallback (to Launchpad) happens only forresource_discovery_failed/no_as_advertised; once a first-party issuer is committed, every later failure is a hard, typed error that never silently issues a Launchpad request.make oauth-fixtures-check) keep all five SDKs behaviorally identical — same selection rules, same fallback state machine, same rejection of malformed metadata (missing/empty/null endpoints, non-array list fields, non-origin issuers, and parser-differential authority tricks).Verification
make check— all five SDKs (plus Swift), the cross-SDK OAuth conformance suite, and the schema/lint gates — passes at the branch tip. Discovery is additionally verified live against production Launchpad metadata.Summary by cubic
Implements resource-first OAuth discovery (RFC 9728 + RFC 8414) across Go, TypeScript, Python, Ruby, and Kotlin with strict issuer/resource binding, SSRF hardening, and a shared conformance suite. Update: Go now makes
RegistrationEndpointoptional and normalizes origin ports numerically to avoid:000443misclassification during issuer selection.New Features
discover(issuer),discoverProtectedResource(origin), anddiscoverFromResource(origin, expectedIssuer?)with code‑point issuer/resource binding while fetching from normalized origins.expectedIssuer; excludes Launchpad by profile; dedupes duplicate issuers; ambiguity and binding mismatches are hard errors; soft fallback only onresource_discovery_failedorno_as_advertised.api_error. Rejects bare?/#, missing/extra‑slash authority, dangling/signed/zero ports, empty userinfo, invalid characters (C0/space/backslash), and dot‑segment paths. TypeScript truncates non‑2xx discovery bodies in error messages to 500 characters. Ruby adds a monotonic wall‑clock deadline over streaming reads and Ruby/Python normalize non‑finite/invalid discovery timeouts so the deadline cannot be disabled (Python guards oversized ints; Ruby rejects non‑real numerics).make oauth-fixtures-check; scenarios cover raw‑string binding, deduped issuers, present‑null metadata, origin‑root rejections (incl. dot‑segment paths), SSRF defenses, and unmodeled endpoint validation.*_endpointas a non‑empty string; Go propagates caller context cancellation on both hop‑1 and hop‑2 (no soft fallback; no Launchpad request) and makesRegistrationEndpointoptional with numeric port normalization to canonicalize origins. Earlier updates retained — e.g., TypeScript origin validation (missing/extra‑slash authority, non‑string inputs), and Ruby/Kotlin appending the device endpoint to keep positional APIs stable.Migration
authorization_endpointbefore use.discoverFromResource; setexpectedIssuerwhen multiple issuers are advertised.resourceBaseUrl(mutually exclusive withbaseUrl; presence‑checked).Noneexplicitly for a missingauthorization_endpoint; positional field order unchanged.device_authorization_endpointis appended in config types; positional callers remain stable.Written for commit be0f992. Summary will update on new commits.