From bb349a884bd354909ea9f4c6a640839725b6d75f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 14 Jul 2026 16:41:16 -0700 Subject: [PATCH 01/38] OAuth resource-first discovery contract: SPEC.md + fixtures + oauth-fixtures-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the shared contract for BC5's resource-first OAuth discovery (communique §2/§3) ahead of the per-SDK implementations: - SPEC.md §16: two composable discovery ops (RFC 9728 resource + RFC 8414 AS), origin-root profile via the transport parser, code-point issuer binding, explicit-identity + ambiguity selection, the stage-sensitive fallback state machine, per-grant endpoint validation, optional authorization_endpoint, and SSRF hardening (bounded reads, suppressed redirects) on both hops. - conformance/oauth/: data-only fixtures + JSON Schema for the discovery scenarios, with {{RESOURCE_ORIGIN}}/{{ISSUER_ORIGIN}}/{{LAUNCHPAD_ORIGIN}}/ {{BC5_ISSUER}} placeholder substitution per harness. - make oauth-fixtures-check: pinned check-jsonschema (via uvx), wired into check. --- Makefile | 16 +- SPEC.md | 157 +++++++++++++++++- conformance/oauth/README.md | 38 +++++ .../oauth/fixtures/01-two-hop-happy-path.json | 30 ++++ .../fixtures/02-no-as-advertised-absent.json | 16 ++ .../03-no-as-advertised-empty-array.json | 16 ++ .../oauth/fixtures/04-only-launchpad.json | 16 ++ .../oauth/fixtures/05-resource-mismatch.json | 19 +++ .../fixtures/06-hop1-transport-failure.json | 13 ++ .../fixtures/07-issuer-binding-mismatch.json | 32 ++++ .../oauth/fixtures/08-as-metadata-500.json | 30 ++++ .../oauth/fixtures/09-ambiguous-issuers.json | 24 +++ .../fixtures/10-expected-issuer-selected.json | 29 ++++ .../11-expected-issuer-unavailable.json | 24 +++ .../fixtures/12-empty-string-endpoint.json | 20 +++ .../oauth/fixtures/13-device-only-as.json | 20 +++ .../14-origin-root-http-nonlocalhost.json | 12 ++ .../15-origin-root-malformed-port.json | 12 ++ .../16-origin-root-path-rejected.json | 12 ++ .../17-origin-root-ipv6-localhost-accept.json | 11 ++ .../fixtures/18-ssrf-oversized-body.json | 20 +++ .../19-ssrf-redirect-not-followed.json | 16 ++ .../fixtures/20-invalid-issuer-origin.json | 23 +++ .../21-authorization-servers-not-array.json | 19 +++ .../fixtures/22-grant-types-not-array.json | 20 +++ .../23-launchpad-lookalike-with-path.json | 23 +++ conformance/oauth/schema.json | 100 +++++++++++ 27 files changed, 760 insertions(+), 8 deletions(-) create mode 100644 conformance/oauth/README.md create mode 100644 conformance/oauth/fixtures/01-two-hop-happy-path.json create mode 100644 conformance/oauth/fixtures/02-no-as-advertised-absent.json create mode 100644 conformance/oauth/fixtures/03-no-as-advertised-empty-array.json create mode 100644 conformance/oauth/fixtures/04-only-launchpad.json create mode 100644 conformance/oauth/fixtures/05-resource-mismatch.json create mode 100644 conformance/oauth/fixtures/06-hop1-transport-failure.json create mode 100644 conformance/oauth/fixtures/07-issuer-binding-mismatch.json create mode 100644 conformance/oauth/fixtures/08-as-metadata-500.json create mode 100644 conformance/oauth/fixtures/09-ambiguous-issuers.json create mode 100644 conformance/oauth/fixtures/10-expected-issuer-selected.json create mode 100644 conformance/oauth/fixtures/11-expected-issuer-unavailable.json create mode 100644 conformance/oauth/fixtures/12-empty-string-endpoint.json create mode 100644 conformance/oauth/fixtures/13-device-only-as.json create mode 100644 conformance/oauth/fixtures/14-origin-root-http-nonlocalhost.json create mode 100644 conformance/oauth/fixtures/15-origin-root-malformed-port.json create mode 100644 conformance/oauth/fixtures/16-origin-root-path-rejected.json create mode 100644 conformance/oauth/fixtures/17-origin-root-ipv6-localhost-accept.json create mode 100644 conformance/oauth/fixtures/18-ssrf-oversized-body.json create mode 100644 conformance/oauth/fixtures/19-ssrf-redirect-not-followed.json create mode 100644 conformance/oauth/fixtures/20-invalid-issuer-origin.json create mode 100644 conformance/oauth/fixtures/21-authorization-servers-not-array.json create mode 100644 conformance/oauth/fixtures/22-grant-types-not-array.json create mode 100644 conformance/oauth/fixtures/23-launchpad-lookalike-with-path.json create mode 100644 conformance/oauth/schema.json diff --git a/Makefile b/Makefile index 29e6728fa..10ffde063 100644 --- a/Makefile +++ b/Makefile @@ -373,7 +373,18 @@ py-clean: # Conformance Test targets #------------------------------------------------------------------------------ -.PHONY: conformance conformance-go conformance-kotlin conformance-typescript conformance-ruby conformance-python conformance-build +.PHONY: conformance conformance-go conformance-kotlin conformance-typescript conformance-ruby conformance-python conformance-build oauth-fixtures-check + +# Pinned validator for the data-only OAuth discovery fixtures. Run via uvx so the +# version is reproducible without a global install; the schema is separate from +# the operation-dispatch conformance/schema.json (unusable for OAuth data). +CHECK_JSONSCHEMA_VERSION := 0.35.0 + +# Validate OAuth resource-first discovery fixtures against their JSON Schema. +oauth-fixtures-check: + @echo "==> Validating OAuth discovery fixtures..." + uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \ + --schemafile conformance/oauth/schema.json conformance/oauth/fixtures/*.json # Build conformance test runner conformance-build: @@ -406,7 +417,7 @@ conformance-python: cd conformance/runner/python && uv sync && uv run python runner.py # Run all conformance tests -conformance: conformance-go conformance-kotlin conformance-typescript conformance-ruby conformance-python +conformance: oauth-fixtures-check conformance-go conformance-kotlin conformance-typescript conformance-ruby conformance-python @echo "==> Conformance tests passed" #------------------------------------------------------------------------------ @@ -644,6 +655,7 @@ help: @echo " conformance-ruby Run Ruby conformance tests" @echo " conformance-python Run Python conformance tests" @echo " conformance-build Build Go conformance test runner" + @echo " oauth-fixtures-check Validate OAuth discovery fixtures against their schema" @echo "" @echo "Ruby SDK:" @echo " rb-generate Generate types and metadata from OpenAPI" diff --git a/SPEC.md b/SPEC.md index a98b93a63..2e6bbecd5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -912,17 +912,162 @@ FUNCTION generateState() → String END ``` -### RFC 8414 Discovery +### Resource-First Discovery `[conformance]` + +*Rubric-critical: BC5 OAuth go-live (communique §2/§3).* + +BC5's Authorization Server (AS) metadata lives **only** at the canonical issuer +(the web host, e.g. `https://3.basecamp.com`). Probing the API host +(`3.basecampapi.com/.well-known/oauth-authorization-server`) 404s permanently +because RFC 8414 §3.3 requires `issuer` to equal the URL the metadata was +derived from, and BC5's issuer is the web host. Discovery therefore starts from +the **resource** (RFC 9728) and composes with AS discovery (RFC 8414). + +**Two composable operations, never merged:** + +1. `discover(issuerURL)` — RFC 8414 AS metadata (unchanged public op). +2. `discoverProtectedResource(resourceOrigin)` — RFC 9728 resource metadata. + +A third orchestrator, `discoverFromResource(resourceOrigin, expectedIssuer?)`, +composes them and encodes selection + the stage-sensitive fallback below. + +#### Origin-root profile `[conformance]` + +Both hops derive a well-known path from a caller- or metadata-supplied origin. +The origin MUST be parsed with the SDK's **own transport URL parser** (Go +`net/url`, JS `URL`, Python `urllib`, Ruby `URI`, Kotlin the extracted Ktor +`parseAbsoluteUrl`) — never a hand-rolled regex, because bracketed IPv6 +(`http://[::1]:3000`) and ports break naive regexes and can disagree with the +host the client actually dials. ``` -FUNCTION discoverOAuthEndpoints(issuer: String) → OAuthEndpoints - 1. Fetch issuer + "/.well-known/oauth-authorization-server". (Basecamp's Launchpad issuer is at the origin root; RFC 8414 path-segment rules do not apply.) - 2. Parse JSON response. - 3. Extract authorization_endpoint, token_endpoint. - 4. → OAuthEndpoints +FUNCTION requireOriginRoot(raw: String) → Origin | raise usage + 1. parsed = transportParser.parse(raw) # fail-closed on parse error + 2. REQUIRE scheme ∈ {https} OR (scheme == http AND isLocalhost(host)) + 3. REQUIRE host present + 4. REQUIRE port absent OR a valid numeric port + 5. REQUIRE path is empty OR exactly "/" + 6. REQUIRE no query, no fragment, no userinfo + 7. → origin = scheme "://" host [":" port] + # A bad caller-supplied origin is a usage error; a bad *advertised* issuer + # origin is a discovery-classification failure (see fallback table). END ``` +Accept `http://[::1]:3000`; reject `http://[::1]:notaport`, any path beyond `/`, +and any query/fragment/userinfo. + +#### Hop 1 — RFC 9728 resource metadata `[conformance]` + +``` +FUNCTION discoverProtectedResource(resourceOrigin: String) → ProtectedResourceMetadata + 1. origin = requireOriginRoot(resourceOrigin) + 2. doc = fetchJSON(origin + "/.well-known/oauth-protected-resource") # SSRF-hardened + 3. REQUIRE doc.resource present and non-empty + 4. REQUIRE doc.resource IDENTICAL BY CODE-POINT to the resource identifier used + (the requested resourceOrigin); NO normalization + 5. authorization_servers is OPTIONAL; preserve absent vs [] DISTINCTLY + 6. → ProtectedResourceMetadata{ resource, authorizationServers? } +END +``` + +`ProtectedResourceMetadata` models `authorization_servers` as a nullable list so +"key absent" (BC5 omits it while dark) and "present but empty `[]`" stay +distinguishable at the type level, even though both select Launchpad. + +#### Hop 2 — RFC 8414 AS metadata with issuer binding `[conformance]` + +``` +FUNCTION discover(issuerURL: String) → OAuthConfig + 1. origin = requireOriginRoot(issuerURL) + 2. doc = fetchJSON(origin + "/.well-known/oauth-authorization-server") # SSRF-hardened + 3. REQUIRE doc.issuer present, non-empty, and IDENTICAL BY CODE-POINT to + issuerURL (the advertised issuer string); NO normalization # RFC 8414 §3.3/§4 + 4. Universal endpoint validation: token_endpoint present + non-empty; + any endpoint field that IS present must be non-empty (reject ""). + 5. authorization_endpoint is OPTIONAL (device-only AS omit it). + 6. → OAuthConfig{ issuer, tokenEndpoint, authorizationEndpoint?, ... } +END +``` + +**Per-grant endpoint validation is the consumer's, not `discover`'s.** `discover` +no longer requires `authorization_endpoint`. Each consumer asserts the endpoints +its grant needs: +- authorization-code: `authorization_endpoint` + `token_endpoint`. +- device flow: `device_authorization_endpoint` + `token_endpoint`. + +#### `authorization_endpoint` is now OPTIONAL `[static]` + +Previously required in every SDK model; now optional/nullable, preserving absent +vs present-empty (Go `*string`, TS `authorizationEndpoint?`, Python `str | None`, +Ruby kw default `nil`, Kotlin `String? = null`). `token_endpoint` stays required. +**Public-compatibility impact:** a previously-always-present field becomes +optional — authorization-code consumers MUST assert presence before use. + +#### Selection — one name, one rule `[conformance]` + +`expectedIssuer` is the single selection parameter (`preferredIssuer` is dropped). + +``` +FUNCTION selectIssuer(advertised: [String], expectedIssuer?: String) + IF expectedIssuer provided: # explicit, authoritative + IF ∃ m ∈ advertised with m == expectedIssuer (code-point): SELECT m + ELSE raise expected_issuer_unavailable # HARD + ELSE: # Basecamp-profile heuristic + nonLaunchpad = advertised \ {Launchpad} # (identification by exclusion) + IF |nonLaunchpad| == 1: SELECT that member # documented heuristic + IF |nonLaunchpad| ≥ 2: raise ambiguous_issuers # HARD — never guess + IF |nonLaunchpad| == 0: soft no_as_advertised → Launchpad +END +``` + +The SDK does not know BC5's canonical issuer a priori; during migration the +advertised set is {BC5 canonical, Launchpad}, so exactly-one-non-Launchpad +identifies BC5 by exclusion. Callers wanting no heuristic pass `expectedIssuer`. + +#### Stage-sensitive fallback state machine `[conformance]` + +Launchpad fallback is allowed **only before BC5 is committed**. Once valid +resource metadata advertises BC5 and it is selected, every later failure is +**fatal — no Launchpad request may be issued.** + +| Stage / failure | Outcome | +|---|---| +| Hop-1 fetch/parse fails, or `resource` mismatch | **soft** `resource_discovery_failed` → Launchpad | +| Valid resource metadata omits BC5 (absent / `[]` / only-Launchpad) | **soft** `no_as_advertised` → select Launchpad | +| ≥2 non-Launchpad issuers advertised (no `expectedIssuer`) | **hard** `ambiguous_issuers` | +| `expectedIssuer` provided but not advertised | **hard** `expected_issuer_unavailable` | +| BC5 committed → invalid BC5 issuer origin | **hard** `invalid_issuer_origin` | +| BC5 committed → AS-metadata fetch fails (5xx / network) | **hard** `as_fetch_failed` | +| BC5 committed → issuer binding mismatch | **hard** `issuer_mismatch` | +| BC5 committed → missing per-grant endpoint/capability | **hard** `capability_unavailable` (consumer-asserted) | + +`discoverFromResource` returns `Selected(config)` **or** `FallBack(reason)` where +`reason ∈ {resource_discovery_failed, no_as_advertised}` ONLY, and **raises** a +typed selection error for every hard case. **No consumer may convert a raise into +a Launchpad request.** ("BC5 committed" = valid resource metadata advertised a +BC5 issuer that was then selected.) + +#### SSRF hardening — both hops, all five SDKs `[conformance]` + +RFC 9728 §7.7 flags SSRF via attacker-influenced metadata; advertised AS URLs are +untrusted input. Every `fetchJSON` above MUST: + +1. **Require HTTPS** (localhost exempt) — validated by `requireOriginRoot` before + any socket is opened. +2. **Bound the timeout.** +3. **Suppress redirects** — fetch `redirect:"error"` (TS) / `CheckRedirect: + ErrUseLastResponse` (Go) / `followRedirects=false` (Ktor) / + `follow_redirects=False` (httpx) / no redirect middleware (Faraday) — or + re-validate each target against the origin-root profile. +4. **Read the body under a genuine, bounded/streaming cap that aborts once the + limit is exceeded** — NOT a post-hoc size check on an already-buffered body. + Python (`httpx.stream()`) and Ruby (Faraday `on_data`/capped read) must switch + from buffered reads to bounded streaming reads; Go/Kotlin/TS gain bounded reads + they lack today. + +Non-2xx on either hop → `api_error` (not `network`). + ### Launchpad Legacy Format The Basecamp Launchpad OAuth endpoints use a mix of standard and legacy parameters: diff --git a/conformance/oauth/README.md b/conformance/oauth/README.md new file mode 100644 index 000000000..48946d500 --- /dev/null +++ b/conformance/oauth/README.md @@ -0,0 +1,38 @@ +# OAuth resource-first discovery fixtures + +Data-only, cross-language fixtures for BC5's resource-first OAuth discovery +(SPEC.md §16 "Resource-First Discovery"). Unlike `conformance/tests/*.json` +(operation-dispatch, driven by the language runners), these describe discovery +**scenarios** — the two well-known documents a mock serves and the expected +selection/fallback/raise outcome. + +## Validation + +`schema.json` is the contract. `make oauth-fixtures-check` validates every +fixture against it with a pinned `check-jsonschema` (run through `uvx`, so no +global install is required). + +## Placeholder substitution + +Fixtures are host-agnostic. Each SDK's discovery test substitutes these tokens +with its own mock origins **before** driving the scenario, so issuer/resource +binding stays code-point-exact against whatever ephemeral host the mock listens +on: + +| Placeholder | Meaning | +|---|---| +| `{{RESOURCE_ORIGIN}}` | The API/resource host (hop-1 `oauth-protected-resource`). | +| `{{ISSUER_ORIGIN}}` | A generic issuer host for direct `discover` (hop-2) cases. | +| `{{LAUNCHPAD_ORIGIN}}` | The Launchpad AS host (fallback target). | +| `{{BC5_ISSUER}}` | BC5's canonical issuer (web host). | + +Literal origins (e.g. `http://[::1]:3000`, `https://attacker.example.com`) are +intentional and must **not** be substituted. + +## Fields + +See `schema.json` for the authoritative shape. Each fixture names an +`operation` (`discoverFromResource` | `discoverProtectedResource` | `discover`), +optional `hop1`/`hop2` mock exchanges, and an `expect` block. Hard cases set +`expect.launchpadContacted: false` — the harness must assert the Launchpad host +received **zero** requests. diff --git a/conformance/oauth/fixtures/01-two-hop-happy-path.json b/conformance/oauth/fixtures/01-two-hop-happy-path.json new file mode 100644 index 000000000..1b5f542b2 --- /dev/null +++ b/conformance/oauth/fixtures/01-two-hop-happy-path.json @@ -0,0 +1,30 @@ +{ + "name": "two-hop-happy-path", + "description": "Resource metadata advertises BC5 + Launchpad; exactly one non-Launchpad issuer is selected by exclusion, its AS metadata binds, config is returned.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": true, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": ["{{BC5_ISSUER}}", "{{LAUNCHPAD_ORIGIN}}"] + } + }, + "hop2": { + "origin": "{{BC5_ISSUER}}", + "status": 200, + "body": { + "issuer": "{{BC5_ISSUER}}", + "authorization_endpoint": "{{BC5_ISSUER}}/oauth/authorize", + "token_endpoint": "{{BC5_ISSUER}}/oauth/token", + "device_authorization_endpoint": "{{BC5_ISSUER}}/oauth/device", + "grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"] + } + }, + "expect": { + "outcome": "selected", + "selectedIssuer": "{{BC5_ISSUER}}", + "launchpadContacted": false + } +} diff --git a/conformance/oauth/fixtures/02-no-as-advertised-absent.json b/conformance/oauth/fixtures/02-no-as-advertised-absent.json new file mode 100644 index 000000000..e318656dc --- /dev/null +++ b/conformance/oauth/fixtures/02-no-as-advertised-absent.json @@ -0,0 +1,16 @@ +{ + "name": "no-as-advertised-absent", + "description": "authorization_servers key absent (BC5 dark posture) → soft no_as_advertised → Launchpad.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { "resource": "{{RESOURCE_ORIGIN}}" } + }, + "expect": { + "outcome": "fallback", + "fallbackReason": "no_as_advertised", + "launchpadContacted": true + } +} diff --git a/conformance/oauth/fixtures/03-no-as-advertised-empty-array.json b/conformance/oauth/fixtures/03-no-as-advertised-empty-array.json new file mode 100644 index 000000000..dea878790 --- /dev/null +++ b/conformance/oauth/fixtures/03-no-as-advertised-empty-array.json @@ -0,0 +1,16 @@ +{ + "name": "no-as-advertised-empty-array", + "description": "authorization_servers present but empty [] (distinct from absent) → soft no_as_advertised → Launchpad.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { "resource": "{{RESOURCE_ORIGIN}}", "authorization_servers": [] } + }, + "expect": { + "outcome": "fallback", + "fallbackReason": "no_as_advertised", + "launchpadContacted": true + } +} diff --git a/conformance/oauth/fixtures/04-only-launchpad.json b/conformance/oauth/fixtures/04-only-launchpad.json new file mode 100644 index 000000000..d7758cb62 --- /dev/null +++ b/conformance/oauth/fixtures/04-only-launchpad.json @@ -0,0 +1,16 @@ +{ + "name": "only-launchpad", + "description": "authorization_servers lists only Launchpad → zero non-Launchpad → soft no_as_advertised → Launchpad selected.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { "resource": "{{RESOURCE_ORIGIN}}", "authorization_servers": ["{{LAUNCHPAD_ORIGIN}}"] } + }, + "expect": { + "outcome": "fallback", + "fallbackReason": "no_as_advertised", + "launchpadContacted": true + } +} diff --git a/conformance/oauth/fixtures/05-resource-mismatch.json b/conformance/oauth/fixtures/05-resource-mismatch.json new file mode 100644 index 000000000..760206e42 --- /dev/null +++ b/conformance/oauth/fixtures/05-resource-mismatch.json @@ -0,0 +1,19 @@ +{ + "name": "resource-mismatch", + "description": "Hop-1 metadata.resource differs by code-point from the requested resource origin → soft resource_discovery_failed → Launchpad.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { + "resource": "https://attacker.example.com", + "authorization_servers": ["{{BC5_ISSUER}}", "{{LAUNCHPAD_ORIGIN}}"] + } + }, + "expect": { + "outcome": "fallback", + "fallbackReason": "resource_discovery_failed", + "launchpadContacted": true + } +} diff --git a/conformance/oauth/fixtures/06-hop1-transport-failure.json b/conformance/oauth/fixtures/06-hop1-transport-failure.json new file mode 100644 index 000000000..b9b26fbd5 --- /dev/null +++ b/conformance/oauth/fixtures/06-hop1-transport-failure.json @@ -0,0 +1,13 @@ +{ + "name": "hop1-transport-failure", + "description": "Hop-1 fetch fails at the transport layer (connection refused) → soft resource_discovery_failed → Launchpad.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { "transportError": true }, + "expect": { + "outcome": "fallback", + "fallbackReason": "resource_discovery_failed", + "launchpadContacted": true + } +} diff --git a/conformance/oauth/fixtures/07-issuer-binding-mismatch.json b/conformance/oauth/fixtures/07-issuer-binding-mismatch.json new file mode 100644 index 000000000..6fe8cf276 --- /dev/null +++ b/conformance/oauth/fixtures/07-issuer-binding-mismatch.json @@ -0,0 +1,32 @@ +{ + "name": "issuer-binding-mismatch", + "description": "BC5 committed, but hop-2 AS metadata.issuer differs by code-point from the advertised issuer \u2192 HARD issuer_mismatch, no Launchpad request.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": true, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": [ + "{{BC5_ISSUER}}", + "{{LAUNCHPAD_ORIGIN}}" + ] + } + }, + "hop2": { + "origin": "{{BC5_ISSUER}}", + "status": 200, + "body": { + "issuer": "https://impostor.example.com", + "authorization_endpoint": "{{BC5_ISSUER}}/oauth/authorize", + "token_endpoint": "{{BC5_ISSUER}}/oauth/token" + } + }, + "expect": { + "outcome": "raise", + "error": "issuer_mismatch", + "launchpadContacted": false, + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/08-as-metadata-500.json b/conformance/oauth/fixtures/08-as-metadata-500.json new file mode 100644 index 000000000..5eb4e28db --- /dev/null +++ b/conformance/oauth/fixtures/08-as-metadata-500.json @@ -0,0 +1,30 @@ +{ + "name": "as-metadata-500", + "description": "BC5 committed, but hop-2 AS metadata fetch returns 500 \u2192 HARD as_fetch_failed, no Launchpad request.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": true, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": [ + "{{BC5_ISSUER}}", + "{{LAUNCHPAD_ORIGIN}}" + ] + } + }, + "hop2": { + "origin": "{{BC5_ISSUER}}", + "status": 500, + "body": { + "error": "internal_server_error" + } + }, + "expect": { + "outcome": "raise", + "error": "as_fetch_failed", + "launchpadContacted": false, + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/09-ambiguous-issuers.json b/conformance/oauth/fixtures/09-ambiguous-issuers.json new file mode 100644 index 000000000..56f23f0a5 --- /dev/null +++ b/conformance/oauth/fixtures/09-ambiguous-issuers.json @@ -0,0 +1,24 @@ +{ + "name": "ambiguous-issuers", + "description": "Two non-Launchpad issuers advertised, no expectedIssuer \u2192 HARD ambiguous_issuers; the SDK never guesses by exclusion.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": [ + "{{BC5_ISSUER}}", + "https://other-as.example.com", + "{{LAUNCHPAD_ORIGIN}}" + ] + } + }, + "expect": { + "outcome": "raise", + "error": "ambiguous_issuers", + "launchpadContacted": false, + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/10-expected-issuer-selected.json b/conformance/oauth/fixtures/10-expected-issuer-selected.json new file mode 100644 index 000000000..51cab806b --- /dev/null +++ b/conformance/oauth/fixtures/10-expected-issuer-selected.json @@ -0,0 +1,29 @@ +{ + "name": "expected-issuer-selected", + "description": "expectedIssuer names an advertised member (even amid multiple non-Launchpad issuers) → selected non-heuristically.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "expectedIssuer": "{{BC5_ISSUER}}", + "bc5Committed": true, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": ["{{BC5_ISSUER}}", "https://other-as.example.com", "{{LAUNCHPAD_ORIGIN}}"] + } + }, + "hop2": { + "origin": "{{BC5_ISSUER}}", + "status": 200, + "body": { + "issuer": "{{BC5_ISSUER}}", + "authorization_endpoint": "{{BC5_ISSUER}}/oauth/authorize", + "token_endpoint": "{{BC5_ISSUER}}/oauth/token" + } + }, + "expect": { + "outcome": "selected", + "selectedIssuer": "{{BC5_ISSUER}}", + "launchpadContacted": false + } +} diff --git a/conformance/oauth/fixtures/11-expected-issuer-unavailable.json b/conformance/oauth/fixtures/11-expected-issuer-unavailable.json new file mode 100644 index 000000000..6821868c1 --- /dev/null +++ b/conformance/oauth/fixtures/11-expected-issuer-unavailable.json @@ -0,0 +1,24 @@ +{ + "name": "expected-issuer-unavailable", + "description": "expectedIssuer provided but not in the advertised set \u2192 HARD expected_issuer_unavailable, no Launchpad request.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "expectedIssuer": "https://not-advertised.example.com", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": [ + "{{BC5_ISSUER}}", + "{{LAUNCHPAD_ORIGIN}}" + ] + } + }, + "expect": { + "outcome": "raise", + "error": "expected_issuer_unavailable", + "launchpadContacted": false, + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/12-empty-string-endpoint.json b/conformance/oauth/fixtures/12-empty-string-endpoint.json new file mode 100644 index 000000000..9112b1c41 --- /dev/null +++ b/conformance/oauth/fixtures/12-empty-string-endpoint.json @@ -0,0 +1,20 @@ +{ + "name": "empty-string-endpoint", + "description": "Direct hop-2 discover: AS metadata with a present-but-empty token_endpoint must be rejected as invalid metadata (exercises Ruby/Kotlin which historically accepted \"\").", + "operation": "discover", + "issuerOrigin": "{{ISSUER_ORIGIN}}", + "hop2": { + "origin": "{{ISSUER_ORIGIN}}", + "status": 200, + "body": { + "issuer": "{{ISSUER_ORIGIN}}", + "authorization_endpoint": "{{ISSUER_ORIGIN}}/oauth/authorize", + "token_endpoint": "" + } + }, + "expect": { + "outcome": "raise", + "error": "invalid_metadata", + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/13-device-only-as.json b/conformance/oauth/fixtures/13-device-only-as.json new file mode 100644 index 000000000..0f0f45ea1 --- /dev/null +++ b/conformance/oauth/fixtures/13-device-only-as.json @@ -0,0 +1,20 @@ +{ + "name": "device-only-as", + "description": "AS metadata omits authorization_endpoint (device-only client). discover succeeds; the auth-code consumer must assert presence and error, the device consumer proceeds.", + "operation": "discover", + "issuerOrigin": "{{ISSUER_ORIGIN}}", + "hop2": { + "origin": "{{ISSUER_ORIGIN}}", + "status": 200, + "body": { + "issuer": "{{ISSUER_ORIGIN}}", + "token_endpoint": "{{ISSUER_ORIGIN}}/oauth/token", + "device_authorization_endpoint": "{{ISSUER_ORIGIN}}/oauth/device", + "grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"] + } + }, + "expect": { + "outcome": "selected", + "selectedIssuer": "{{ISSUER_ORIGIN}}" + } +} diff --git a/conformance/oauth/fixtures/14-origin-root-http-nonlocalhost.json b/conformance/oauth/fixtures/14-origin-root-http-nonlocalhost.json new file mode 100644 index 000000000..a5c8a4079 --- /dev/null +++ b/conformance/oauth/fixtures/14-origin-root-http-nonlocalhost.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-http-nonlocalhost", + "description": "Caller-supplied resource origin is plain http on a non-localhost host \u2192 usage error before any socket opens.", + "operation": "discoverProtectedResource", + "resourceOrigin": "http://api.example.com", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/conformance/oauth/fixtures/15-origin-root-malformed-port.json b/conformance/oauth/fixtures/15-origin-root-malformed-port.json new file mode 100644 index 000000000..ac2f20e5c --- /dev/null +++ b/conformance/oauth/fixtures/15-origin-root-malformed-port.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-malformed-port", + "description": "Resource origin with a non-numeric port is rejected by the transport parser \u2192 usage error.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https://api.example.com:notaport", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/conformance/oauth/fixtures/16-origin-root-path-rejected.json b/conformance/oauth/fixtures/16-origin-root-path-rejected.json new file mode 100644 index 000000000..e640bb97a --- /dev/null +++ b/conformance/oauth/fixtures/16-origin-root-path-rejected.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-path-rejected", + "description": "Resource origin carrying a path beyond \"/\" (or query/fragment/userinfo) violates the origin-root profile \u2192 usage error.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https://api.example.com/tenant/1", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/conformance/oauth/fixtures/17-origin-root-ipv6-localhost-accept.json b/conformance/oauth/fixtures/17-origin-root-ipv6-localhost-accept.json new file mode 100644 index 000000000..82a22e993 --- /dev/null +++ b/conformance/oauth/fixtures/17-origin-root-ipv6-localhost-accept.json @@ -0,0 +1,11 @@ +{ + "name": "origin-root-ipv6-localhost-accept", + "description": "Bracketed IPv6 localhost origin with an explicit port is accepted by the transport parser (regex parsers break here). Harness serves hop-1 at the literal origin.", + "operation": "discoverProtectedResource", + "resourceOrigin": "http://[::1]:3000", + "hop1": { + "status": 200, + "body": { "resource": "http://[::1]:3000", "authorization_servers": ["{{LAUNCHPAD_ORIGIN}}"] } + }, + "expect": { "outcome": "selected", "selectedIssuer": "http://[::1]:3000" } +} diff --git a/conformance/oauth/fixtures/18-ssrf-oversized-body.json b/conformance/oauth/fixtures/18-ssrf-oversized-body.json new file mode 100644 index 000000000..809424cfe --- /dev/null +++ b/conformance/oauth/fixtures/18-ssrf-oversized-body.json @@ -0,0 +1,20 @@ +{ + "name": "ssrf-oversized-body", + "description": "AS metadata body exceeds the SSRF read cap. The bounded/streaming read must abort before buffering the whole body (memory bounded, not a post-hoc size check) \u2192 api_error.", + "operation": "discover", + "issuerOrigin": "{{ISSUER_ORIGIN}}", + "hop2": { + "origin": "{{ISSUER_ORIGIN}}", + "status": 200, + "oversized": true, + "body": { + "issuer": "{{ISSUER_ORIGIN}}", + "token_endpoint": "{{ISSUER_ORIGIN}}/oauth/token" + } + }, + "expect": { + "outcome": "raise", + "error": "api_error", + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/19-ssrf-redirect-not-followed.json b/conformance/oauth/fixtures/19-ssrf-redirect-not-followed.json new file mode 100644 index 000000000..dc3843a6e --- /dev/null +++ b/conformance/oauth/fixtures/19-ssrf-redirect-not-followed.json @@ -0,0 +1,16 @@ +{ + "name": "ssrf-redirect-not-followed", + "description": "AS metadata endpoint answers a 3xx to a foreign host. Redirects are suppressed, so the fetch surfaces the non-2xx as api_error rather than chasing an attacker-controlled target.", + "operation": "discover", + "issuerOrigin": "{{ISSUER_ORIGIN}}", + "hop2": { + "origin": "{{ISSUER_ORIGIN}}", + "status": 302, + "redirectTo": "https://attacker.example.com/.well-known/oauth-authorization-server" + }, + "expect": { + "outcome": "raise", + "error": "api_error", + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/20-invalid-issuer-origin.json b/conformance/oauth/fixtures/20-invalid-issuer-origin.json new file mode 100644 index 000000000..376db2529 --- /dev/null +++ b/conformance/oauth/fixtures/20-invalid-issuer-origin.json @@ -0,0 +1,23 @@ +{ + "name": "invalid-issuer-origin", + "description": "BC5 selected by exclusion, but the advertised issuer string is not a valid origin root (carries a path) \u2192 HARD invalid_issuer_origin, no Launchpad request.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": true, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": [ + "https://bc5.example.com/oauth", + "{{LAUNCHPAD_ORIGIN}}" + ] + } + }, + "expect": { + "outcome": "raise", + "error": "invalid_issuer_origin", + "launchpadContacted": false, + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/21-authorization-servers-not-array.json b/conformance/oauth/fixtures/21-authorization-servers-not-array.json new file mode 100644 index 000000000..b1287613d --- /dev/null +++ b/conformance/oauth/fixtures/21-authorization-servers-not-array.json @@ -0,0 +1,19 @@ +{ + "name": "authorization-servers-not-array", + "description": "Hop-1 authorization_servers is a JSON string, not an array. Structural validation must reject it as malformed metadata (never iterate its characters as issuers) → soft resource_discovery_failed → Launchpad.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": "{{BC5_ISSUER}}" + } + }, + "expect": { + "outcome": "fallback", + "fallbackReason": "resource_discovery_failed", + "launchpadContacted": true + } +} diff --git a/conformance/oauth/fixtures/22-grant-types-not-array.json b/conformance/oauth/fixtures/22-grant-types-not-array.json new file mode 100644 index 000000000..920930cec --- /dev/null +++ b/conformance/oauth/fixtures/22-grant-types-not-array.json @@ -0,0 +1,20 @@ +{ + "name": "grant-types-not-array", + "description": "AS metadata grant_types_supported is a JSON string, not an array. Structural validation must reject it as invalid metadata (never substring-match it, which would falsely enable device flow) → api_error.", + "operation": "discover", + "issuerOrigin": "{{ISSUER_ORIGIN}}", + "hop2": { + "origin": "{{ISSUER_ORIGIN}}", + "status": 200, + "body": { + "issuer": "{{ISSUER_ORIGIN}}", + "token_endpoint": "{{ISSUER_ORIGIN}}/oauth/token", + "grant_types_supported": "urn:ietf:params:oauth:grant-type:device_code" + } + }, + "expect": { + "outcome": "raise", + "error": "invalid_metadata", + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/fixtures/23-launchpad-lookalike-with-path.json b/conformance/oauth/fixtures/23-launchpad-lookalike-with-path.json new file mode 100644 index 000000000..fa883bb8a --- /dev/null +++ b/conformance/oauth/fixtures/23-launchpad-lookalike-with-path.json @@ -0,0 +1,23 @@ +{ + "name": "launchpad-lookalike-with-path", + "description": "A Launchpad look-alike carrying a path (…/path) is NOT the Launchpad issuer under the origin-root profile, so the selection heuristic must not exclude it as Launchpad. With BC5 also advertised there are two non-Launchpad candidates → HARD ambiguous_issuers; the SDK never silently drops the malformed value nor guesses BC5. Guards the origin-normalization bug where comparing only URL.origin/same_origin would misclassify the look-alike as Launchpad.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": [ + "{{LAUNCHPAD_ORIGIN}}/path", + "{{BC5_ISSUER}}" + ] + } + }, + "expect": { + "outcome": "raise", + "error": "ambiguous_issuers", + "launchpadContacted": false, + "errorCategory": "api_error" + } +} diff --git a/conformance/oauth/schema.json b/conformance/oauth/schema.json new file mode 100644 index 000000000..c775c3f12 --- /dev/null +++ b/conformance/oauth/schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basecamp.com/schemas/oauth-discovery-fixture.json", + "title": "OAuth Resource-First Discovery Fixture", + "description": "Data-only, cross-language fixture describing one resource-first OAuth discovery scenario. Placeholders {{RESOURCE_ORIGIN}}, {{ISSUER_ORIGIN}}, {{LAUNCHPAD_ORIGIN}}, {{BC5_ISSUER}} are substituted by each SDK's test harness with its own mock origins so issuer/resource binding stays code-point-exact. This schema is validated by `make oauth-fixtures-check`; the existing conformance/schema.json is operation-dispatch and unusable for OAuth.", + "type": "object", + "additionalProperties": false, + "required": ["name", "operation", "expect"], + "properties": { + "name": { "type": "string", "description": "Kebab-case fixture identifier." }, + "description": { "type": "string" }, + "operation": { + "type": "string", + "enum": ["discoverFromResource", "discoverProtectedResource", "discover"], + "description": "Which discovery entry point the harness drives." + }, + "resourceOrigin": { + "type": "string", + "description": "Caller-supplied resource origin for hop 1 / discoverProtectedResource. Usually {{RESOURCE_ORIGIN}}; may be a malformed value to exercise requireOriginRoot rejection." + }, + "issuerOrigin": { + "type": "string", + "description": "Caller-supplied issuer origin for the `discover` operation (hop-2 in isolation)." + }, + "expectedIssuer": { + "type": "string", + "description": "Optional explicit-identity selection parameter." + }, + "bc5Committed": { + "type": "boolean", + "description": "Documentation aid: true once valid resource metadata advertised a BC5 issuer that was selected. Governs whether hop-2 failures are fatal." + }, + "hop1": { "$ref": "#/$defs/exchange", "description": "Mock response at {resourceOrigin}/.well-known/oauth-protected-resource." }, + "hop2": { + "allOf": [{ "$ref": "#/$defs/exchange" }], + "description": "Mock response at the selected issuer's /.well-known/oauth-authorization-server. `origin` names which placeholder host serves it." + }, + "expect": { "$ref": "#/$defs/expect" } + }, + "$defs": { + "exchange": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "description": "A mocked HTTP exchange for one discovery hop. Must specify at least one field — an empty object would silently mock nothing.", + "properties": { + "origin": { + "type": "string", + "description": "Placeholder host that answers this hop (e.g. {{BC5_ISSUER}} or {{LAUNCHPAD_ORIGIN}})." + }, + "status": { "type": "integer", "minimum": 100, "maximum": 599 }, + "transportError": { "type": "boolean", "description": "Simulate a connection-level failure (no HTTP response)." }, + "body": { + "description": "Raw JSON document the mock returns. Values may embed placeholders. `oversized: true` requests a body larger than the SSRF cap.", + "type": ["object", "array", "string", "null"] + }, + "oversized": { "type": "boolean", "description": "Serve a body exceeding the SSRF read cap to assert bounded reads." }, + "redirectTo": { "type": "string", "description": "Serve a 3xx to this location to assert redirects are not followed." } + } + }, + "expect": { + "type": "object", + "additionalProperties": false, + "required": ["outcome"], + "properties": { + "outcome": { "type": "string", "enum": ["selected", "fallback", "raise"] }, + "selectedIssuer": { "type": "string", "description": "Placeholder for the issuer selected on `selected`." }, + "fallbackReason": { + "type": "string", + "enum": ["resource_discovery_failed", "no_as_advertised"], + "description": "Soft reason on `fallback`. Only these two are ever returned; everything else raises." + }, + "error": { + "type": "string", + "enum": [ + "ambiguous_issuers", + "expected_issuer_unavailable", + "invalid_issuer_origin", + "as_fetch_failed", + "issuer_mismatch", + "capability_unavailable", + "invalid_metadata", + "api_error", + "usage" + ], + "description": "Typed selection/validation error on `raise`." + }, + "launchpadContacted": { + "type": "boolean", + "description": "Assertion: whether the Launchpad host may be contacted. Hard cases require false." + }, + "errorCategory": { + "type": "string", + "enum": ["usage", "validation", "api_error", "network", "auth_required"], + "description": "On `raise`, the coarse BasecampError category the thrown error must map to (asserted cross-SDK alongside the typed reason)." + } + } + } + } +} From 9aa5e708cafb841973d4bc9cbafd354d8c99354e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 14 Jul 2026 16:52:21 -0700 Subject: [PATCH 02/38] TypeScript: resource-first OAuth discovery + issuer binding + SSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Ask #1 for the TS SDK (SPEC.md §16): - discoverProtectedResource (RFC 9728) + discoverFromResource orchestrator alongside the existing discover (RFC 8414), now with code-point issuer binding and the origin-root profile parsed via the platform URL parser. - authorizationEndpoint is now optional on OAuthConfig (device-only servers omit it); interactive-login asserts its presence for the auth-code flow. Added deviceAuthorizationEndpoint/grantTypesSupported to the model. - SSRF hardening on both hops: redirect suppression (redirect:"manual"), bounded timeout, and a genuine streaming bounded body read that aborts before buffering an oversized body. Non-2xx → api_error. - Selection: explicit expectedIssuer (authoritative) or the documented Basecamp-profile exclusion heuristic; ambiguity is a hard error. - Stage-sensitive fallback: DiscoverFromResourceResult (selected|soft-fallback) vs thrown DiscoverySelectionError for every hard case. interactive-login gains resourceBaseUrl (mutually exclusive with baseUrl → usage error), falls back to Launchpad only on the two soft reasons, and rethrows hard selection errors — proven by AS-500 / issuer-mismatch tests asserting zero Launchpad requests. - New resource-discovery.test.ts drives the shared conformance/oauth fixtures with placeholder substitution. README documents the new model. --- typescript/README.md | 59 ++ typescript/src/index.ts | 8 + typescript/src/oauth/discovery.ts | 560 +++++++++++++++--- typescript/src/oauth/index.ts | 9 + typescript/src/oauth/interactive-login.ts | 92 ++- typescript/src/oauth/types.ts | 61 +- .../tests/oauth/interactive-login.test.ts | 92 +++ typescript/tests/oauth/oauth.test.ts | 37 +- .../tests/oauth/resource-discovery.test.ts | 324 ++++++++++ 9 files changed, 1154 insertions(+), 88 deletions(-) create mode 100644 typescript/tests/oauth/resource-discovery.test.ts diff --git a/typescript/README.md b/typescript/README.md index 0686ec57c..e2b1460a1 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -168,6 +168,65 @@ if (isTokenExpired(token)) { } ``` +### Resource-first discovery (BC5) + +BC5 serves its Authorization Server metadata only at its canonical issuer (the +web host), so discovery starts from the **resource** (RFC 9728) and composes with +AS discovery (RFC 8414): + +```ts +import { discoverFromResource, DiscoverySelectionError } from "@37signals/basecamp"; + +const result = await discoverFromResource("https://3.basecampapi.com"); +if (result.kind === "selected") { + // result.config is bound + validated for result.issuer +} else { + // result.reason is "resource_discovery_failed" | "no_as_advertised" + // → fall back to Launchpad (discoverLaunchpad()) +} +``` + +`performInteractiveLogin` supports this via `resourceBaseUrl` (mutually exclusive +with the legacy `baseUrl`; supplying both is a `usage` error): + +```ts +await performInteractiveLogin({ + clientId: "basecamp-cli", + store: myTokenStore, + resourceBaseUrl: "https://3.basecampapi.com", + expectedIssuer, // optional: authoritative, non-heuristic selection + openBrowser: (url) => open(url), +}); +``` + +**Selection.** With `expectedIssuer`, the advertised member equal by code-point is +selected (else a hard `expected_issuer_unavailable`). Without it, the SDK uses a +documented Basecamp-profile heuristic: exactly one non-Launchpad issuer → selected; +≥2 → hard `ambiguous_issuers` (never guesses); zero → Launchpad. + +**Fallback is allowed only before a first-party issuer is committed.** Once valid +resource metadata advertises it and it is selected, every later failure is fatal — +the SDK **never** silently falls back to Launchpad: + +| Failure | Result | +|---|---| +| Hop-1 fetch/parse fails, or `resource` mismatch | soft `resource_discovery_failed` → Launchpad | +| Valid metadata omits the first-party issuer | soft `no_as_advertised` → Launchpad | +| ≥2 non-Launchpad issuers (no `expectedIssuer`) | throws `ambiguous_issuers` | +| `expectedIssuer` not advertised | throws `expected_issuer_unavailable` | +| Committed issuer origin invalid | throws `invalid_issuer_origin` | +| Committed AS metadata fetch fails | throws `as_fetch_failed` | +| Committed issuer binding mismatch | throws `issuer_mismatch` | + +Hard cases throw `DiscoverySelectionError` (with a `.reason`); soft cases return a +`{ kind: "fallback", reason }`. + +> **Breaking-ish:** `OAuthConfig.authorizationEndpoint` is now **optional** — +> device-only servers omit it. Authorization-code consumers must assert it before +> use (`performInteractiveLogin` does this and errors if it is missing). Both +> discovery hops are SSRF-hardened (HTTPS-only origins, suppressed redirects, +> bounded body reads). + ## Services The SDK provides typed services for the complete Basecamp API: diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 3e79e4e46..1da811311 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -435,14 +435,22 @@ export type { RefreshRequest, RawTokenResponse, OAuthErrorResponse, + ProtectedResourceMetadata, + FallbackReason, + DiscoverySelectionErrorReason, + DiscoverFromResourceResult, } from "./oauth/types.js"; // OAuth functions export { discover, + discoverProtectedResource, + discoverFromResource, discoverLaunchpad, + DiscoverySelectionError, LAUNCHPAD_BASE_URL, type DiscoverOptions, + type DiscoverFromResourceOptions, } from "./oauth/discovery.js"; export { diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index bcedd4815..0d6d3085c 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -1,25 +1,65 @@ /** * OAuth 2.0 discovery for Basecamp SDK. * - * Fetches OAuth server configuration from the well-known discovery endpoint. + * Two composable operations plus an orchestrator (SPEC.md §16, communique §2/§3): + * - discover(issuerURL) — RFC 8414 AS metadata + issuer binding + * - discoverProtectedResource(origin) — RFC 9728 resource metadata + * - discoverFromResource(origin, opts) — resource-first selection + fallback + * + * All fetches are SSRF-hardened: HTTPS-only origins (localhost exempt), origin + * parsed/validated with the platform URL parser before any socket opens, + * redirects suppressed, timeouts bounded, and bodies read under a genuine + * bounded cap that aborts before the whole oversized body is buffered. */ import { BasecampError } from "../errors.js"; import { isLocalhost } from "../security.js"; -import type { OAuthConfig } from "./types.js"; +import type { + OAuthConfig, + ProtectedResourceMetadata, + FallbackReason, + DiscoverySelectionErrorReason, + DiscoverFromResourceResult, +} from "./types.js"; -/** - * Raw discovery response from OAuth server. - */ +/** Raw AS metadata response from an OAuth server (RFC 8414). */ interface RawDiscoveryResponse { - issuer: string; - authorization_endpoint: string; - token_endpoint: string; + issuer?: string; + authorization_endpoint?: string; + token_endpoint?: string; + device_authorization_endpoint?: string; registration_endpoint?: string; scopes_supported?: string[]; + grant_types_supported?: string[]; code_challenge_methods_supported?: string[]; } +/** Raw resource metadata response (RFC 9728). */ +interface RawResourceResponse { + resource?: string; + authorization_servers?: string[]; +} + +/** Default cap on a discovery response body (1 MiB) — discovery docs are tiny. */ +const MAX_DISCOVERY_BODY_BYTES = 1 * 1024 * 1024; + +/** True iff `value` is an array whose every element is a string. */ +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +/** + * Resolves the caller-supplied body cap to a usable byte count. A finite, + * non-negative number is honored; anything else (Infinity, NaN, negative, a + * non-number) falls back to the default so the bounded read can never be + * silently disabled. + */ +function resolveMaxBodyBytes(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : MAX_DISCOVERY_BODY_BYTES; +} + /** * Options for OAuth discovery. */ @@ -28,92 +68,221 @@ export interface DiscoverOptions { fetch?: typeof globalThis.fetch; /** Request timeout in milliseconds (default: 10000) */ timeoutMs?: number; + /** + * Maximum discovery response body size in bytes (default: 1 MiB). Must be a + * finite, non-negative number; Infinity/NaN/negative values are ignored and + * the default cap applies (the bound cannot be disabled). + */ + maxBodyBytes?: number; } /** - * Discovers OAuth server configuration from the well-known endpoint. - * - * Fetches the OAuth 2.0 Authorization Server Metadata from: - * `{baseUrl}/.well-known/oauth-authorization-server` + * Options for {@link discoverFromResource}. + */ +export interface DiscoverFromResourceOptions extends DiscoverOptions { + /** + * Explicit, authoritative issuer selection. When provided, the advertised + * member equal by code-point is selected; if none matches, discovery raises + * `expected_issuer_unavailable` (never falls back). Omit to use the + * Basecamp-profile exclusion heuristic. + */ + expectedIssuer?: string; +} + +/** + * Hard resource-first selection/validation failure. Thrown — never returned as a + * fallback — so no consumer can convert it into a Launchpad request. + */ +export class DiscoverySelectionError extends BasecampError { + readonly reason: DiscoverySelectionErrorReason; + + constructor( + reason: DiscoverySelectionErrorReason, + message: string, + options?: { cause?: Error; httpStatus?: number } + ) { + // capability/expected-issuer are consumer/usage-shaped; the rest are AS + // metadata faults surfaced as api_error. + const code = + reason === "capability_unavailable" + ? "validation" + : "api_error"; + super(code, message, options); + this.name = "DiscoverySelectionError"; + this.reason = reason; + } +} + +/** + * Default Basecamp/Launchpad OAuth server URL. + */ +export const LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com"; + +/** + * Parses a caller- or metadata-supplied origin and enforces the origin-root + * profile: https (or http+localhost), host present, optional valid port, path + * empty or exactly "/", and no query/fragment/userinfo. Uses the platform URL + * parser (never a regex) so bracketed IPv6 and ports agree with the host the + * client actually dials. * - * @param baseUrl - The OAuth server's base URL (e.g., "https://launchpad.37signals.com") - * @param options - Optional configuration - * @returns The OAuth server configuration - * @throws BasecampError on network or parsing errors + * Throws `BasecampError("usage")` on violation — a bad *caller* origin is a + * usage error. Callers validating an *advertised* origin catch and reclassify. * - * @example - * ```ts - * const config = await discover("https://launchpad.37signals.com"); - * console.log(config.tokenEndpoint); - * // => "https://launchpad.37signals.com/authorization/token" - * ``` + * @returns the normalized origin (scheme://host[:port], no trailing slash) */ -export async function discover( - baseUrl: string, - options: DiscoverOptions = {} -): Promise { - const { fetch: customFetch = globalThis.fetch, timeoutMs = 10000 } = options; +export function requireOriginRoot(raw: string, label = "origin"): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new BasecampError("usage", `Invalid ${label}: not a valid absolute URL: ${raw}`); + } + + const isLocalhostHttp = url.protocol === "http:" && isLocalhost(url.hostname); + if (url.protocol !== "https:" && !isLocalhostHttp) { + throw new BasecampError("usage", `${label} must use HTTPS (or http on localhost): ${raw}`); + } + if (!url.hostname) { + throw new BasecampError("usage", `${label} has no host: ${raw}`); + } + if (url.username || url.password) { + throw new BasecampError("usage", `${label} must not contain userinfo: ${raw}`); + } + if (url.search || url.hash) { + throw new BasecampError("usage", `${label} must not contain a query or fragment: ${raw}`); + } + if (url.pathname !== "" && url.pathname !== "/") { + throw new BasecampError("usage", `${label} must be an origin root (no path): ${raw}`); + } + // Note: `new URL("https://h:notaport")` throws above, so a surviving url has a + // structurally valid (possibly default) port. url.origin drops a default port + // and any trailing slash — exactly the normalized origin we want. + return url.origin; +} - // Validate HTTPS before making any network request (allow localhost for testing) +/** + * True when an issuer string is a valid origin root equal to Launchpad's. + * + * The comparison runs both sides through {@link requireOriginRoot}, so an + * advertised look-alike that is *not* a clean origin root — e.g. + * `https://launchpad.37signals.com/path` (path), userinfo, or a query — is not + * treated as Launchpad. It stays a non-Launchpad candidate and later fails hard + * (`ambiguous_issuers` / `invalid_issuer_origin`) rather than being silently + * excluded from selection. A trailing-slash-only origin root still matches + * because `requireOriginRoot` normalizes it away. + * + * Exported so the login orchestrator derives the legacy-token decision from the + * same predicate the selection heuristic uses. + */ +export function isLaunchpadIssuer(issuer: string): boolean { try { - const parsed = new URL(baseUrl); - if (parsed.protocol !== "https:" && !isLocalhost(parsed.hostname)) { - throw new BasecampError("validation", `OAuth discovery base URL must use HTTPS: ${baseUrl}`); + return requireOriginRoot(issuer, "issuer") === requireOriginRoot(LAUNCHPAD_BASE_URL, "issuer"); + } catch { + return false; + } +} + +/** + * Reads a Response body under a bounded, streaming cap. Aborts (cancels the + * stream) the moment the accumulated size exceeds `maxBytes`, so an oversized + * body is never fully buffered — real memory bounding, not a post-hoc check. + * + * Exported for reuse by the device-flow transport (same bounding, different + * `label` in the error message); not re-exported from the package index. + */ +export async function readBodyBounded( + response: Response, + maxBytes: number, + label = "OAuth discovery" +): Promise { + const body = response.body; + if (!body) { + // No readable stream available (some mock transports): guard the buffered + // length instead. Real runtimes always expose response.body. + const text = await response.text(); + if (new TextEncoder().encode(text).length > maxBytes) { + throw new BasecampError("api_error", `${label} response exceeds size cap`); } - } catch (err) { - if (err instanceof BasecampError) throw err; - throw new BasecampError("validation", `Invalid OAuth discovery base URL: ${baseUrl}`); + return text; } - // Normalize base URL (remove trailing slash) - const normalizedBase = baseUrl.replace(/\/$/, ""); - const discoveryUrl = `${normalizedBase}/.well-known/oauth-authorization-server`; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let exceeded = false; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + total += value.byteLength; + if (total > maxBytes) { + exceeded = true; + break; + } + chunks.push(value); + } + } + // Release the stream without blocking on cancellation (some transports hang on + // an awaited cancel()); the point is that we stopped reading past the cap. + void reader.cancel().catch(() => {}); + if (exceeded) { + throw new BasecampError("api_error", `${label} response exceeds size cap`); + } + + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.byteLength; + } + return new TextDecoder().decode(merged); +} + +/** + * SSRF-hardened GET of a discovery document. The origin must already be + * validated (via {@link requireOriginRoot}); this suppresses redirects, bounds + * the timeout, reads the body under a bounded cap, and maps non-2xx → api_error. + */ +async function fetchDiscoveryDocument( + url: string, + options: DiscoverOptions +): Promise { + const { fetch: customFetch = globalThis.fetch, timeoutMs = 10000 } = options; + const maxBodyBytes = resolveMaxBodyBytes(options.maxBodyBytes); - // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await customFetch(discoveryUrl, { + const response = await customFetch(url, { method: "GET", - headers: { - Accept: "application/json", - }, + headers: { Accept: "application/json" }, signal: controller.signal, + // Suppress redirects: never chase an attacker-influenced Location. A 3xx + // surfaces below as a non-2xx api_error rather than a followed request. + redirect: "manual", }); - if (!response.ok) { - const body = await response.text().catch(() => ""); + if (!response.ok || (response.status >= 300 && response.status < 400)) { + // Drain-and-cap defensively; body is unused on the error path. + const body = await readBodyBounded(response, maxBodyBytes).catch(() => ""); throw new BasecampError( - "network", + "api_error", `OAuth discovery failed with status ${response.status}: ${body}`, { httpStatus: response.status } ); } - const data = (await response.json()) as RawDiscoveryResponse; - - // Validate required fields - if (!data.issuer || !data.authorization_endpoint || !data.token_endpoint) { - throw new BasecampError( - "api_error", - "Invalid OAuth discovery response: missing required fields" - ); + const text = await readBodyBounded(response, maxBodyBytes); + try { + return JSON.parse(text); + } catch (err) { + throw new BasecampError("api_error", "Failed to parse OAuth discovery response", { + cause: err instanceof Error ? err : undefined, + }); } - - return { - issuer: data.issuer, - authorizationEndpoint: data.authorization_endpoint, - tokenEndpoint: data.token_endpoint, - registrationEndpoint: data.registration_endpoint, - scopesSupported: data.scopes_supported, - codeChallengeMethodsSupported: data.code_challenge_methods_supported, - }; } catch (err) { - if (err instanceof BasecampError) { - throw err; - } - + if (err instanceof BasecampError) throw err; if (err instanceof Error) { if (err.name === "AbortError") { throw new BasecampError("network", "OAuth discovery request timed out", { @@ -121,13 +290,11 @@ export async function discover( retryable: true, }); } - throw new BasecampError("network", `OAuth discovery failed: ${err.message}`, { cause: err, retryable: true, }); } - throw new BasecampError("network", "OAuth discovery failed with unknown error", { retryable: true, }); @@ -137,23 +304,258 @@ export async function discover( } /** - * Default Basecamp/Launchpad OAuth server URL. + * Discovers OAuth 2.0 Authorization Server Metadata (RFC 8414) from + * `{issuerURL}/.well-known/oauth-authorization-server`, and binds it: the + * returned `issuer` must equal the requested issuer by code-point (no + * normalization beyond origin-root parsing). `token_endpoint` is required; + * `authorization_endpoint` is optional (device-only servers omit it). + * + * @param baseUrl - The OAuth server's issuer origin (e.g. "https://launchpad.37signals.com") + * @throws BasecampError("usage") on a malformed origin, api_error on invalid metadata + * + * @example + * ```ts + * const config = await discover("https://launchpad.37signals.com"); + * console.log(config.tokenEndpoint); + * ``` */ -export const LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com"; +export async function discover( + baseUrl: string, + options: DiscoverOptions = {} +): Promise { + const issuerOrigin = requireOriginRoot(baseUrl, "OAuth discovery base URL"); + const discoveryUrl = `${issuerOrigin}/.well-known/oauth-authorization-server`; + + const data = (await fetchDiscoveryDocument(discoveryUrl, options)) as RawDiscoveryResponse; + if (typeof data !== "object" || data === null) { + throw new BasecampError("api_error", "OAuth discovery response is not a JSON object"); + } + + return parseAndBindAsMetadata(data, issuerOrigin); +} /** - * Discovers OAuth configuration from Basecamp's Launchpad server. + * Module-private structural marker for an RFC 8414 issuer-binding failure: the + * AS metadata's `issuer` did not equal the requested issuer by code-point. * - * Convenience function that calls discover() with the Launchpad base URL. + * `discoverFromResource` branches on this via `instanceof` to classify + * `issuer_mismatch` vs `as_fetch_failed` — a structured tag, never a match on + * the message text. Kept private to this module and NOT re-exported from the + * package index: to any external caller of `discover`, this is an ordinary + * `api_error` `BasecampError`. + */ +class IssuerBindingError extends BasecampError { + constructor(message: string) { + super("api_error", message); + this.name = "IssuerBindingError"; + } +} + +/** + * Validates AS metadata and binds `issuer` to `expectedIssuerOrigin` by + * code-point. Universal validation only: `issuer`+`token_endpoint` present and + * non-empty; any present endpoint field non-empty. Per-grant endpoint checks + * are the consumer's responsibility. + */ +function parseAndBindAsMetadata( + data: RawDiscoveryResponse, + expectedIssuerOrigin: string +): OAuthConfig { + if (typeof data.issuer !== "string" || !data.issuer) { + throw new BasecampError("api_error", "Invalid OAuth discovery response: missing required fields (issuer)"); + } + // RFC 8414 §3.3/§4: issuer identical by code-point. No normalization. Thrown + // as the structural IssuerBindingError so discoverFromResource can classify it + // without matching the message text. + if (data.issuer !== expectedIssuerOrigin) { + throw new IssuerBindingError( + `OAuth issuer mismatch: metadata issuer "${data.issuer}" does not equal "${expectedIssuerOrigin}"` + ); + } + if (typeof data.token_endpoint !== "string" || !data.token_endpoint) { + throw new BasecampError("api_error", "Invalid OAuth discovery response: missing required fields (token_endpoint)"); + } + // Every present endpoint field must be a non-empty string (reject "", arrays, + // numbers, etc. — a non-string endpoint is malformed, not merely empty). + for (const [key, value] of Object.entries(data)) { + if (key.endsWith("_endpoint") && value !== undefined && (typeof value !== "string" || value === "")) { + throw new BasecampError("api_error", `Invalid OAuth discovery response: invalid ${key}`); + } + } + // grant_types_supported, when present, must be an array of strings — never a + // bare string (substring-matching it would falsely enable a grant). + if (data.grant_types_supported !== undefined && !isStringArray(data.grant_types_supported)) { + throw new BasecampError( + "api_error", + "Invalid OAuth discovery response: grant_types_supported must be an array of strings" + ); + } + // code_challenge_methods_supported, when present, must likewise be an array of + // strings — a bare string would be substring-matched during PKCE negotiation + // and could falsely appear to advertise "S256". + if ( + data.code_challenge_methods_supported !== undefined && + !isStringArray(data.code_challenge_methods_supported) + ) { + throw new BasecampError( + "api_error", + "Invalid OAuth discovery response: code_challenge_methods_supported must be an array of strings" + ); + } + + return { + issuer: data.issuer, + authorizationEndpoint: data.authorization_endpoint, + tokenEndpoint: data.token_endpoint, + deviceAuthorizationEndpoint: data.device_authorization_endpoint, + registrationEndpoint: data.registration_endpoint, + scopesSupported: data.scopes_supported, + grantTypesSupported: data.grant_types_supported, + codeChallengeMethodsSupported: data.code_challenge_methods_supported, + }; +} + +/** + * Discovers RFC 9728 protected-resource metadata from + * `{resourceOrigin}/.well-known/oauth-protected-resource`. `resource` is + * required and must equal the requested origin by code-point. + * `authorization_servers` is preserved distinctly as absent vs `[]`. * - * @param options - Optional configuration - * @returns The OAuth server configuration + * @throws BasecampError("usage") on a malformed caller origin, api_error on + * invalid metadata + */ +export async function discoverProtectedResource( + resourceOrigin: string, + options: DiscoverOptions = {} +): Promise { + const origin = requireOriginRoot(resourceOrigin, "resource origin"); + const url = `${origin}/.well-known/oauth-protected-resource`; + + const data = (await fetchDiscoveryDocument(url, options)) as RawResourceResponse; + if (typeof data !== "object" || data === null) { + throw new BasecampError("api_error", "Resource metadata response is not a JSON object"); + } + if (typeof data.resource !== "string" || !data.resource) { + throw new BasecampError("api_error", "Invalid resource metadata: missing required field (resource)"); + } + // Bind resource identifier to the requested origin, code-point exact. + if (data.resource !== origin) { + throw new BasecampError( + "api_error", + `Resource identifier mismatch: metadata resource "${data.resource}" does not equal "${origin}"` + ); + } + + // authorization_servers, when present, must be an array of strings. A bare + // string here previously slipped through and was iterated char-by-char during + // selection; reject it as malformed so the orchestrator soft-falls-back. + let authorizationServers: string[] | undefined; + if (Object.prototype.hasOwnProperty.call(data, "authorization_servers")) { + const raw: unknown = (data as Record).authorization_servers; + if (raw === null || raw === undefined) { + authorizationServers = []; // present-but-null → present-empty + } else if (isStringArray(raw)) { + authorizationServers = raw; + } else { + throw new BasecampError( + "api_error", + "Invalid resource metadata: authorization_servers must be an array of strings" + ); + } + } + + return { resource: data.resource, authorizationServers }; +} + +/** + * Resource-first discovery orchestrator (SPEC.md §16). Composes RFC 9728 + RFC + * 8414 and applies the stage-sensitive fallback state machine. * - * @example - * ```ts - * const config = await discoverLaunchpad(); - * // Use config.authorizationEndpoint to start OAuth flow - * ``` + * Returns `{ kind: "selected", config, issuer }` or `{ kind: "fallback", reason }` + * where `reason ∈ {resource_discovery_failed, no_as_advertised}`. Every hard + * failure throws {@link DiscoverySelectionError} — callers MUST NOT convert a + * throw into a Launchpad request. + */ +export async function discoverFromResource( + resourceOrigin: string, + options: DiscoverFromResourceOptions = {} +): Promise { + const { expectedIssuer, ...discoverOptions } = options; + + // Origin-root validation of the *caller's* input is a usage error — let it + // propagate as-is (not a soft fallback). + const origin = requireOriginRoot(resourceOrigin, "resource origin"); + + // --- Hop 1: resource metadata. Failure here is soft (before selection). --- + let resource: ProtectedResourceMetadata; + try { + resource = await discoverProtectedResource(origin, discoverOptions); + } catch (err) { + if (err instanceof BasecampError && err.code === "usage") throw err; + return { kind: "fallback", reason: "resource_discovery_failed" }; + } + + const advertised = resource.authorizationServers ?? []; + + // --- Selection --- + let selectedIssuer: string; + if (expectedIssuer !== undefined) { + const match = advertised.find((s) => s === expectedIssuer); + if (!match) { + throw new DiscoverySelectionError( + "expected_issuer_unavailable", + `Expected issuer "${expectedIssuer}" is not advertised by the resource` + ); + } + selectedIssuer = match; + } else { + const nonLaunchpad = advertised.filter((s) => !isLaunchpadIssuer(s)); + if (nonLaunchpad.length >= 2) { + throw new DiscoverySelectionError( + "ambiguous_issuers", + `Multiple non-Launchpad issuers advertised; pass expectedIssuer to disambiguate: ${nonLaunchpad.join(", ")}` + ); + } + if (nonLaunchpad.length === 0) { + // Valid resource metadata omits BC5 — soft fallback (before selection). + return { kind: "fallback", reason: "no_as_advertised" }; + } + selectedIssuer = nonLaunchpad[0]!; + } + + // --- BC5 is now committed: every subsequent failure is fatal (no Launchpad). --- + let issuerOrigin: string; + try { + issuerOrigin = requireOriginRoot(selectedIssuer, "advertised issuer"); + } catch (err) { + throw new DiscoverySelectionError( + "invalid_issuer_origin", + `Advertised issuer "${selectedIssuer}" is not a valid origin root`, + { cause: err instanceof Error ? err : undefined } + ); + } + + let config: OAuthConfig; + try { + config = await discover(issuerOrigin, discoverOptions); + } catch (err) { + // Distinguish issuer-binding mismatch from a generic fetch failure via the + // structural marker (never the message text). + if (err instanceof IssuerBindingError) { + throw new DiscoverySelectionError("issuer_mismatch", err.message, { cause: err }); + } + throw new DiscoverySelectionError( + "as_fetch_failed", + `AS metadata fetch failed for committed issuer "${issuerOrigin}": ${err instanceof Error ? err.message : String(err)}`, + { cause: err instanceof Error ? err : undefined } + ); + } + + return { kind: "selected", config, issuer: config.issuer }; +} + +/** + * Discovers OAuth configuration from Basecamp's Launchpad server. */ export async function discoverLaunchpad( options: DiscoverOptions = {} diff --git a/typescript/src/oauth/index.ts b/typescript/src/oauth/index.ts index b37103dff..6d01bc5cc 100644 --- a/typescript/src/oauth/index.ts +++ b/typescript/src/oauth/index.ts @@ -46,14 +46,23 @@ export type { RefreshRequest, RawTokenResponse, OAuthErrorResponse, + ProtectedResourceMetadata, + FallbackReason, + DiscoverySelectionErrorReason, + DiscoverFromResourceResult, } from "./types.js"; // Discovery export { discover, + discoverProtectedResource, + discoverFromResource, discoverLaunchpad, + requireOriginRoot, + DiscoverySelectionError, LAUNCHPAD_BASE_URL, type DiscoverOptions, + type DiscoverFromResourceOptions, } from "./discovery.js"; // Token exchange diff --git a/typescript/src/oauth/interactive-login.ts b/typescript/src/oauth/interactive-login.ts index 2f94fa49a..cc6bf244f 100644 --- a/typescript/src/oauth/interactive-login.ts +++ b/typescript/src/oauth/interactive-login.ts @@ -5,7 +5,7 @@ * discovery, PKCE, local callback server, browser launch, code exchange. */ -import { discover, discoverLaunchpad } from "./discovery.js"; +import { discover, discoverLaunchpad, discoverFromResource, isLaunchpadIssuer } from "./discovery.js"; import { generateState, generatePKCE } from "./pkce.js"; import { buildAuthorizationUrl } from "./authorize.js"; import { startCallbackServer } from "./callback-server.js"; @@ -24,9 +24,27 @@ export interface InteractiveLoginOptions { clientSecret?: string; /** Token store for persisting the resulting token */ store: TokenStore; - /** OAuth server base URL (defaults to Launchpad) */ + /** + * Legacy single-hop AS base URL (RFC 8414). Mutually exclusive with + * {@link resourceBaseUrl}. Defaults to Launchpad when neither is set. + */ baseUrl?: string; - /** Use Launchpad's non-standard token format (default: true) */ + /** + * Resource-first (two-hop) discovery origin — the API/resource host (RFC + * 9728). Mutually exclusive with {@link baseUrl}. Soft failures fall back to + * Launchpad; hard selection errors propagate. + */ + resourceBaseUrl?: string; + /** + * Explicit issuer selection for resource-first discovery (passed through to + * `discoverFromResource`). + */ + expectedIssuer?: string; + /** + * Use Launchpad's non-standard token format. When omitted, it is derived from + * the selected flow (legacy for Launchpad, standard for a first-party issuer); + * the legacy `baseUrl` path defaults to `true`. + */ useLegacyFormat?: boolean; /** Port for the local callback server (optional) */ callbackPort?: number; @@ -75,16 +93,41 @@ export async function performInteractiveLogin( clientSecret, store, baseUrl, - useLegacyFormat = true, + resourceBaseUrl, + expectedIssuer, + useLegacyFormat, callbackPort, openBrowser, promptForManualVisit, onStatus, } = options; + if (baseUrl && resourceBaseUrl) { + throw new BasecampError( + "usage", + "baseUrl and resourceBaseUrl are mutually exclusive discovery modes; supply only one" + ); + } + // 1. Discover OAuth endpoints onStatus?.("Discovering OAuth endpoints..."); - const config = baseUrl ? await discover(baseUrl) : await discoverLaunchpad(); + const { config, legacy } = await discoverEndpoints({ + baseUrl, + resourceBaseUrl, + expectedIssuer, + useLegacyFormat, + onStatus, + }); + + // Authorization-code flow requires an authorization endpoint. It is optional + // in discovery now (device-only servers omit it), so assert presence here. + if (!config.authorizationEndpoint) { + throw new BasecampError( + "validation", + "Selected authorization server does not advertise an authorization_endpoint; " + + "authorization-code login is unavailable for this issuer" + ); + } // 2. Generate PKCE and state const state = generateState(); @@ -139,7 +182,7 @@ export async function performInteractiveLogin( clientId, clientSecret, codeVerifier: pkce?.verifier, - useLegacyFormat, + useLegacyFormat: legacy, }); // 8. Save token @@ -151,3 +194,40 @@ export async function performInteractiveLogin( close(); } } + +/** + * Resolves the OAuth config for the login flow across the two discovery modes, + * and decides whether to send Launchpad's legacy token format. + * + * Resource-first mode falls back to Launchpad ONLY on the two soft reasons; any + * hard selection error (issuer mismatch, AS fetch failure, ambiguity, …) throws + * from `discoverFromResource` and propagates here — never a silent Launchpad + * request. + */ +async function discoverEndpoints(opts: { + baseUrl?: string; + resourceBaseUrl?: string; + expectedIssuer?: string; + useLegacyFormat?: boolean; + onStatus?: (message: string) => void; +}): Promise<{ config: import("./types.js").OAuthConfig; legacy: boolean }> { + const { baseUrl, resourceBaseUrl, expectedIssuer, useLegacyFormat, onStatus } = opts; + + if (resourceBaseUrl) { + const result = await discoverFromResource(resourceBaseUrl, { expectedIssuer }); + if (result.kind === "selected") { + // Derive the token format from the selected issuer unless the caller + // pinned it explicitly. + const legacy = useLegacyFormat ?? isLaunchpadIssuer(result.config.issuer); + return { config: result.config, legacy }; + } + // Soft fallback (resource_discovery_failed | no_as_advertised) → Launchpad. + onStatus?.(`Resource discovery fell back to Launchpad (${result.reason}).`); + const config = await discoverLaunchpad(); + return { config, legacy: useLegacyFormat ?? true }; + } + + const config = baseUrl ? await discover(baseUrl) : await discoverLaunchpad(); + // Legacy single-hop path keeps its historical default of legacy=true. + return { config, legacy: useLegacyFormat ?? true }; +} diff --git a/typescript/src/oauth/types.ts b/typescript/src/oauth/types.ts index 9cbe697d3..2b90ed0de 100644 --- a/typescript/src/oauth/types.ts +++ b/typescript/src/oauth/types.ts @@ -11,18 +11,75 @@ export interface OAuthConfig { /** The authorization server's issuer identifier */ issuer: string; - /** URL of the authorization endpoint */ - authorizationEndpoint: string; + /** + * URL of the authorization endpoint. + * + * Optional as of BC5 resource-first discovery: device-only authorization + * servers omit it. Authorization-code consumers MUST assert its presence + * before use. + */ + authorizationEndpoint?: string; /** URL of the token endpoint */ tokenEndpoint: string; + /** URL of the RFC 8628 device authorization endpoint (optional) */ + deviceAuthorizationEndpoint?: string; /** URL of the dynamic client registration endpoint (optional) */ registrationEndpoint?: string; /** List of OAuth 2.0 scopes supported (optional) */ scopesSupported?: string[]; + /** OAuth 2.0 grant types the server supports (optional) */ + grantTypesSupported?: string[]; /** PKCE code challenge methods supported by the server (optional) */ codeChallengeMethodsSupported?: string[]; } +/** + * RFC 9728 protected-resource metadata (hop 1 of resource-first discovery). + */ +export interface ProtectedResourceMetadata { + /** The resource identifier; must equal the requested resource origin by code-point. */ + resource: string; + /** + * Authorization servers advertised for this resource. + * + * Absent (`undefined`) and present-but-empty (`[]`) are preserved distinctly: + * BC5 omits the key while dark, per RFC 9728 §3.2. Both nonetheless select + * Launchpad, but the distinction is meaningful to callers inspecting metadata. + */ + authorizationServers?: string[]; +} + +/** + * Soft fallback reasons — the ONLY two outcomes under which + * {@link DiscoverFromResourceResult} yields a fallback (Launchpad) rather than a + * selected config. Every other failure raises {@link DiscoverySelectionError}. + */ +export type FallbackReason = "resource_discovery_failed" | "no_as_advertised"; + +/** + * Hard selection/validation failures. These are THROWN, never returned as a + * fallback — no consumer may convert them into a Launchpad request. + */ +export type DiscoverySelectionErrorReason = + | "ambiguous_issuers" + | "expected_issuer_unavailable" + | "invalid_issuer_origin" + | "as_fetch_failed" + | "issuer_mismatch" + | "capability_unavailable"; + +/** + * Result of {@link discoverFromResource}: either a selected AS config, or a soft + * fallback to Launchpad. Hard failures are thrown, not represented here. + * + * Note: a malformed caller `resourceOrigin` (not an origin-root URL) is a usage + * error — it throws `BasecampError("usage")` up front and is never surfaced as a + * fallback reason here. + */ +export type DiscoverFromResourceResult = + | { kind: "selected"; config: OAuthConfig; issuer: string } + | { kind: "fallback"; reason: FallbackReason }; + /** * OAuth 2.0 access token response. */ diff --git a/typescript/tests/oauth/interactive-login.test.ts b/typescript/tests/oauth/interactive-login.test.ts index 68083e07b..a8519c6c2 100644 --- a/typescript/tests/oauth/interactive-login.test.ts +++ b/typescript/tests/oauth/interactive-login.test.ts @@ -143,6 +143,8 @@ describe("performInteractiveLogin", () => { "https://custom.example.com/.well-known/oauth-authorization-server", () => HttpResponse.json({ ...mockDiscoveryResponse, + // Issuer must bind to the host the metadata was fetched from (RFC 8414). + issuer: "https://custom.example.com", authorization_endpoint: "https://custom.example.com/authorize", token_endpoint: "https://custom.example.com/token", }) @@ -281,3 +283,93 @@ describe("performInteractiveLogin", () => { }); }); }); + +describe("performInteractiveLogin — resource-first fatal-after-selection", () => { + const RESOURCE = "https://api.basecamp-test.example"; + const BC5 = "https://bc5.basecamp-test.example"; + const LAUNCHPAD = "https://launchpad.37signals.com"; + + let launchpadContacted = false; + + beforeEach(() => { + launchpadContacted = false; + server.use( + mswHttp.get(`${LAUNCHPAD}/.well-known/oauth-authorization-server`, () => { + launchpadContacted = true; + return HttpResponse.json({ + issuer: LAUNCHPAD, + authorization_endpoint: `${LAUNCHPAD}/authorization/new`, + token_endpoint: `${LAUNCHPAD}/authorization/token`, + }); + }), + ); + }); + + function advertiseBc5() { + server.use( + mswHttp.get(`${RESOURCE}/.well-known/oauth-protected-resource`, () => + HttpResponse.json({ resource: RESOURCE, authorization_servers: [BC5, LAUNCHPAD] }), + ), + ); + } + + it("rejects and never contacts Launchpad when the committed AS returns 500", async () => { + advertiseBc5(); + server.use( + mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => + HttpResponse.json({ error: "boom" }, { status: 500 }), + ), + ); + + const openBrowser = vi.fn(async () => {}); + await expect( + performInteractiveLogin({ + clientId: "basecamp-cli", + store: createMockStore(), + resourceBaseUrl: RESOURCE, + openBrowser, + }), + ).rejects.toMatchObject({ code: "api_error" }); + + expect(openBrowser).not.toHaveBeenCalled(); + expect(launchpadContacted).toBe(false); + }); + + it("rejects and never contacts Launchpad on an issuer-binding mismatch", async () => { + advertiseBc5(); + server.use( + mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => + HttpResponse.json({ + issuer: "https://impostor.example.com", + authorization_endpoint: `${BC5}/oauth/authorize`, + token_endpoint: `${BC5}/oauth/token`, + }), + ), + ); + + const openBrowser = vi.fn(async () => {}); + await expect( + performInteractiveLogin({ + clientId: "basecamp-cli", + store: createMockStore(), + resourceBaseUrl: RESOURCE, + openBrowser, + }), + ).rejects.toMatchObject({ code: "api_error" }); + + expect(openBrowser).not.toHaveBeenCalled(); + expect(launchpadContacted).toBe(false); + }); + + it("rejects when baseUrl and resourceBaseUrl are both supplied", async () => { + await expect( + performInteractiveLogin({ + clientId: "basecamp-cli", + store: createMockStore(), + baseUrl: LAUNCHPAD, + resourceBaseUrl: RESOURCE, + openBrowser: vi.fn(async () => {}), + }), + ).rejects.toMatchObject({ code: "usage" }); + }); +}); diff --git a/typescript/tests/oauth/oauth.test.ts b/typescript/tests/oauth/oauth.test.ts index ed68658fd..f2f4c1edb 100644 --- a/typescript/tests/oauth/oauth.test.ts +++ b/typescript/tests/oauth/oauth.test.ts @@ -67,6 +67,40 @@ describe("OAuth Discovery", () => { expect(config.codeChallengeMethodsSupported).toEqual(["S256"]); }); + it("rejects a non-array code_challenge_methods_supported as api_error", async () => { + // A bare string would be substring-matched during PKCE negotiation and + // could falsely appear to advertise "S256"; it must be rejected. + server.use( + http.get( + "https://launchpad.37signals.com/.well-known/oauth-authorization-server", + () => HttpResponse.json({ + ...mockDiscoveryResponse, + code_challenge_methods_supported: "S256", + }) + ) + ); + + await expect(discover("https://launchpad.37signals.com")).rejects.toMatchObject({ + code: "api_error", + }); + }); + + it("rejects code_challenge_methods_supported with a non-string element", async () => { + server.use( + http.get( + "https://launchpad.37signals.com/.well-known/oauth-authorization-server", + () => HttpResponse.json({ + ...mockDiscoveryResponse, + code_challenge_methods_supported: ["S256", 256], + }) + ) + ); + + await expect(discover("https://launchpad.37signals.com")).rejects.toMatchObject({ + code: "api_error", + }); + }); + it("leaves codeChallengeMethodsSupported undefined when not in response", async () => { server.use( http.get( @@ -106,7 +140,8 @@ describe("OAuth Discovery", () => { expect.fail("Should have thrown"); } catch (err) { expect(err).toBeInstanceOf(BasecampError); - expect((err as BasecampError).code).toBe("network"); + // Non-2xx discovery responses are api_error (standardized), not network. + expect((err as BasecampError).code).toBe("api_error"); expect((err as BasecampError).httpStatus).toBe(404); } }); diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts new file mode 100644 index 000000000..a1f119097 --- /dev/null +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -0,0 +1,324 @@ +/** + * Resource-first OAuth discovery tests. + * + * Drives the shared, data-only fixtures in conformance/oauth/fixtures with this + * harness's mock origins substituted for the {{...}} placeholders, so issuer / + * resource binding stays code-point-exact against the mocked hosts. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { http as mswHttp, HttpResponse } from "msw"; +import { server } from "../setup.js"; +import { + discover, + discoverProtectedResource, + discoverFromResource, + requireOriginRoot, + DiscoverySelectionError, +} from "../../src/oauth/index.js"; +import { BasecampError } from "../../src/errors.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = join(HERE, "../../../conformance/oauth/fixtures"); + +// Mock origins substituted for fixture placeholders. LAUNCHPAD must be the real +// origin because discoverLaunchpad() targets it. +const ORIGINS = { + "{{RESOURCE_ORIGIN}}": "https://api.basecamp-test.example", + "{{ISSUER_ORIGIN}}": "https://issuer.basecamp-test.example", + "{{LAUNCHPAD_ORIGIN}}": "https://launchpad.37signals.com", + "{{BC5_ISSUER}}": "https://bc5.basecamp-test.example", +}; + +function substitute(value: T): T { + let json = JSON.stringify(value); + for (const [ph, origin] of Object.entries(ORIGINS)) { + json = json.split(ph).join(origin); + } + return JSON.parse(json) as T; +} + +interface Exchange { + origin?: string; + status?: number; + transportError?: boolean; + body?: unknown; + oversized?: boolean; + redirectTo?: string; +} + +interface Fixture { + name: string; + operation: "discoverFromResource" | "discoverProtectedResource" | "discover"; + resourceOrigin?: string; + issuerOrigin?: string; + expectedIssuer?: string; + hop1?: Exchange; + hop2?: Exchange; + expect: { + outcome: "selected" | "fallback" | "raise"; + selectedIssuer?: string; + fallbackReason?: string; + error?: string; + errorCategory?: string; + launchpadContacted?: boolean; + }; +} + +function loadFixtures(): Fixture[] { + return readdirSync(FIXTURE_DIR) + .filter((f) => f.endsWith(".json")) + .sort() + .map((f) => JSON.parse(readFileSync(join(FIXTURE_DIR, f), "utf8")) as Fixture); +} + +const WELL_KNOWN = { + resource: "/.well-known/oauth-protected-resource", + as: "/.well-known/oauth-authorization-server", +}; + +/** Registers an MSW handler for one mocked hop. */ +function handlerFor(url: string, ex: Exchange) { + return mswHttp.get(url, () => { + if (ex.transportError) return HttpResponse.error(); + if (ex.redirectTo) { + return new HttpResponse(null, { status: ex.status ?? 302, headers: { Location: ex.redirectTo } }); + } + const status = ex.status ?? 200; + if (ex.body === undefined) return new HttpResponse(null, { status }); + return HttpResponse.json(ex.body as object, { status }); + }); +} + +describe("resource-first discovery fixtures", () => { + let launchpadContacted = false; + + beforeEach(() => { + launchpadContacted = false; + // Track any request to the Launchpad well-known endpoints. The orchestrator + // itself never contacts Launchpad; hard cases must never reach here. + server.use( + mswHttp.get(`${ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}${WELL_KNOWN.as}`, () => { + launchpadContacted = true; + return HttpResponse.json({ + issuer: ORIGINS["{{LAUNCHPAD_ORIGIN}}"], + authorization_endpoint: `${ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}/authorization/new`, + token_endpoint: `${ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}/authorization/token`, + }); + }) + ); + }); + + for (const raw of loadFixtures()) { + // Oversized streaming is exercised by a dedicated test below (MSW cannot + // easily stream a body larger than the cap through json()). + if (raw.hop2?.oversized || raw.hop1?.oversized) continue; + + it(`${raw.name}`, async () => { + const fx = substitute(raw); + + // Bracketed IPv6 origins can't be pattern-matched by MSW's path parser, so + // the IPv6 origin-root accept case is verified at the parser boundary (the + // point of the fixture: the transport parser accepts it where a regex fails). + if (fx.resourceOrigin?.includes("[") && fx.expect.outcome === "selected") { + expect(requireOriginRoot(fx.resourceOrigin)).toBe(fx.expect.selectedIssuer); + return; + } + + if (fx.hop1) { + const resourceOrigin = fx.resourceOrigin!; + server.use(handlerFor(`${resourceOrigin}${WELL_KNOWN.resource}`, fx.hop1)); + } + if (fx.hop2) { + const issuerOrigin = fx.hop2.origin ?? fx.issuerOrigin!; + server.use(handlerFor(`${issuerOrigin}${WELL_KNOWN.as}`, fx.hop2)); + } + + const run = async () => { + switch (fx.operation) { + case "discoverFromResource": + return discoverFromResource(fx.resourceOrigin!, { expectedIssuer: fx.expectedIssuer }); + case "discoverProtectedResource": + return discoverProtectedResource(fx.resourceOrigin!); + case "discover": + return discover(fx.issuerOrigin!); + } + }; + + if (fx.expect.outcome === "raise") { + let thrown: unknown; + try { + await run(); + expect.fail("expected a throw"); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(BasecampError); + if (fx.expect.error === "usage") { + expect((thrown as BasecampError).code).toBe("usage"); + } else if (fx.operation === "discoverFromResource") { + expect(thrown).toBeInstanceOf(DiscoverySelectionError); + expect((thrown as DiscoverySelectionError).reason).toBe(fx.expect.error); + } else { + // discover / discoverProtectedResource hard failures are api_error. + expect((thrown as BasecampError).code).toBe("api_error"); + } + // Cross-SDK coarse-category assertion. + if (fx.expect.errorCategory) { + expect((thrown as BasecampError).code).toBe(fx.expect.errorCategory); + } + } else if (fx.expect.outcome === "fallback") { + const result = (await run()) as { kind: string; reason?: string }; + expect(result.kind).toBe("fallback"); + expect(result.reason).toBe(fx.expect.fallbackReason); + } else { + // selected + const result = await run(); + if (fx.operation === "discoverFromResource") { + const r = result as { kind: string; issuer: string }; + expect(r.kind).toBe("selected"); + if (fx.expect.selectedIssuer) expect(r.issuer).toBe(fx.expect.selectedIssuer); + } + // discover / discoverProtectedResource: absence of a throw is success. + } + + if (fx.expect.launchpadContacted === false) { + expect(launchpadContacted).toBe(false); + } + }); + } + + it("device-only AS omits authorization_endpoint but carries device capability", async () => { + const issuer = ORIGINS["{{ISSUER_ORIGIN}}"]; + server.use( + mswHttp.get(`${issuer}${WELL_KNOWN.as}`, () => + HttpResponse.json({ + issuer, + token_endpoint: `${issuer}/oauth/token`, + device_authorization_endpoint: `${issuer}/oauth/device`, + grant_types_supported: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + }) + ) + ); + const config = await discover(issuer); + expect(config.authorizationEndpoint).toBeUndefined(); + expect(config.deviceAuthorizationEndpoint).toBe(`${issuer}/oauth/device`); + expect(config.grantTypesSupported).toContain("urn:ietf:params:oauth:grant-type:device_code"); + }); +}); + +describe("issuer-binding classification (structural marker, not message text)", () => { + const RESOURCE = "https://api.marker-test.example"; + const BC5 = "https://bc5.marker-test.example"; + + function mountResource(servers: string[]) { + server.use( + mswHttp.get(`${RESOURCE}/.well-known/oauth-protected-resource`, () => + HttpResponse.json({ resource: RESOURCE, authorization_servers: servers }) + ) + ); + } + + it("classifies a committed-issuer binding mismatch as issuer_mismatch", async () => { + mountResource([BC5]); + server.use( + mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => + HttpResponse.json({ issuer: "https://impostor.example", token_endpoint: `${BC5}/t` }) + ) + ); + const err = await discoverFromResource(RESOURCE).catch((e) => e); + expect(err).toBeInstanceOf(DiscoverySelectionError); + expect((err as DiscoverySelectionError).reason).toBe("issuer_mismatch"); + }); + + it("classifies a non-mismatch committed-AS fault as as_fetch_failed", async () => { + // Missing token_endpoint yields an api_error whose message says nothing + // about a mismatch. Message-based classification would misroute it; the + // structural marker keeps it as as_fetch_failed. + mountResource([BC5]); + server.use( + mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => + HttpResponse.json({ issuer: BC5 }) + ) + ); + const err = await discoverFromResource(RESOURCE).catch((e) => e); + expect(err).toBeInstanceOf(DiscoverySelectionError); + expect((err as DiscoverySelectionError).reason).toBe("as_fetch_failed"); + }); + + it("does not leak the marker through the public discover() surface", async () => { + server.use( + mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => + HttpResponse.json({ issuer: "https://impostor.example", token_endpoint: `${BC5}/t` }) + ) + ); + const err = await discover(BC5).catch((e) => e); + expect(err).toBeInstanceOf(BasecampError); + expect(err).not.toBeInstanceOf(DiscoverySelectionError); + expect((err as BasecampError).code).toBe("api_error"); + }); +}); + +describe("SSRF hardening", () => { + it("rejects an over-cap discovery body via the bounded read (not a post-hoc check)", async () => { + const issuer = "https://issuer.ssrf-test.example"; + // A well-formed but oversized document: valid JSON padded far past the cap. + const oversized = `{"issuer":"${issuer}","token_endpoint":"${issuer}/t","pad":"${"x".repeat(256 * 1024)}"}`; + + server.use( + mswHttp.get(`${issuer}${WELL_KNOWN.as}`, () => + new HttpResponse(oversized, { status: 200, headers: { "Content-Type": "application/json" } }) + ) + ); + + // Cap at 8 KiB: the streaming reader cancels once the accumulated bytes + // exceed the cap, so the full 256 KiB body is never buffered. + await expect(discover(issuer, { maxBodyBytes: 8 * 1024 })).rejects.toMatchObject({ + code: "api_error", + }); + }); + + it("ignores Infinity/NaN/negative maxBodyBytes and applies the default cap", async () => { + const issuer = "https://issuer.badcap-test.example"; + // Padded well past the 1 MiB default cap. A caller-supplied Infinity/NaN/ + // negative must NOT disable the bound — it falls back to the default so the + // oversized body is still rejected. + const oversized = + `{"issuer":"${issuer}","token_endpoint":"${issuer}/t","pad":"${"x".repeat(2 * 1024 * 1024)}"}`; + + server.use( + mswHttp.get(`${issuer}${WELL_KNOWN.as}`, () => + new HttpResponse(oversized, { status: 200, headers: { "Content-Type": "application/json" } }) + ) + ); + + for (const bad of [Infinity, Number.NaN, -1]) { + await expect( + discover(issuer, { maxBodyBytes: bad }) + ).rejects.toMatchObject({ code: "api_error" }); + } + }); + + it("does not follow a redirect on a discovery fetch", async () => { + const issuer = "https://issuer.redirect-test.example"; + let attackerContacted = false; + server.use( + mswHttp.get(`${issuer}${WELL_KNOWN.as}`, () => + new HttpResponse(null, { + status: 302, + headers: { Location: "https://attacker.example.com/.well-known/oauth-authorization-server" }, + }) + ), + mswHttp.get("https://attacker.example.com/.well-known/oauth-authorization-server", () => { + attackerContacted = true; + return HttpResponse.json({ issuer: "https://attacker.example.com", token_endpoint: "x" }); + }) + ); + + await expect(discover(issuer)).rejects.toMatchObject({ code: "api_error" }); + expect(attackerContacted).toBe(false); + }); +}); From 23e1448e9a913c749618abad08cd38fde94879e1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 14 Jul 2026 17:03:00 -0700 Subject: [PATCH 03/38] Python: resource-first OAuth discovery + issuer binding + SSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Ask #1 for the Python SDK (SPEC.md §16), mirroring the TS reference: - discover_protected_resource (RFC 9728) + discover_from_resource orchestrator alongside discover (RFC 8414), now with code-point issuer binding and the origin-root profile (require_origin_root, urllib-based, no regex). - OAuthConfig.authorization_endpoint is now str | None (absent vs present-empty preserved); added device_authorization_endpoint / grant_types_supported. - SSRF: _fetch_discovery_document switches from the buffered response.content size check to a genuine httpx.stream() read that aborts once the cap is exceeded, with follow_redirects=False and a bounded timeout. Non-2xx → api_error. - Selection (expected_issuer authoritative, else Basecamp-profile exclusion heuristic; ambiguity is hard) and the stage-sensitive fallback: DiscoveryResult (selected|soft-fallback) vs raised DiscoverySelectionError for hard cases. - New test_resource_discovery.py drives the shared conformance/oauth fixtures with placeholder substitution and asserts zero Launchpad requests on hard failures. README documents the new model. make py-check green (264 tests, coverage 77.9%, mypy + ruff clean). --- python/README.md | 60 ++- python/src/basecamp/_security.py | 61 ++- python/src/basecamp/oauth/__init__.py | 25 +- python/src/basecamp/oauth/config.py | 69 +++- python/src/basecamp/oauth/discovery.py | 323 ++++++++++++++-- python/src/basecamp/oauth/errors.py | 34 +- python/tests/oauth/test_discovery.py | 10 +- python/tests/oauth/test_resource_discovery.py | 353 ++++++++++++++++++ .../services/authorization_service.rb | 0 9 files changed, 890 insertions(+), 45 deletions(-) create mode 100644 python/tests/oauth/test_resource_discovery.py rename ruby/lib/basecamp/{generated => }/services/authorization_service.rb (100%) diff --git a/python/README.md b/python/README.md index 4564775bb..d597db870 100644 --- a/python/README.md +++ b/python/README.md @@ -9,7 +9,7 @@ Official Python SDK for the [Basecamp API](https://github.com/basecamp/bc3-api). ## Features - **Full API coverage** — 40 generated services covering projects, todos, messages, schedules, campfires, card tables, and more -- **OAuth 2.0 authentication** — PKCE support, token refresh, Launchpad discovery +- **OAuth 2.0 authentication** — PKCE support, token refresh, resource-first (RFC 9728 + RFC 8414) discovery - **Static token authentication** — Simple setup for personal integrations - **Automatic retry with backoff** — Exponential backoff with jitter, respects `Retry-After` headers - **Pagination handling** — Automatic Link header-based pagination with `ListResult` @@ -154,10 +154,66 @@ The SDK provides helpers for the full OAuth 2.0 authorization code flow with PKC from basecamp.oauth import discover_launchpad config = discover_launchpad() -# config.authorization_endpoint +# config.authorization_endpoint # Optional[str] — may be None (see below) # config.token_endpoint ``` +`config.authorization_endpoint` is now **optional** (`str | None`): device-only +authorization servers omit it, so authorization-code consumers MUST assert its +presence before use. `token_endpoint` stays required. + +#### Resource-first discovery (RFC 9728 + RFC 8414) + +BC5's Authorization Server metadata lives only at its canonical issuer (the web +host), not the API host. Discovery therefore starts from the **resource** and +composes with AS discovery. Two composable operations plus an orchestrator: + +```python +from basecamp.oauth import ( + discover, # RFC 8414 AS metadata + issuer binding + discover_protected_resource, # RFC 9728 resource metadata (hop 1) + discover_from_resource, # orchestrator: selection + fallback +) + +result = discover_from_resource("https://3.basecampapi.com") +if result.kind == "selected": + config = result.config # bound OAuthConfig from the selected issuer +else: # result.kind == "fallback" + config = discover_launchpad() # result.reason explains why +``` + +**Selection.** Pass `expected_issuer="https://3.basecamp.com"` for an explicit, +authoritative choice (raises `expected_issuer_unavailable` if it is not +advertised — never a silent fallback). Without it, the SDK identifies BC5 by +exclusion: exactly one non-Launchpad issuer is selected; two or more raise +`ambiguous_issuers`; zero falls back to Launchpad. + +**Stage-sensitive fallback.** `discover_from_resource` returns a +`DiscoveryResult` that is either `selected` or a soft `fallback` whose `reason` +is one of **only** two `FallbackReason` values. Every hard case raises +`DiscoverySelectionError` (carrying a `.reason`) — no consumer may convert a +raise into a Launchpad request. + +| Stage / failure | Outcome | +|---|---| +| Hop-1 fetch/parse fails, or `resource` mismatch | soft `resource_discovery_failed` → Launchpad | +| Valid metadata omits BC5 (absent / `[]` / only-Launchpad) | soft `no_as_advertised` → Launchpad | +| ≥2 non-Launchpad issuers, no `expected_issuer` | **raise** `ambiguous_issuers` | +| `expected_issuer` not advertised | **raise** `expected_issuer_unavailable` | +| BC5 committed → invalid issuer origin | **raise** `invalid_issuer_origin` | +| BC5 committed → AS-metadata fetch fails (5xx / network) | **raise** `as_fetch_failed` | +| BC5 committed → issuer binding mismatch | **raise** `issuer_mismatch` | +| BC5 committed → missing per-grant endpoint (consumer-asserted) | **raise** `capability_unavailable` | + +`discover_protected_resource` preserves `authorization_servers` absent (`None`) +vs present-but-empty (`[]`) distinctly. + +**SSRF hardening.** Both hops require HTTPS (localhost exempt), validate the +origin-root with the transport URL parser before any socket opens, suppress +redirects, bound the timeout, and read the body under a genuine streaming cap +that aborts before an oversized body is buffered. Non-2xx on either hop maps to +`api_error`. + ### PKCE and Authorization URL ```python diff --git a/python/src/basecamp/_security.py b/python/src/basecamp/_security.py index 318ffa44d..5988615c4 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -1,6 +1,6 @@ from __future__ import annotations -from urllib.parse import urljoin, urlparse +from urllib.parse import urljoin, urlparse, urlsplit import httpx @@ -40,6 +40,65 @@ def require_https(url: str, label: str = "URL") -> None: raise UsageError(f"{label} must include a hostname: {url}") +def _is_localhost_host(host: str) -> bool: + h = host.lower() + return h in ("localhost", "127.0.0.1", "::1") or h.endswith(".localhost") + + +def require_origin_root(raw: str, label: str = "origin") -> str: + """Parse and enforce the origin-root profile, returning the normalized origin. + + Accepts iff scheme is https (or http on localhost), a host is present, any + port is valid, the path is empty or exactly ``/``, and there is no query, + fragment, or userinfo. Parsing uses ``urllib.parse`` — the transport parser, + never a regex — so bracketed IPv6 (``http://[::1]:3000``) and ports agree + with the host the client actually dials. + + Raises :class:`~basecamp.errors.UsageError` on any violation: a bad + caller-supplied origin is a usage error. Callers validating an *advertised* + origin catch and reclassify (e.g. ``invalid_issuer_origin``). + + Returns the normalized origin (``scheme://host[:port]``, no trailing slash, + default port dropped). + """ + try: + parts = urlsplit(raw) + except ValueError as exc: + raise UsageError(f"Invalid {label}: not a valid absolute URL: {raw}") from exc + + scheme = parts.scheme.lower() + host = parts.hostname + if not scheme or not host: + raise UsageError(f"Invalid {label}: not a valid absolute URL: {raw}") + + is_localhost_http = scheme == "http" and _is_localhost_host(host) + if scheme != "https" and not is_localhost_http: + raise UsageError(f"{label} must use HTTPS (or http on localhost): {raw}") + # Reject on the *presence* of userinfo, not its truthiness: urlsplit reports + # an empty (but non-None) username for authorities like "@host" or ":@host", + # so a truthiness test would let empty-credential forms slip through. An "@" + # in the netloc is a userinfo delimiter regardless of what surrounds it. + if parts.username is not None or parts.password is not None or "@" in parts.netloc: + raise UsageError(f"{label} must not contain userinfo: {raw}") + if parts.query or parts.fragment: + raise UsageError(f"{label} must not contain a query or fragment: {raw}") + if parts.path not in ("", "/"): + raise UsageError(f"{label} must be an origin root (no path): {raw}") + + # Accessing .port validates it (urlsplit defers port parsing); a non-numeric + # or out-of-range port raises ValueError here. + try: + port = parts.port + except ValueError as exc: + raise UsageError(f"{label} has an invalid port: {raw}") from exc + + host_part = f"[{host}]" if ":" in host else host + default_port = 443 if scheme == "https" else 80 + if port is None or port == default_port: + return f"{scheme}://{host_part}" + return f"{scheme}://{host_part}:{port}" + + def is_localhost(url: str) -> bool: # Decide with the SAME parser the transport dials with (httpx.URL, see # _http.py) so the guard can never disagree with the client about which diff --git a/python/src/basecamp/oauth/__init__.py b/python/src/basecamp/oauth/__init__.py index 890b00ec1..7f9f41903 100644 --- a/python/src/basecamp/oauth/__init__.py +++ b/python/src/basecamp/oauth/__init__.py @@ -1,24 +1,43 @@ from __future__ import annotations +from basecamp._security import require_origin_root from basecamp.oauth.authorize import build_authorization_url -from basecamp.oauth.config import OAuthConfig -from basecamp.oauth.discovery import LAUNCHPAD_BASE_URL, discover, discover_launchpad -from basecamp.oauth.errors import OAuthError +from basecamp.oauth.config import ( + DiscoveryResult, + FallbackReason, + OAuthConfig, + ProtectedResourceMetadata, +) +from basecamp.oauth.discovery import ( + LAUNCHPAD_BASE_URL, + discover, + discover_from_resource, + discover_launchpad, + discover_protected_resource, +) +from basecamp.oauth.errors import DiscoverySelectionError, OAuthError from basecamp.oauth.exchange import exchange_code, refresh_token from basecamp.oauth.pkce import PKCE, generate_pkce, generate_state from basecamp.oauth.token import OAuthToken __all__ = [ "OAuthConfig", + "ProtectedResourceMetadata", + "DiscoveryResult", + "FallbackReason", "OAuthToken", "PKCE", "discover", "discover_launchpad", + "discover_protected_resource", + "discover_from_resource", + "require_origin_root", "generate_pkce", "generate_state", "build_authorization_url", "exchange_code", "refresh_token", "OAuthError", + "DiscoverySelectionError", "LAUNCHPAD_BASE_URL", ] diff --git a/python/src/basecamp/oauth/config.py b/python/src/basecamp/oauth/config.py index ae14842b4..9d9a9d835 100644 --- a/python/src/basecamp/oauth/config.py +++ b/python/src/basecamp/oauth/config.py @@ -1,14 +1,79 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum +from typing import Literal @dataclass(frozen=True) class OAuthConfig: - """OAuth 2 server configuration from discovery endpoint.""" + """OAuth 2 server configuration from discovery endpoint (RFC 8414).""" issuer: str - authorization_endpoint: str token_endpoint: str + # Optional as of BC5 resource-first discovery: device-only authorization + # servers omit it. Authorization-code consumers MUST assert its presence + # before use. Absent (``None``) and present-empty are preserved distinctly. + authorization_endpoint: str | None = None + device_authorization_endpoint: str | None = None registration_endpoint: str | None = None scopes_supported: list[str] | None = None + grant_types_supported: list[str] | None = None + code_challenge_methods_supported: list[str] | None = None + + +@dataclass(frozen=True) +class ProtectedResourceMetadata: + """RFC 9728 protected-resource metadata (hop 1 of resource-first discovery).""" + + resource: str + # Authorization servers advertised for this resource. Absent (``None``) and + # present-but-empty (``[]``) are preserved distinctly: BC5 omits the key + # while dark (RFC 9728 §3.2). Both select Launchpad, but the distinction is + # meaningful to callers inspecting the metadata directly. + authorization_servers: list[str] | None = None + + +class FallbackReason(StrEnum): + """The ONLY two soft outcomes under which resource-first discovery yields a + Launchpad fallback rather than a selected config. Every other failure raises + :class:`~basecamp.oauth.errors.DiscoverySelectionError`. + """ + + RESOURCE_DISCOVERY_FAILED = "resource_discovery_failed" + NO_AS_ADVERTISED = "no_as_advertised" + + +@dataclass(frozen=True) +class DiscoveryResult: + """Result of :func:`~basecamp.oauth.discovery.discover_from_resource`: either + a selected AS config, or a soft fallback to Launchpad. Hard failures are + raised, not represented here. + + ``kind`` discriminates the two shapes, and the four fields carry a strict + per-``kind`` invariant that the static ``| None`` types cannot express (a + typed caller must branch on ``kind`` first, then read the guaranteed fields): + + - ``kind == "selected"`` → ``config`` and ``issuer`` are non-``None``; + ``reason`` is ``None``. + - ``kind == "fallback"`` → ``reason`` is non-``None`` (a + :class:`FallbackReason`); ``config`` and ``issuer`` are ``None``. + + Use :meth:`selected_config` to read ``config`` with the invariant enforced + (it narrows to a non-optional :class:`OAuthConfig`, raising if misused). + """ + + kind: Literal["selected", "fallback"] + config: OAuthConfig | None = None + issuer: str | None = None + reason: FallbackReason | None = None + + def selected_config(self) -> OAuthConfig: + """Return the selected :class:`OAuthConfig`, narrowing away ``None`` for + typed callers. Raises :class:`ValueError` if this is not a ``selected`` + result — enforcing the documented invariant instead of returning + ``OAuthConfig | None`` that every caller would have to re-narrow. + """ + if self.kind != "selected" or self.config is None: + raise ValueError("DiscoveryResult.selected_config() called on a non-selected result") + return self.config diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index 407f66168..95f67367b 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -1,77 +1,332 @@ +"""OAuth 2.0 discovery for the Basecamp SDK. + +Two composable operations plus an orchestrator (SPEC.md §16): + + - ``discover(base_url)`` — RFC 8414 AS metadata + issuer binding + - ``discover_protected_resource(origin)`` — RFC 9728 resource metadata + - ``discover_from_resource(origin, ...)`` — resource-first selection + fallback + +All fetches are SSRF-hardened: HTTPS-only origins (localhost exempt), the origin +is parsed/validated with the transport URL parser before any socket opens, +redirects are suppressed, timeouts are bounded, and bodies are read under a +genuine streaming cap that aborts before the whole oversized body is buffered. +""" + from __future__ import annotations +import json +from typing import Any + import httpx -from basecamp._security import MAX_ERROR_BODY_BYTES, check_body_size, is_localhost, require_https, truncate -from basecamp.errors import ApiError -from basecamp.oauth.config import OAuthConfig -from basecamp.oauth.errors import OAuthError +from basecamp._security import require_origin_root, truncate +from basecamp.errors import BasecampError +from basecamp.oauth.config import ( + DiscoveryResult, + FallbackReason, + OAuthConfig, + ProtectedResourceMetadata, +) +from basecamp.oauth.errors import DiscoverySelectionError, OAuthError LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com" _DISCOVERY_TIMEOUT = 10.0 +# Cap on a discovery response body (1 MiB) — discovery documents are tiny. +MAX_DISCOVERY_BODY_BYTES = 1 * 1024 * 1024 + + +def _is_str_list(value: Any) -> bool: + """True iff ``value`` is a list whose every element is a ``str``.""" + return isinstance(value, list) and all(isinstance(v, str) for v in value) -def discover(base_url: str) -> OAuthConfig: - """Fetch OAuth 2 server configuration from a well-known discovery endpoint. - GETs ``{base_url}/.well-known/oauth-authorization-server``, parses the - JSON response, and returns an :class:`OAuthConfig`. HTTPS is enforced - unless the host is localhost. +class _IssuerBindingError(OAuthError): + """Structured marker: AS metadata failed the RFC 8414 issuer code-point bind. + + Kept module-private (and deliberately NOT in ``errors.py``, which device flow + shares) so :func:`discover_from_resource` classifies an issuer mismatch by + ``isinstance`` — never by substring-matching an exception message, which is + brittle and locale/wording-sensitive. """ - if not is_localhost(base_url): - require_https(base_url, "discovery base URL") - normalized = base_url.rstrip("/") - url = f"{normalized}/.well-known/oauth-authorization-server" + def __init__(self, message: str, **kwargs: Any) -> None: + super().__init__("api_error", message, **kwargs) + + +def _fetch_discovery_document(url: str, timeout: float, max_body_bytes: int) -> Any: + """SSRF-hardened GET of a discovery document. + The origin must already be validated (via :func:`require_origin_root`); this + suppresses redirects, bounds the timeout, reads the body under a genuine + streaming cap that aborts once ``max_body_bytes`` is exceeded, and maps any + non-2xx status to ``api_error`` (not ``network``). + """ try: - response = httpx.get(url, headers={"Accept": "application/json"}, timeout=_DISCOVERY_TIMEOUT) + with httpx.stream( + "GET", + url, + headers={"Accept": "application/json"}, + timeout=timeout, + follow_redirects=False, + ) as response: + chunks: list[bytes] = [] + total = 0 + for chunk in response.iter_bytes(): + total += len(chunk) + if total > max_body_bytes: + # Abort the stream — leaving the ``with`` closes the + # connection, so the oversized body is never fully buffered. + raise OAuthError("api_error", "OAuth discovery response exceeds size cap") + chunks.append(chunk) + status = response.status_code + body = b"".join(chunks) except httpx.TimeoutException as exc: raise OAuthError("network", "OAuth discovery timed out", retryable=True) from exc except httpx.HTTPError as exc: raise OAuthError("network", f"OAuth discovery failed: {exc}", retryable=True) from exc - try: - check_body_size(response.content, MAX_ERROR_BODY_BYTES, "Discovery") - except ApiError as exc: - raise OAuthError("api_error", str(exc)) from exc - - if not response.is_success: + # A suppressed redirect (3xx) surfaces here as a non-2xx api_error rather + # than a followed request to an attacker-influenced Location. + if not 200 <= status < 300: raise OAuthError( "api_error", - f"OAuth discovery failed with status {response.status_code}: {truncate(response.text)}", - http_status=response.status_code, + f"OAuth discovery failed with status {status}: {truncate(body.decode(errors='replace'))}", + http_status=status, ) try: - data = response.json() + return json.loads(body) except ValueError as exc: - raise OAuthError("api_error", f"Failed to parse discovery response: {exc}") from exc + raise OAuthError("api_error", f"Failed to parse OAuth discovery response: {exc}") from exc + +def discover( + base_url: str, + *, + timeout: float = _DISCOVERY_TIMEOUT, + max_body_bytes: int = MAX_DISCOVERY_BODY_BYTES, +) -> OAuthConfig: + """Discover OAuth 2.0 Authorization Server Metadata (RFC 8414). + + GETs ``{base_url}/.well-known/oauth-authorization-server`` and binds it: the + returned ``issuer`` must equal the requested origin by code-point (no + normalization beyond origin-root parsing). ``token_endpoint`` is required; + ``authorization_endpoint`` is optional (device-only servers omit it). + + Raises :class:`~basecamp.errors.UsageError` on a malformed origin and + :class:`OAuthError` (``api_error``) on invalid metadata. + """ + issuer_origin = require_origin_root(base_url, "OAuth discovery base URL") + url = f"{issuer_origin}/.well-known/oauth-authorization-server" + + data = _fetch_discovery_document(url, timeout, max_body_bytes) if not isinstance(data, dict): raise OAuthError("api_error", "OAuth discovery response is not a JSON object") - _validate(data) + return _parse_and_bind_as_metadata(data, issuer_origin) + + +def _parse_and_bind_as_metadata(data: dict[str, Any], expected_issuer_origin: str) -> OAuthConfig: + """Validate AS metadata and bind ``issuer`` to ``expected_issuer_origin`` by + code-point. Universal validation only: ``issuer`` + ``token_endpoint`` + present and non-empty, and any present ``*_endpoint`` field non-empty. + Per-grant endpoint checks are the consumer's responsibility. + """ + issuer = data.get("issuer") + if not isinstance(issuer, str) or not issuer: + raise OAuthError("api_error", "Invalid OAuth discovery response: missing required fields: issuer") + # RFC 8414 §3.3/§4: issuer identical by code-point. No normalization. + if issuer != expected_issuer_origin: + raise _IssuerBindingError( + f'OAuth issuer mismatch: metadata issuer "{issuer}" does not equal "{expected_issuer_origin}"', + ) + if not isinstance(data.get("token_endpoint"), str) or not data.get("token_endpoint"): + raise OAuthError("api_error", "Invalid OAuth discovery response: missing required fields: token_endpoint") + # Every present ``*_endpoint`` field must be a non-empty string — reject "", + # lists, numbers, null (a non-string endpoint is malformed, not merely empty). + for key, value in data.items(): + if key.endswith("_endpoint") and (not isinstance(value, str) or value == ""): + raise OAuthError("api_error", f"Invalid OAuth discovery response: invalid {key}") + # List-valued metadata fields, when present, must be arrays of strings — + # never a bare string (substring-matching one would falsely enable a grant + # or scope) nor a list carrying non-string members. + for list_field in ( + "grant_types_supported", + "scopes_supported", + "code_challenge_methods_supported", + ): + value = data.get(list_field) + if value is not None and not _is_str_list(value): + raise OAuthError( + "api_error", + f"Invalid OAuth discovery response: {list_field} must be an array of strings", + ) return OAuthConfig( - issuer=data["issuer"], - authorization_endpoint=data["authorization_endpoint"], + issuer=issuer, token_endpoint=data["token_endpoint"], + authorization_endpoint=data.get("authorization_endpoint"), + device_authorization_endpoint=data.get("device_authorization_endpoint"), registration_endpoint=data.get("registration_endpoint"), scopes_supported=data.get("scopes_supported"), + grant_types_supported=data.get("grant_types_supported"), + code_challenge_methods_supported=data.get("code_challenge_methods_supported"), ) -def discover_launchpad() -> OAuthConfig: - """Convenience wrapper: discover configuration from Launchpad.""" - return discover(LAUNCHPAD_BASE_URL) +def discover_protected_resource( + resource_origin: str, + *, + timeout: float = _DISCOVERY_TIMEOUT, + max_body_bytes: int = MAX_DISCOVERY_BODY_BYTES, +) -> ProtectedResourceMetadata: + """Discover RFC 9728 protected-resource metadata. + + GETs ``{resource_origin}/.well-known/oauth-protected-resource``. ``resource`` + is required and must equal the requested origin by code-point. + ``authorization_servers`` is OPTIONAL and preserved distinctly as absent + (``None``) vs present-but-empty (``[]``). + + Raises :class:`~basecamp.errors.UsageError` on a malformed caller origin and + :class:`OAuthError` (``api_error``) on invalid metadata. + """ + origin = require_origin_root(resource_origin, "resource origin") + url = f"{origin}/.well-known/oauth-protected-resource" + data = _fetch_discovery_document(url, timeout, max_body_bytes) + if not isinstance(data, dict): + raise OAuthError("api_error", "Resource metadata response is not a JSON object") -def _validate(data: dict) -> None: - missing = [f for f in ("issuer", "authorization_endpoint", "token_endpoint") if not data.get(f)] - if missing: + resource = data.get("resource") + if not isinstance(resource, str) or not resource: + raise OAuthError("api_error", "Invalid resource metadata: missing required field: resource") + # Bind the resource identifier to the requested origin, code-point exact. + if resource != origin: raise OAuthError( "api_error", - f"Invalid OAuth discovery response: missing required fields: {', '.join(missing)}", + f'Resource identifier mismatch: metadata resource "{resource}" does not equal "{origin}"', ) + + # Preserve absent (None) vs present-empty ([]). When present it must be a list + # of strings — a bare string previously slipped through and was iterated + # char-by-char during selection; reject it as malformed so the orchestrator + # soft-falls-back. A present-but-null value normalizes to []. + authorization_servers: list[str] | None + if "authorization_servers" not in data: + authorization_servers = None + else: + servers = data.get("authorization_servers") + if servers is None: + authorization_servers = [] + elif _is_str_list(servers): + authorization_servers = servers + else: + raise OAuthError( + "api_error", + "Invalid resource metadata: authorization_servers must be an array of strings", + ) + + return ProtectedResourceMetadata(resource=resource, authorization_servers=authorization_servers) + + +def _is_launchpad_issuer(issuer: str) -> bool: + """True when an issuer string is a valid origin root equal to Launchpad's. + + Both sides run through :func:`require_origin_root`, so an advertised + look-alike that is not a clean origin root — e.g. + ``https://launchpad.37signals.com/path`` (path), userinfo, or a query — is + not treated as Launchpad. It stays a non-Launchpad candidate and later fails + hard (``ambiguous_issuers`` / ``invalid_issuer_origin``) rather than being + silently excluded. A trailing-slash-only origin root still matches because + ``require_origin_root`` normalizes it away. + """ + try: + return require_origin_root(issuer, "issuer") == require_origin_root(LAUNCHPAD_BASE_URL, "issuer") + except BasecampError: + return False + + +def discover_from_resource( + resource_origin: str, + *, + expected_issuer: str | None = None, + timeout: float = _DISCOVERY_TIMEOUT, + max_body_bytes: int = MAX_DISCOVERY_BODY_BYTES, +) -> DiscoveryResult: + """Resource-first discovery orchestrator (SPEC.md §16). + + Composes RFC 9728 + RFC 8414 and applies the stage-sensitive fallback state + machine. Returns a :class:`DiscoveryResult` that is either ``selected`` (with + ``config`` and ``issuer``) or a soft ``fallback`` whose ``reason`` is one of + :class:`FallbackReason` ONLY. Every hard failure raises + :class:`DiscoverySelectionError`; callers MUST NOT convert a raise into a + Launchpad request. + """ + # Origin-root validation of the *caller's* input is a usage error — let it + # propagate as-is (never a soft fallback). + origin = require_origin_root(resource_origin, "resource origin") + + # --- Hop 1: resource metadata. Failure here is soft (before selection). --- + try: + resource = discover_protected_resource(origin, timeout=timeout, max_body_bytes=max_body_bytes) + except OAuthError: + return DiscoveryResult(kind="fallback", reason=FallbackReason.RESOURCE_DISCOVERY_FAILED) + + advertised = resource.authorization_servers or [] + + # --- Selection --- + if expected_issuer is not None: + selected = next((s for s in advertised if s == expected_issuer), None) + if selected is None: + raise DiscoverySelectionError( + "expected_issuer_unavailable", + f'Expected issuer "{expected_issuer}" is not advertised by the resource', + ) + else: + non_launchpad = [s for s in advertised if not _is_launchpad_issuer(s)] + if len(non_launchpad) >= 2: + raise DiscoverySelectionError( + "ambiguous_issuers", + "Multiple non-Launchpad issuers advertised; pass expected_issuer to disambiguate: " + + ", ".join(non_launchpad), + ) + if not non_launchpad: + # Valid resource metadata omits BC5 — soft fallback (before selection). + return DiscoveryResult(kind="fallback", reason=FallbackReason.NO_AS_ADVERTISED) + selected = non_launchpad[0] + + # --- BC5 is now committed: every subsequent failure is fatal (no Launchpad). --- + try: + issuer_origin = require_origin_root(selected, "advertised issuer") + except BasecampError as exc: + # A bad *advertised* issuer origin is a hard classification failure, not + # the usage error a bad caller origin would be. + raise DiscoverySelectionError( + "invalid_issuer_origin", + f'Advertised issuer "{selected}" is not a valid origin root', + ) from exc + + try: + config = discover(issuer_origin, timeout=timeout, max_body_bytes=max_body_bytes) + except _IssuerBindingError as exc: + # A structured marker — not a message substring — distinguishes an + # issuer-binding mismatch from a generic fetch failure. + raise DiscoverySelectionError("issuer_mismatch", str(exc)) from exc + except OAuthError as exc: + raise DiscoverySelectionError( + "as_fetch_failed", + f'AS metadata fetch failed for committed issuer "{issuer_origin}": {exc}', + ) from exc + + return DiscoveryResult(kind="selected", config=config, issuer=config.issuer) + + +def discover_launchpad( + *, + timeout: float = _DISCOVERY_TIMEOUT, + max_body_bytes: int = MAX_DISCOVERY_BODY_BYTES, +) -> OAuthConfig: + """Convenience wrapper: discover configuration from Launchpad.""" + return discover(LAUNCHPAD_BASE_URL, timeout=timeout, max_body_bytes=max_body_bytes) diff --git a/python/src/basecamp/oauth/errors.py b/python/src/basecamp/oauth/errors.py index 1e112b1a7..ea3ad68e9 100644 --- a/python/src/basecamp/oauth/errors.py +++ b/python/src/basecamp/oauth/errors.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any, Literal + from basecamp.errors import BasecampError, ErrorCode _OAUTH_TYPE_TO_CODE: dict[str, str] = { @@ -9,6 +11,18 @@ "api_error": ErrorCode.API, } +# Hard resource-first selection/validation failures. These are RAISED, never +# returned as a soft fallback, so no consumer can convert one into a Launchpad +# request. +DiscoverySelectionReason = Literal[ + "ambiguous_issuers", + "expected_issuer_unavailable", + "invalid_issuer_origin", + "as_fetch_failed", + "issuer_mismatch", + "capability_unavailable", +] + class OAuthError(BasecampError): """OAuth-specific error with a type classifier. @@ -16,7 +30,25 @@ class OAuthError(BasecampError): Types: "validation", "auth", "network", "api_error" """ - def __init__(self, oauth_type: str, message: str, **kwargs): + def __init__(self, oauth_type: str, message: str, **kwargs: Any): code = _OAUTH_TYPE_TO_CODE.get(oauth_type, ErrorCode.API) super().__init__(message, code=code, **kwargs) self.oauth_type = oauth_type + + +class DiscoverySelectionError(OAuthError): + """Hard resource-first selection/validation failure. + + Raised — never returned as a fallback — so no consumer can convert it into a + Launchpad request. The ``reason`` attribute distinguishes the specific hard + case (see :data:`DiscoverySelectionReason`). + + ``capability_unavailable`` and ``expected_issuer_unavailable`` are + consumer/usage-shaped; the remaining reasons are AS-metadata faults surfaced + as ``api_error``. + """ + + def __init__(self, reason: DiscoverySelectionReason, message: str, **kwargs: Any): + oauth_type = "validation" if reason == "capability_unavailable" else "api_error" + super().__init__(oauth_type, message, **kwargs) + self.reason: DiscoverySelectionReason = reason diff --git a/python/tests/oauth/test_discovery.py b/python/tests/oauth/test_discovery.py index d52914bfd..9e54986ce 100644 --- a/python/tests/oauth/test_discovery.py +++ b/python/tests/oauth/test_discovery.py @@ -41,13 +41,19 @@ def test_discover_https_enforcement(self): @respx.mock def test_discover_localhost_allowed(self): + # Issuer must bind to the requested origin by code-point (RFC 8414). + local_metadata = { + "issuer": "http://localhost:3000", + "authorization_endpoint": "http://localhost:3000/oauth/authorize", + "token_endpoint": "http://localhost:3000/oauth/token", + } respx.get("http://localhost:3000/.well-known/oauth-authorization-server").mock( - return_value=httpx.Response(200, json=DISCOVERY_RESPONSE) + return_value=httpx.Response(200, json=local_metadata) ) config = discover("http://localhost:3000") - assert config.issuer == DISCOVERY_RESPONSE["issuer"] + assert config.issuer == "http://localhost:3000" @respx.mock def test_discover_missing_fields(self): diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py new file mode 100644 index 000000000..5daa77444 --- /dev/null +++ b/python/tests/oauth/test_resource_discovery.py @@ -0,0 +1,353 @@ +"""Resource-first OAuth discovery tests. + +Drives the shared, data-only fixtures in ``conformance/oauth/fixtures`` with this +harness's mock origins substituted for the ``{{...}}`` placeholders, so issuer / +resource binding stays code-point-exact against the mocked hosts. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest +import respx + +from basecamp.errors import BasecampError, UsageError +from basecamp.oauth import ( + DiscoverySelectionError, + OAuthError, + discover, + discover_from_resource, + discover_protected_resource, + require_origin_root, +) + +FIXTURE_DIR = Path(__file__).resolve().parents[3] / "conformance" / "oauth" / "fixtures" + +# Mock origins substituted for fixture placeholders. LAUNCHPAD must be the real +# origin because the fallback path targets it. +ORIGINS = { + "{{RESOURCE_ORIGIN}}": "https://api.basecamp-test.example", + "{{ISSUER_ORIGIN}}": "https://issuer.basecamp-test.example", + "{{LAUNCHPAD_ORIGIN}}": "https://launchpad.37signals.com", + "{{BC5_ISSUER}}": "https://bc5.basecamp-test.example", +} + +WELL_KNOWN_RESOURCE = "/.well-known/oauth-protected-resource" +WELL_KNOWN_AS = "/.well-known/oauth-authorization-server" + +# Small cap used only for the oversized-body SSRF fixture. +SMALL_CAP = 8 * 1024 + + +def substitute(value: Any) -> Any: + text = json.dumps(value) + for placeholder, origin in ORIGINS.items(): + text = text.replace(placeholder, origin) + return json.loads(text) + + +def load_fixtures() -> list[dict[str, Any]]: + return [json.loads(p.read_text()) for p in sorted(FIXTURE_DIR.glob("*.json"))] + + +def _add_route(router: respx.Router, url: str, exchange: dict[str, Any]) -> None: + route = router.get(url) + if exchange.get("transportError"): + route.mock(side_effect=httpx.ConnectError("connection refused")) + elif exchange.get("redirectTo"): + route.mock( + return_value=httpx.Response( + exchange.get("status", 302), + headers={"Location": exchange["redirectTo"]}, + ) + ) + elif exchange.get("oversized"): + body = dict(exchange.get("body") or {}) + body["pad"] = "x" * (256 * 1024) # far past the SSRF cap + route.mock(return_value=httpx.Response(exchange.get("status", 200), json=body)) + else: + status = exchange.get("status", 200) + body = exchange.get("body") + if body is None: + route.mock(return_value=httpx.Response(status)) + else: + route.mock(return_value=httpx.Response(status, json=body)) + + +@pytest.mark.parametrize("raw", load_fixtures(), ids=lambda fx: fx["name"]) +def test_resource_discovery_fixture(raw: dict[str, Any]) -> None: + fx = substitute(raw) + op = fx["operation"] + expect = fx["expect"] + outcome = expect["outcome"] + + oversized = bool((fx.get("hop1") or {}).get("oversized") or (fx.get("hop2") or {}).get("oversized")) + cap = SMALL_CAP if oversized else 1 * 1024 * 1024 + + with respx.mock(assert_all_called=False, assert_all_mocked=True) as router: + contacted = {"launchpad": False} + + def _track(request: httpx.Request) -> httpx.Response: + contacted["launchpad"] = True + origin = ORIGINS["{{LAUNCHPAD_ORIGIN}}"] + return httpx.Response( + 200, + json={ + "issuer": origin, + "authorization_endpoint": f"{origin}/authorization/new", + "token_endpoint": f"{origin}/authorization/token", + }, + ) + + router.get(f"{ORIGINS['{{LAUNCHPAD_ORIGIN}}']}{WELL_KNOWN_AS}").mock(side_effect=_track) + + if fx.get("hop1"): + _add_route(router, f"{fx['resourceOrigin']}{WELL_KNOWN_RESOURCE}", fx["hop1"]) + if fx.get("hop2"): + issuer_origin = fx["hop2"].get("origin") or fx.get("issuerOrigin") + _add_route(router, f"{issuer_origin}{WELL_KNOWN_AS}", fx["hop2"]) + + def run() -> Any: + if op == "discoverFromResource": + return discover_from_resource( + fx["resourceOrigin"], expected_issuer=fx.get("expectedIssuer"), max_body_bytes=cap + ) + if op == "discoverProtectedResource": + return discover_protected_resource(fx["resourceOrigin"], max_body_bytes=cap) + return discover(fx["issuerOrigin"], max_body_bytes=cap) + + if outcome == "raise": + with pytest.raises(BasecampError) as exc_info: + run() + err = exc_info.value + error = expect.get("error") + if error == "usage": + assert err.code == "usage" + elif op == "discoverFromResource": + assert isinstance(err, DiscoverySelectionError) + assert err.reason == error + else: + # discover / discoverProtectedResource hard failures are api_error. + assert err.code == "api_error" + # Cross-SDK: the coarse BasecampError category (.code) must equal the + # fixture's errorCategory ("usage" | "validation" | "api_error"). + if expect.get("errorCategory"): + assert err.code == expect["errorCategory"] + elif outcome == "fallback": + result = run() + assert result.kind == "fallback" + assert result.reason == expect["fallbackReason"] + else: # selected + result = run() + if op == "discoverFromResource": + assert result.kind == "selected" + if expect.get("selectedIssuer"): + assert result.issuer == expect["selectedIssuer"] + elif op == "discoverProtectedResource": + if expect.get("selectedIssuer"): + assert result.resource == expect["selectedIssuer"] + else: # discover — absence of a throw is success + assert result.issuer + + if expect.get("launchpadContacted") is False: + assert contacted["launchpad"] is False + + +def test_origin_root_accepts_bracketed_ipv6_localhost() -> None: + # The transport parser accepts bracketed IPv6 where a naive regex breaks. + assert require_origin_root("http://[::1]:3000") == "http://[::1]:3000" + + +def test_origin_root_drops_default_port() -> None: + assert require_origin_root("https://api.example.com:443") == "https://api.example.com" + + +@pytest.mark.parametrize( + "raw", + [ + "https://user@host", # populated userinfo + "https://@example.com", # empty username, absent password + "https://:@host", # empty username and empty password + ], +) +def test_origin_root_rejects_userinfo(raw: str) -> None: + # Rejection keys off the *presence* of userinfo, not its truthiness: an + # empty-but-present username (urlsplit reports "" for "@host") must still + # be rejected rather than slipping through a falsy check. + with pytest.raises(UsageError, match="userinfo"): + require_origin_root(raw) + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("https://host", "https://host"), + ("https://[::1]:3000", "https://[::1]:3000"), + ("https://host:443", "https://host"), + ], +) +def test_origin_root_accepts_legitimate_origins(raw: str, expected: str) -> None: + assert require_origin_root(raw) == expected + + +@respx.mock +def test_device_only_as_omits_authorization_endpoint() -> None: + issuer = ORIGINS["{{ISSUER_ORIGIN}}"] + respx.get(f"{issuer}{WELL_KNOWN_AS}").mock( + return_value=httpx.Response( + 200, + json={ + "issuer": issuer, + "token_endpoint": f"{issuer}/oauth/token", + "device_authorization_endpoint": f"{issuer}/oauth/device", + "grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + }, + ) + ) + + config = discover(issuer) + + assert config.authorization_endpoint is None + assert config.device_authorization_endpoint == f"{issuer}/oauth/device" + assert "urn:ietf:params:oauth:grant-type:device_code" in (config.grant_types_supported or []) + + +@respx.mock +def test_protected_resource_preserves_absent_vs_empty() -> None: + origin = "https://api.basecamp-test.example" + respx.get(f"{origin}{WELL_KNOWN_RESOURCE}").mock(return_value=httpx.Response(200, json={"resource": origin})) + absent = discover_protected_resource(origin) + assert absent.authorization_servers is None + + +@respx.mock +def test_protected_resource_present_empty_array() -> None: + origin = "https://api.basecamp-test.example" + respx.get(f"{origin}{WELL_KNOWN_RESOURCE}").mock( + return_value=httpx.Response(200, json={"resource": origin, "authorization_servers": []}) + ) + empty = discover_protected_resource(origin) + assert empty.authorization_servers == [] + + +@respx.mock +def test_issuer_mismatch_classified_via_marker() -> None: + # Committed to a BC5 issuer, the AS document returns a non-matching issuer. + # The classification must come from the structured _IssuerBindingError marker + # (isinstance), not a substring match on the message. + resource_origin = ORIGINS["{{RESOURCE_ORIGIN}}"] + bc5 = ORIGINS["{{BC5_ISSUER}}"] + respx.get(f"{resource_origin}{WELL_KNOWN_RESOURCE}").mock( + return_value=httpx.Response(200, json={"resource": resource_origin, "authorization_servers": [bc5]}) + ) + respx.get(f"{bc5}{WELL_KNOWN_AS}").mock( + return_value=httpx.Response( + 200, + json={ + "issuer": "https://impostor.basecamp-test.example", # ≠ requested bc5 + "token_endpoint": f"{bc5}/oauth/token", + }, + ) + ) + + with pytest.raises(DiscoverySelectionError) as exc_info: + discover_from_resource(resource_origin) + assert exc_info.value.reason == "issuer_mismatch" + assert exc_info.value.code == "api_error" + + +@respx.mock +def test_issuer_mismatch_marker_is_not_message_based() -> None: + # A generic AS-fetch failure whose message happens to omit "issuer mismatch" + # classifies as as_fetch_failed — proving the marker, not wording, decides. + resource_origin = ORIGINS["{{RESOURCE_ORIGIN}}"] + bc5 = ORIGINS["{{BC5_ISSUER}}"] + respx.get(f"{resource_origin}{WELL_KNOWN_RESOURCE}").mock( + return_value=httpx.Response(200, json={"resource": resource_origin, "authorization_servers": [bc5]}) + ) + respx.get(f"{bc5}{WELL_KNOWN_AS}").mock(return_value=httpx.Response(500)) + + with pytest.raises(DiscoverySelectionError) as exc_info: + discover_from_resource(resource_origin) + assert exc_info.value.reason == "as_fetch_failed" + + +@respx.mock +def test_discovery_rejects_non_string_endpoint_type() -> None: + issuer = ORIGINS["{{ISSUER_ORIGIN}}"] + respx.get(f"{issuer}{WELL_KNOWN_AS}").mock( + return_value=httpx.Response( + 200, + json={"issuer": issuer, "token_endpoint": f"{issuer}/oauth/token", "authorization_endpoint": 42}, + ) + ) + with pytest.raises(OAuthError) as exc_info: + discover(issuer) + assert exc_info.value.code == "api_error" + + +@respx.mock +def test_discovery_rejects_scopes_supported_wrong_type() -> None: + issuer = ORIGINS["{{ISSUER_ORIGIN}}"] + respx.get(f"{issuer}{WELL_KNOWN_AS}").mock( + return_value=httpx.Response( + 200, + json={"issuer": issuer, "token_endpoint": f"{issuer}/oauth/token", "scopes_supported": "read write"}, + ) + ) + with pytest.raises(OAuthError) as exc_info: + discover(issuer) + assert exc_info.value.code == "api_error" + + +@respx.mock +def test_discovery_rejects_list_field_with_non_string_members() -> None: + issuer = ORIGINS["{{ISSUER_ORIGIN}}"] + respx.get(f"{issuer}{WELL_KNOWN_AS}").mock( + return_value=httpx.Response( + 200, + json={ + "issuer": issuer, + "token_endpoint": f"{issuer}/oauth/token", + "code_challenge_methods_supported": ["S256", 256], + }, + ) + ) + with pytest.raises(OAuthError) as exc_info: + discover(issuer) + assert exc_info.value.code == "api_error" + + +@respx.mock +def test_protected_resource_rejects_non_string_resource() -> None: + origin = "https://api.basecamp-test.example" + respx.get(f"{origin}{WELL_KNOWN_RESOURCE}").mock( + return_value=httpx.Response(200, json={"resource": ["not", "a", "string"]}) + ) + with pytest.raises(OAuthError) as exc_info: + discover_protected_resource(origin) + assert exc_info.value.code == "api_error" + + +def test_selected_config_narrows_on_selected_result() -> None: + from basecamp.oauth import DiscoveryResult, OAuthConfig + + config = OAuthConfig(issuer="https://bc5.example", token_endpoint="https://bc5.example/oauth/token") + result = DiscoveryResult(kind="selected", config=config, issuer=config.issuer) + + # Narrows OAuthConfig | None → OAuthConfig for typed callers. + assert result.selected_config() is config + + +def test_selected_config_raises_on_fallback_result() -> None: + from basecamp.oauth import DiscoveryResult + from basecamp.oauth.config import FallbackReason + + result = DiscoveryResult(kind="fallback", reason=FallbackReason.NO_AS_ADVERTISED) + + with pytest.raises(ValueError): + result.selected_config() diff --git a/ruby/lib/basecamp/generated/services/authorization_service.rb b/ruby/lib/basecamp/services/authorization_service.rb similarity index 100% rename from ruby/lib/basecamp/generated/services/authorization_service.rb rename to ruby/lib/basecamp/services/authorization_service.rb From b65f85d0c669d20ab439c6a9596d0247e3979dc0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 14 Jul 2026 17:09:27 -0700 Subject: [PATCH 04/38] Go: resource-first OAuth discovery + issuer binding + SSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Ask #1 for the Go SDK (SPEC.md §16), mirroring the TS reference: - DiscoverProtectedResource (RFC 9728) + DiscoverFromResource orchestrator (with WithExpectedIssuer/WithTimeout/WithMaxBodyBytes options) alongside Discover (RFC 8414), now with code-point issuer binding and an origin-root profile parsed via net/url. - Config.AuthorizationEndpoint is now *string (absent vs present-empty preserved); added DeviceAuthorizationEndpoint *string, GrantTypesSupported, CodeChallengeMethodsSupported. ProtectedResourceMetadata uses *[]string so absent vs [] stay distinct. - SSRF (Go had none): RequireSecureEndpoint pre-check, per-request client with CheckRedirect: http.ErrUseLastResponse (3xx → non-2xx → api_error), bounded context timeout, and io.LimitReader(r, cap+1) bounded read that aborts before buffering an oversized body. Non-2xx → api_error. - Typed DiscoveryResult (selected|soft FallbackReason) + errors.Is-compatible hard sentinels (ErrAmbiguousIssuers, ErrExpectedIssuerUnavailable, ErrInvalidIssuerOrigin, ErrASFetchFailed, ErrIssuerMismatch, ErrCapabilityUnavailable); binding mismatch distinguished from generic fetch failure via an errors.Is marker, not message matching. - New resource_discovery_test.go drives the shared conformance/oauth fixtures via httptest with placeholder substitution and a countingTransport asserting zero Launchpad requests on hard failures; fixed the brittle oauth_test.go:75 normalization assertion. README documents the new model. make go-check green (go test + golangci-lint 0 issues). --- go/README.md | 54 ++ go/pkg/basecamp/oauth/discovery.go | 497 +++++++++++++++- go/pkg/basecamp/oauth/oauth_test.go | 51 +- .../basecamp/oauth/resource_discovery_test.go | 557 ++++++++++++++++++ go/pkg/basecamp/oauth/types.go | 187 +++++- 5 files changed, 1300 insertions(+), 46 deletions(-) create mode 100644 go/pkg/basecamp/oauth/resource_discovery_test.go diff --git a/go/README.md b/go/README.md index 64ddd1388..596aaeb85 100644 --- a/go/README.md +++ b/go/README.md @@ -115,6 +115,60 @@ func main() { } ``` +### OAuth discovery (resource-first) + +BC5's authorization-server (AS) metadata lives only at its canonical issuer (the +web host), so discovery starts from the **resource** (RFC 9728) and composes with +AS metadata discovery (RFC 8414). The `oauth` package exposes three composable +operations on `*oauth.Discoverer`: + +```go +import "github.com/basecamp/basecamp-sdk/go/pkg/basecamp/oauth" + +d := oauth.NewDiscoverer(http.DefaultClient) + +// RFC 8414 — authorization-server metadata, with issuer binding. +cfg, err := d.Discover(ctx, "https://launchpad.37signals.com") + +// RFC 9728 — protected-resource metadata (hop 1). +meta, err := d.DiscoverProtectedResource(ctx, "https://3.basecampapi.com") + +// Orchestrator — resource-first selection + stage-sensitive fallback. +result, err := d.DiscoverFromResource(ctx, "https://3.basecampapi.com") +if err != nil { + // Hard failure — never fall back to Launchpad. Match with errors.Is: + switch { + case errors.Is(err, oauth.ErrAmbiguousIssuers): // ≥2 non-Launchpad issuers, no expected issuer + case errors.Is(err, oauth.ErrExpectedIssuerUnavailable): // expected issuer not advertised + case errors.Is(err, oauth.ErrInvalidIssuerOrigin): // advertised issuer is not a valid origin root + case errors.Is(err, oauth.ErrASFetchFailed): // committed issuer's AS metadata unavailable + case errors.Is(err, oauth.ErrIssuerMismatch): // committed issuer's metadata fails issuer binding + } + return err +} +if result.IsFallback() { + // Soft fallback: result.FallbackReason is resource_discovery_failed or + // no_as_advertised. Fall back to Launchpad (oauth.LaunchpadBaseURL). +} else { + use(result.Config, result.Issuer) // selected BC5 authorization server +} +``` + +Pass `oauth.WithExpectedIssuer(issuer)` to select an issuer authoritatively +instead of by the exclude-Launchpad heuristic; `oauth.WithTimeout` and +`oauth.WithMaxBodyBytes` tune each fetch. + +Notes: + +- `Config.AuthorizationEndpoint` is now `*string` (optional): device-only servers + omit it, so authorization-code consumers must assert its presence before use. + `Config` also carries `DeviceAuthorizationEndpoint *string` and + `GrantTypesSupported []string`. +- Every fetch is SSRF-hardened: origins are validated with `net/url` before any + socket opens, HTTPS is required (localhost exempt), redirects are suppressed, + timeouts are bounded, and bodies are read under a bounded cap. Non-2xx on + either hop surfaces as an `api_error`. + ## Configuration ### Environment Variables diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index 2e79ce08d..ba8791fbb 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -3,13 +3,42 @@ package oauth import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" + "strconv" "strings" + "time" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" +) + +// LaunchpadBaseURL is the default Basecamp/Launchpad OAuth authorization server. +const LaunchpadBaseURL = "https://launchpad.37signals.com" + +// Well-known discovery paths. +const ( + wellKnownAS = "/.well-known/oauth-authorization-server" + wellKnownResource = "/.well-known/oauth-protected-resource" +) + +// Discovery limits. +const ( + // maxDiscoveryBodyBytes bounds a discovery response body (1 MiB); discovery + // documents are tiny. + maxDiscoveryBodyBytes int64 = 1 * 1024 * 1024 + // defaultDiscoveryTimeout bounds each discovery fetch. + defaultDiscoveryTimeout = 10 * time.Second ) // Discoverer fetches OAuth 2.0 server configuration from discovery endpoints. +// +// All fetches are SSRF-hardened: origins are validated with net/url before any +// socket opens, HTTPS is required (localhost exempt), redirects are suppressed, +// timeouts are bounded, and bodies are read under a genuine bounded cap that +// aborts before an oversized body is fully buffered. type Discoverer struct { httpClient *http.Client } @@ -23,34 +52,468 @@ func NewDiscoverer(httpClient *http.Client) *Discoverer { return &Discoverer{httpClient: httpClient} } -// Discover fetches OAuth configuration from the well-known discovery endpoint. -// The baseURL should be the OAuth server's base URL (e.g., "https://launchpad.37signals.com"). -func (d *Discoverer) Discover(ctx context.Context, baseURL string) (*Config, error) { - // Normalize base URL - baseURL = strings.TrimSuffix(baseURL, "/") - discoveryURL := baseURL + "/.well-known/oauth-authorization-server" +// DiscoverOption configures a discovery operation. +type DiscoverOption func(*discoverConfig) + +type discoverConfig struct { + expectedIssuer string + hasExpected bool + timeout time.Duration + maxBodyBytes int64 +} + +// WithExpectedIssuer sets an explicit, authoritative issuer for +// DiscoverFromResource. When provided, the advertised member equal by code-point +// is selected; if none matches, discovery raises ErrExpectedIssuerUnavailable +// (it never falls back). Omit to use the Basecamp-profile exclusion heuristic. +func WithExpectedIssuer(issuer string) DiscoverOption { + return func(c *discoverConfig) { + c.expectedIssuer = issuer + c.hasExpected = true + } +} + +// WithTimeout bounds each discovery fetch. Zero or negative leaves the default (10s). +func WithTimeout(d time.Duration) DiscoverOption { + return func(c *discoverConfig) { c.timeout = d } +} + +// WithMaxBodyBytes caps the discovery response body read. Zero or negative +// leaves the default (1 MiB). +func WithMaxBodyBytes(n int64) DiscoverOption { + return func(c *discoverConfig) { c.maxBodyBytes = n } +} + +func newDiscoverConfig(opts []DiscoverOption) discoverConfig { + c := discoverConfig{timeout: defaultDiscoveryTimeout, maxBodyBytes: maxDiscoveryBodyBytes} + for _, o := range opts { + o(&c) + } + if c.timeout <= 0 { + c.timeout = defaultDiscoveryTimeout + } + if c.maxBodyBytes <= 0 { + c.maxBodyBytes = maxDiscoveryBodyBytes + } + return c +} - req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil) +// requireOriginRoot parses a caller- or metadata-supplied origin and enforces +// the origin-root profile: scheme https (or http on localhost), host present, +// valid/absent port, path empty or exactly "/", and no query/fragment/userinfo. +// It uses net/url (never a regex) so bracketed IPv6 and ports agree with the +// host the client actually dials. +// +// A violation is a usage error — a bad *caller* origin is a usage error; callers +// validating an *advertised* origin reclassify it. The returned origin is +// normalized (scheme://host[:port], default ports and trailing slash dropped). +func requireOriginRoot(raw, label string) (string, error) { + usage := func(msg string) error { + return &basecamp.Error{Code: basecamp.CodeUsage, Message: msg} + } + + u, err := url.Parse(raw) if err != nil { - return nil, fmt.Errorf("creating discovery request: %w", err) + return "", usage(fmt.Sprintf("invalid %s: not a valid absolute URL: %s", label, raw)) + } + + // Scheme profile: https anywhere, or http on localhost. RequireSecureEndpoint + // encodes exactly that (localhost exempt from the HTTPS requirement). + scheme := strings.ToLower(u.Scheme) + if err := basecamp.RequireSecureEndpoint(raw); err != nil { + return "", usage(fmt.Sprintf("%s must use HTTPS (or http on localhost): %s", label, raw)) + } + if u.Hostname() == "" { + return "", usage(fmt.Sprintf("%s has no host: %s", label, raw)) + } + if u.User != nil { + return "", usage(fmt.Sprintf("%s must not contain userinfo: %s", label, raw)) + } + if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return "", usage(fmt.Sprintf("%s must not contain a query or fragment: %s", label, raw)) + } + if u.Path != "" && u.Path != "/" { + return "", usage(fmt.Sprintf("%s must be an origin root (no path): %s", label, raw)) + } + if u.Opaque != "" { + return "", usage(fmt.Sprintf("%s must be an origin root: %s", label, raw)) + } + + // url.Parse rejects a non-numeric port but accepts a numeric-but-out-of-range + // one (e.g. ":99999"), so range-check it explicitly against 1–65535. + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return "", usage(fmt.Sprintf("%s has an invalid port: %s", label, raw)) + } + } + + host := u.Hostname() + origin := scheme + "://" + if strings.Contains(host, ":") { + // IPv6 literal — re-bracket (Hostname strips the brackets). + origin += "[" + host + "]" + } else { + origin += host + } + if port := u.Port(); port != "" && !isDefaultPort(scheme, port) { + origin += ":" + port + } + return origin, nil +} + +func isDefaultPort(scheme, port string) bool { + return (scheme == "https" && port == "443") || (scheme == "http" && port == "80") +} + +// isLaunchpadIssuer reports whether an advertised issuer denotes Launchpad. +func isLaunchpadIssuer(issuer string) bool { + origin, err := requireOriginRoot(issuer, "issuer") + if err != nil { + return false + } + launchpad, err := requireOriginRoot(LaunchpadBaseURL, "issuer") + if err != nil { + return false + } + return origin == launchpad +} + +// noRedirectClient returns a shallow copy of the configured client that +// suppresses redirects. A 3xx then surfaces as a non-2xx api_error rather than +// the client chasing an attacker-influenced Location. +func (d *Discoverer) noRedirectClient() *http.Client { + c := *d.httpClient + c.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + return &c +} + +// errBodyTooLarge is returned (wrapped) by readBoundedBody when a response body +// exceeds the byte cap. It is the package sentinel that lets callers tell an +// oversized body (an api_error, not retryable) apart from a genuine read/I-O +// failure (transport). Discovery and device flow both classify against it via +// errors.Is. +var errBodyTooLarge = errors.New("response body exceeds size cap") + +// readBoundedBody reads at most maxBytes from r, aborting once the cap is +// exceeded so an oversized body is never fully buffered (io.LimitReader reads at +// most maxBytes+1 bytes and we detect the overflow byte). +// +// On overflow it returns errBodyTooLarge (wrapped); on any other failure it +// returns the underlying read error unwrapped, so callers can distinguish the +// two with errors.Is. +func readBoundedBody(r io.Reader, maxBytes int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > maxBytes { + return nil, fmt.Errorf("%w (%d byte cap)", errBodyTooLarge, maxBytes) + } + return data, nil +} + +func truncateBody(body []byte) string { + s := string(body) + if len(s) > maxErrorMessageLen { + return s[:maxErrorMessageLen-3] + "..." + } + return s +} + +// fetchDiscoveryDocument performs an SSRF-hardened GET of a discovery document. +// The origin must already be validated via requireOriginRoot; this re-checks TLS, +// suppresses redirects, bounds the timeout, reads the body under a bounded cap, +// and maps non-2xx to api_error. +func (d *Discoverer) fetchDiscoveryDocument(ctx context.Context, rawURL string, cfg discoverConfig) ([]byte, error) { + if err := basecamp.RequireSecureEndpoint(rawURL); err != nil { + return nil, &basecamp.Error{ + Code: basecamp.CodeUsage, + Message: fmt.Sprintf("OAuth discovery endpoint is not secure: %s", rawURL), + Cause: err, + } + } + + if cfg.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.timeout) + defer cancel() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, &basecamp.Error{ + Code: basecamp.CodeUsage, + Message: fmt.Sprintf("creating OAuth discovery request: %v", err), + Cause: err, + } } req.Header.Set("Accept", "application/json") - resp, err := d.httpClient.Do(req) // #nosec G704 -- SDK HTTP client: URL is caller-configured + resp, err := d.noRedirectClient().Do(req) // #nosec G704 -- SDK HTTP client: URL is origin-root validated if err != nil { - return nil, fmt.Errorf("discovery request failed: %w", err) + return nil, basecamp.ErrNetwork(fmt.Errorf("OAuth discovery request failed: %w", err)) } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("discovery failed with status %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Drain-and-cap defensively; the body is only used for the message. + body, _ := readBoundedBody(resp.Body, cfg.maxBodyBytes) + return nil, basecamp.ErrAPI(resp.StatusCode, + fmt.Sprintf("OAuth discovery failed with status %d: %s", resp.StatusCode, truncateBody(body))) + } + + body, err := readBoundedBody(resp.Body, cfg.maxBodyBytes) + if err != nil { + return nil, &basecamp.Error{ + Code: basecamp.CodeAPI, + Message: fmt.Sprintf("OAuth discovery response too large: %v", err), + Cause: err, + } + } + return body, nil +} + +// Discover fetches OAuth 2.0 Authorization Server Metadata (RFC 8414) from +// {baseURL}/.well-known/oauth-authorization-server and binds it: the returned +// issuer must equal the requested issuer by code-point. token_endpoint is +// required; authorization_endpoint is optional (device-only servers omit it). +// +// The baseURL should be the OAuth server's issuer origin +// (e.g., "https://launchpad.37signals.com"). +func (d *Discoverer) Discover(ctx context.Context, baseURL string, opts ...DiscoverOption) (*Config, error) { + origin, err := requireOriginRoot(baseURL, "OAuth discovery base URL") + if err != nil { + return nil, err + } + return d.fetchASMetadata(ctx, origin, newDiscoverConfig(opts)) +} + +// rawDiscoveryResponse mirrors an RFC 8414 metadata document. Endpoint fields +// are pointers so present-but-empty ("") is distinguishable from absent. +type rawDiscoveryResponse struct { + Issuer *string `json:"issuer"` + AuthorizationEndpoint *string `json:"authorization_endpoint"` + TokenEndpoint *string `json:"token_endpoint"` + DeviceAuthorizationEndpoint *string `json:"device_authorization_endpoint"` + RegistrationEndpoint *string `json:"registration_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +func (d *Discoverer) fetchASMetadata(ctx context.Context, issuerOrigin string, cfg discoverConfig) (*Config, error) { + body, err := d.fetchDiscoveryDocument(ctx, issuerOrigin+wellKnownAS, cfg) + if err != nil { + return nil, err + } + return parseAndBindASMetadata(body, issuerOrigin) +} + +// parseAndBindASMetadata validates AS metadata and binds issuer to +// expectedIssuerOrigin by code-point. Universal validation only: issuer and +// token_endpoint present and non-empty; any present endpoint field non-empty. +// Per-grant endpoint checks are the consumer's responsibility. +func parseAndBindASMetadata(body []byte, expectedIssuerOrigin string) (*Config, error) { + apiErr := func(msg string) error { + return &basecamp.Error{Code: basecamp.CodeAPI, Message: msg} + } + + var raw rawDiscoveryResponse + if err := json.Unmarshal(body, &raw); err != nil { + return nil, &basecamp.Error{Code: basecamp.CodeAPI, Message: "failed to parse OAuth discovery response", Cause: err} + } + + if raw.Issuer == nil || *raw.Issuer == "" { + return nil, apiErr("invalid OAuth discovery response: missing required field (issuer)") + } + // RFC 8414 §3.3/§4: issuer identical by code-point. No normalization. + if *raw.Issuer != expectedIssuerOrigin { + return nil, &basecamp.Error{ + Code: basecamp.CodeAPI, + Message: fmt.Sprintf("OAuth issuer mismatch: metadata issuer %q does not equal %q", *raw.Issuer, expectedIssuerOrigin), + Cause: errIssuerBindingMismatch, + } + } + if raw.TokenEndpoint == nil || *raw.TokenEndpoint == "" { + return nil, apiErr("invalid OAuth discovery response: missing required field (token_endpoint)") + } + if err := rejectEmptyEndpoints(body); err != nil { + return nil, err + } + + cfg := &Config{ + Issuer: *raw.Issuer, + AuthorizationEndpoint: raw.AuthorizationEndpoint, + TokenEndpoint: *raw.TokenEndpoint, + DeviceAuthorizationEndpoint: raw.DeviceAuthorizationEndpoint, + GrantTypesSupported: raw.GrantTypesSupported, + ScopesSupported: raw.ScopesSupported, + CodeChallengeMethodsSupported: raw.CodeChallengeMethodsSupported, } + if raw.RegistrationEndpoint != nil { + cfg.RegistrationEndpoint = *raw.RegistrationEndpoint + } + return cfg, nil +} + +// rejectEmptyEndpoints rejects any present-but-empty "*_endpoint" field, matching +// the reference: a present endpoint must be non-empty. +func rejectEmptyEndpoints(body []byte) error { + var m map[string]json.RawMessage + // The body already parsed as an object upstream; a decode failure here leaves + // m empty and the loop below is a no-op. + _ = json.Unmarshal(body, &m) + for k, v := range m { + if !strings.HasSuffix(k, "_endpoint") { + continue + } + var s string + if json.Unmarshal(v, &s) == nil && s == "" { + return &basecamp.Error{ + Code: basecamp.CodeAPI, + Message: fmt.Sprintf("invalid OAuth discovery response: empty %s", k), + } + } + } + return nil +} - var config Config - if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { - return nil, fmt.Errorf("parsing discovery response: %w", err) +// DiscoverProtectedResource fetches RFC 9728 protected-resource metadata from +// {resourceOrigin}/.well-known/oauth-protected-resource. resource is required and +// must equal the requested origin by code-point. authorization_servers is +// preserved distinctly as absent vs []. +func (d *Discoverer) DiscoverProtectedResource(ctx context.Context, resourceOrigin string, opts ...DiscoverOption) (*ProtectedResourceMetadata, error) { + origin, err := requireOriginRoot(resourceOrigin, "resource origin") + if err != nil { + return nil, err } + return d.fetchProtectedResource(ctx, origin, newDiscoverConfig(opts)) +} + +func (d *Discoverer) fetchProtectedResource(ctx context.Context, origin string, cfg discoverConfig) (*ProtectedResourceMetadata, error) { + body, err := d.fetchDiscoveryDocument(ctx, origin+wellKnownResource, cfg) + if err != nil { + return nil, err + } + + var raw struct { + Resource *string `json:"resource"` + AuthorizationServers *[]string `json:"authorization_servers"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, &basecamp.Error{Code: basecamp.CodeAPI, Message: "failed to parse resource metadata response", Cause: err} + } + if raw.Resource == nil || *raw.Resource == "" { + return nil, &basecamp.Error{Code: basecamp.CodeAPI, Message: "invalid resource metadata: missing required field (resource)"} + } + // Bind resource identifier to the requested origin, code-point exact. + if *raw.Resource != origin { + return nil, &basecamp.Error{ + Code: basecamp.CodeAPI, + Message: fmt.Sprintf("resource identifier mismatch: metadata resource %q does not equal %q", *raw.Resource, origin), + } + } + + return &ProtectedResourceMetadata{ + Resource: *raw.Resource, + AuthorizationServers: raw.AuthorizationServers, + }, nil +} + +// DiscoverFromResource is the resource-first discovery orchestrator (SPEC.md +// §16). It composes RFC 9728 + RFC 8414 and applies the stage-sensitive fallback +// state machine. +// +// It returns a DiscoveryResult that is either selected (Config set) or a soft +// fallback (FallbackReason set, one of FallbackResourceDiscoveryFailed or +// FallbackNoASAdvertised). Every hard failure is returned as a *SelectionError +// wrapping a sentinel — callers MUST NOT convert an error into a Launchpad +// request. A malformed caller origin is a usage error and propagates as-is. +func (d *Discoverer) DiscoverFromResource(ctx context.Context, resourceOrigin string, opts ...DiscoverOption) (*DiscoveryResult, error) { + cfg := newDiscoverConfig(opts) + + // Origin-root validation of the caller's input is a usage error. + origin, err := requireOriginRoot(resourceOrigin, "resource origin") + if err != nil { + return nil, err + } + + // --- Hop 1: resource metadata. Failure here is soft (before selection). --- + resource, err := d.fetchProtectedResource(ctx, origin, cfg) + if err != nil { + var be *basecamp.Error + if errors.As(err, &be) && be.Code == basecamp.CodeUsage { + return nil, err + } + return &DiscoveryResult{FallbackReason: FallbackResourceDiscoveryFailed}, nil + } + + var advertised []string + if resource.AuthorizationServers != nil { + advertised = *resource.AuthorizationServers + } + + // --- Selection --- + var selectedIssuer string + if cfg.hasExpected { + selectedIssuer = findAdvertised(advertised, cfg.expectedIssuer) + if selectedIssuer == "" { + // api_error (not validation) to match the other four SDKs: an issuer the + // resource does not advertise is a metadata fault, not a caller-usage one. + return nil, newSelectionError(ErrExpectedIssuerUnavailable, + fmt.Sprintf("expected issuer %q is not advertised by the resource", cfg.expectedIssuer), nil) + } + } else { + nonLaunchpad := make([]string, 0, len(advertised)) + for _, s := range advertised { + if !isLaunchpadIssuer(s) { + nonLaunchpad = append(nonLaunchpad, s) + } + } + switch { + case len(nonLaunchpad) >= 2: + return nil, newSelectionError(ErrAmbiguousIssuers, + fmt.Sprintf("multiple non-Launchpad issuers advertised; pass an expected issuer to disambiguate: %s", strings.Join(nonLaunchpad, ", ")), nil) + case len(nonLaunchpad) == 0: + // Valid resource metadata omits BC5 — soft fallback (before selection). + return &DiscoveryResult{FallbackReason: FallbackNoASAdvertised}, nil + default: + selectedIssuer = nonLaunchpad[0] + } + } + + // --- BC5 is now committed: every subsequent failure is fatal (no Launchpad). --- + issuerOrigin, err := requireOriginRoot(selectedIssuer, "advertised issuer") + if err != nil { + return nil, newSelectionError(ErrInvalidIssuerOrigin, + fmt.Sprintf("advertised issuer %q is not a valid origin root", selectedIssuer), err) + } + + config, err := d.fetchASMetadata(ctx, issuerOrigin, cfg) + if err != nil { + if errors.Is(err, errIssuerBindingMismatch) { + return nil, newSelectionError(ErrIssuerMismatch, err.Error(), err) + } + return nil, newSelectionError(ErrASFetchFailed, + fmt.Sprintf("authorization server metadata fetch failed for committed issuer %q: %v", issuerOrigin, err), err) + } + + return &DiscoveryResult{Config: config, Issuer: config.Issuer}, nil +} + +func findAdvertised(advertised []string, want string) string { + for _, s := range advertised { + if s == want { + return s + } + } + return "" +} - return &config, nil +// DiscoverLaunchpad fetches OAuth configuration from Basecamp's Launchpad server. +func (d *Discoverer) DiscoverLaunchpad(ctx context.Context, opts ...DiscoverOption) (*Config, error) { + return d.Discover(ctx, LaunchpadBaseURL, opts...) } diff --git a/go/pkg/basecamp/oauth/oauth_test.go b/go/pkg/basecamp/oauth/oauth_test.go index 402fd18d3..337087652 100644 --- a/go/pkg/basecamp/oauth/oauth_test.go +++ b/go/pkg/basecamp/oauth/oauth_test.go @@ -14,29 +14,25 @@ import ( func TestDiscoverer_Discover(t *testing.T) { tests := []struct { name string - response any statusCode int + // bindIssuer serves metadata whose issuer equals the server origin so + // the RFC 8414 issuer binding passes. + bindIssuer bool wantErr bool }{ { - name: "successful discovery", - response: Config{ - Issuer: "https://example.com", - AuthorizationEndpoint: "https://example.com/authorize", - TokenEndpoint: "https://example.com/token", - }, + name: "successful discovery", statusCode: http.StatusOK, + bindIssuer: true, wantErr: false, }, { name: "server error", - response: "Internal Server Error", statusCode: http.StatusInternalServerError, wantErr: true, }, { name: "not found", - response: "Not Found", statusCode: http.StatusNotFound, wantErr: true, }, @@ -44,18 +40,21 @@ func TestDiscoverer_Discover(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + var origin string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/.well-known/oauth-authorization-server" { t.Errorf("unexpected path: %s", r.URL.Path) } w.WriteHeader(tt.statusCode) - if tt.statusCode == http.StatusOK { - _ = json.NewEncoder(w).Encode(tt.response) + if tt.bindIssuer { + _, _ = fmt.Fprintf(w, `{"issuer":%q,"authorization_endpoint":%q,"token_endpoint":%q}`, + origin, origin+"/authorize", origin+"/token") } else { - _, _ = w.Write([]byte(tt.response.(string))) + _, _ = w.Write([]byte("error body")) } })) defer server.Close() + origin = server.URL d := NewDiscoverer(server.Client()) cfg, err := d.Discover(context.Background(), server.URL) @@ -64,31 +63,39 @@ func TestDiscoverer_Discover(t *testing.T) { t.Errorf("Discover() error = %v, wantErr %v", err, tt.wantErr) return } - - if !tt.wantErr && cfg == nil { - t.Error("Discover() returned nil config") + if !tt.wantErr { + if cfg == nil { + t.Fatal("Discover() returned nil config") + } + if cfg.Issuer != origin { + t.Errorf("Discover() issuer = %q, want %q", cfg.Issuer, origin) + } } }) } } -func TestDiscoverer_Discover_URLNormalization(t *testing.T) { +// TestDiscoverer_Discover_TrailingSlash verifies the origin-root profile accepts +// a trailing "/" (path exactly "/") and still binds the issuer to the origin. +func TestDiscoverer_Discover_TrailingSlash(t *testing.T) { + var origin string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/.well-known/oauth-authorization-server" { t.Errorf("unexpected path: %s", r.URL.Path) } - _ = json.NewEncoder(w).Encode(Config{ - TokenEndpoint: "https://example.com/token", - }) + _, _ = fmt.Fprintf(w, `{"issuer":%q,"token_endpoint":%q}`, origin, origin+"/token") })) defer server.Close() + origin = server.URL d := NewDiscoverer(server.Client()) - // Test with trailing slash - _, err := d.Discover(context.Background(), server.URL+"/") + cfg, err := d.Discover(context.Background(), server.URL+"/") if err != nil { - t.Errorf("Discover() with trailing slash failed: %v", err) + t.Fatalf("Discover() with trailing slash failed: %v", err) + } + if cfg.Issuer != origin { + t.Errorf("Discover() issuer = %q, want %q", cfg.Issuer, origin) } } diff --git a/go/pkg/basecamp/oauth/resource_discovery_test.go b/go/pkg/basecamp/oauth/resource_discovery_test.go new file mode 100644 index 000000000..907059e67 --- /dev/null +++ b/go/pkg/basecamp/oauth/resource_discovery_test.go @@ -0,0 +1,557 @@ +package oauth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "testing" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" +) + +// These tests drive the shared, data-only fixtures in +// conformance/oauth/fixtures with this harness's mock origins substituted for +// the {{...}} placeholders, so issuer / resource binding stays code-point-exact +// against the mocked hosts. See conformance/oauth/README.md. + +type fixtureHop struct { + Origin string `json:"origin"` + Status int `json:"status"` + TransportError bool `json:"transportError"` + Body json.RawMessage `json:"body"` + Oversized bool `json:"oversized"` + RedirectTo string `json:"redirectTo"` +} + +type fixtureExpect struct { + Outcome string `json:"outcome"` + SelectedIssuer string `json:"selectedIssuer"` + FallbackReason string `json:"fallbackReason"` + Error string `json:"error"` + ErrorCategory string `json:"errorCategory"` + LaunchpadContacted *bool `json:"launchpadContacted"` +} + +type fixture struct { + Name string `json:"name"` + Operation string `json:"operation"` + ResourceOrigin string `json:"resourceOrigin"` + IssuerOrigin string `json:"issuerOrigin"` + ExpectedIssuer string `json:"expectedIssuer"` + Hop1 *fixtureHop `json:"hop1"` + Hop2 *fixtureHop `json:"hop2"` + Expect fixtureExpect `json:"expect"` +} + +func fixtureDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + // .../go/pkg/basecamp/oauth/ → repo root is four levels up. + root := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..") + return filepath.Join(root, "conformance", "oauth", "fixtures") +} + +// hopResponder serves a single configured mock exchange for a role's server. +type hopResponder struct { + mu sync.Mutex + hop *fixtureHop +} + +func (h *hopResponder) set(hop *fixtureHop) { + h.mu.Lock() + h.hop = hop + h.mu.Unlock() +} + +func (h *hopResponder) ServeHTTP(w http.ResponseWriter, _ *http.Request) { + h.mu.Lock() + hop := h.hop + h.mu.Unlock() + + if hop == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if hop.Oversized { + // A body far larger than any test cap; the bounded read must abort + // before buffering it all. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(bytes.Repeat([]byte("x"), 256*1024)) + return + } + if hop.RedirectTo != "" { + status := hop.Status + if status == 0 { + status = http.StatusFound + } + w.Header().Set("Location", hop.RedirectTo) + w.WriteHeader(status) + return + } + status := hop.Status + if status == 0 { + status = http.StatusOK + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if hop.Body != nil { + _, _ = w.Write(hop.Body) + } +} + +// countingTransport counts requests to the real Launchpad host and answers them +// with canned metadata (so a genuine fallback would succeed), while forwarding +// everything else to the base transport. Hard cases must never reach Launchpad. +type countingTransport struct { + base http.RoundTripper + count *int +} + +func (t *countingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.EqualFold(req.URL.Hostname(), "launchpad.37signals.com") { + *t.count++ + body := `{"issuer":"https://launchpad.37signals.com","authorization_endpoint":"https://launchpad.37signals.com/authorization/new","token_endpoint":"https://launchpad.37signals.com/authorization/token"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: req, + }, nil + } + return t.base.RoundTrip(req) +} + +func sentinelFor(name string) error { + switch name { + case "ambiguous_issuers": + return ErrAmbiguousIssuers + case "expected_issuer_unavailable": + return ErrExpectedIssuerUnavailable + case "invalid_issuer_origin": + return ErrInvalidIssuerOrigin + case "as_fetch_failed": + return ErrASFetchFailed + case "issuer_mismatch": + return ErrIssuerMismatch + case "capability_unavailable": + return ErrCapabilityUnavailable + } + return nil +} + +func TestResourceFirstDiscoveryFixtures(t *testing.T) { + dir := fixtureDir(t) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("reading fixture dir %s: %v", dir, err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".json") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + if len(names) == 0 { + t.Fatalf("no fixtures found in %s", dir) + } + + for _, name := range names { + t.Run(name, func(t *testing.T) { + runFixture(t, filepath.Join(dir, name)) + }) + } +} + +func runFixture(t *testing.T, path string) { + t.Helper() + + rawBytes, err := os.ReadFile(path) // #nosec G304 -- test fixture path + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + + // Role servers. Created up front so their URLs are known before placeholder + // substitution; unreferenced servers are simply never contacted. + resourceResp := &hopResponder{} + bc5Resp := &hopResponder{} + issuerResp := &hopResponder{} + resourceSrv := httptest.NewServer(resourceResp) + defer resourceSrv.Close() + bc5Srv := httptest.NewServer(bc5Resp) + defer bc5Srv.Close() + issuerSrv := httptest.NewServer(issuerResp) + defer issuerSrv.Close() + + replacements := map[string]string{ + "{{RESOURCE_ORIGIN}}": resourceSrv.URL, + "{{BC5_ISSUER}}": bc5Srv.URL, + "{{ISSUER_ORIGIN}}": issuerSrv.URL, + "{{LAUNCHPAD_ORIGIN}}": LaunchpadBaseURL, + } + subbed := string(rawBytes) + for ph, origin := range replacements { + subbed = strings.ReplaceAll(subbed, ph, origin) + } + + var fx fixture + if err := json.Unmarshal([]byte(subbed), &fx); err != nil { + t.Fatalf("unmarshaling fixture %s: %v", path, err) + } + + // Bracketed IPv6 origins can't be served by httptest (it listens on + // 127.0.0.1), so the IPv6 origin-root accept case is verified at the parser + // boundary — the point of the fixture is that the transport parser accepts it + // where a regex would fail. + if strings.Contains(fx.ResourceOrigin, "[") && fx.Expect.Outcome == "selected" { + got, err := requireOriginRoot(fx.ResourceOrigin, "resource origin") + if err != nil { + t.Fatalf("requireOriginRoot(%q) unexpected error: %v", fx.ResourceOrigin, err) + } + if got != fx.Expect.SelectedIssuer { + t.Errorf("requireOriginRoot(%q) = %q, want %q", fx.ResourceOrigin, got, fx.Expect.SelectedIssuer) + } + return + } + + // Wire up mock responses. + oversized := false + if fx.Hop1 != nil { + resourceResp.set(fx.Hop1) + oversized = oversized || fx.Hop1.Oversized + } + if fx.Hop2 != nil { + switch fx.Hop2.Origin { + case bc5Srv.URL: + bc5Resp.set(fx.Hop2) + case issuerSrv.URL: + issuerResp.set(fx.Hop2) + default: + t.Fatalf("hop2 origin %q matches no role server", fx.Hop2.Origin) + } + oversized = oversized || fx.Hop2.Oversized + } + // A transport-level failure: close the resource server so the connection is + // refused. + if fx.Hop1 != nil && fx.Hop1.TransportError { + resourceSrv.Close() + } + + launchpadHits := 0 + client := &http.Client{Transport: &countingTransport{base: http.DefaultTransport, count: &launchpadHits}} + d := NewDiscoverer(client) + + opts := []DiscoverOption{} + if fx.ExpectedIssuer != "" { + opts = append(opts, WithExpectedIssuer(fx.ExpectedIssuer)) + } + if oversized { + opts = append(opts, WithMaxBodyBytes(8*1024)) + } + + ctx := context.Background() + var runErr error + var result *DiscoveryResult + switch fx.Operation { + case "discoverFromResource": + result, runErr = d.DiscoverFromResource(ctx, fx.ResourceOrigin, opts...) + case "discoverProtectedResource": + _, runErr = d.DiscoverProtectedResource(ctx, fx.ResourceOrigin, opts...) + case "discover": + _, runErr = d.Discover(ctx, fx.IssuerOrigin, opts...) + default: + t.Fatalf("unknown operation %q", fx.Operation) + } + + switch fx.Expect.Outcome { + case "raise": + if runErr == nil { + t.Fatalf("expected an error, got nil") + } + assertRaise(t, fx, runErr) + case "fallback": + if runErr != nil { + t.Fatalf("expected fallback, got error: %v", runErr) + } + if result == nil || !result.IsFallback() { + t.Fatalf("expected fallback result, got %+v", result) + } + if string(result.FallbackReason) != fx.Expect.FallbackReason { + t.Errorf("fallback reason = %q, want %q", result.FallbackReason, fx.Expect.FallbackReason) + } + // A soft fallback hands off to the caller, whose next step is Launchpad + // (oauth.LaunchpadBaseURL). Drive that hand-off so a launchpadContacted:true + // fixture asserts the fallback path actually reaches Launchpad — the + // counting transport serves canned metadata, so a genuine fallback succeeds. + if fx.Expect.LaunchpadContacted != nil && *fx.Expect.LaunchpadContacted { + if _, err := d.DiscoverLaunchpad(ctx); err != nil { + t.Fatalf("Launchpad fallback discovery failed: %v", err) + } + if launchpadHits == 0 { + t.Errorf("expected Launchpad to be contacted on fallback, but it was not") + } + } + case "selected": + if runErr != nil { + t.Fatalf("expected selected, got error: %v", runErr) + } + if fx.Operation == "discoverFromResource" { + if result == nil || result.IsFallback() || result.Config == nil { + t.Fatalf("expected selected config, got %+v", result) + } + if fx.Expect.SelectedIssuer != "" && result.Issuer != fx.Expect.SelectedIssuer { + t.Errorf("selected issuer = %q, want %q", result.Issuer, fx.Expect.SelectedIssuer) + } + } + // discover / discoverProtectedResource: absence of an error is success. + default: + t.Fatalf("unknown expected outcome %q", fx.Expect.Outcome) + } + + if fx.Expect.LaunchpadContacted != nil && !*fx.Expect.LaunchpadContacted { + if launchpadHits != 0 { + t.Errorf("Launchpad was contacted %d time(s); hard/selected case must not touch Launchpad", launchpadHits) + } + } +} + +func TestRequireOriginRoot(t *testing.T) { + accept := []struct{ in, want string }{ + {"https://api.example.com", "https://api.example.com"}, + {"https://api.example.com/", "https://api.example.com"}, + {"https://api.example.com:8443", "https://api.example.com:8443"}, + {"https://api.example.com:443", "https://api.example.com"}, // default port dropped + {"https://h:443", "https://h"}, // in-range port accepted + {"http://localhost:3000", "http://localhost:3000"}, + {"http://[::1]:3000", "http://[::1]:3000"}, + {"http://127.0.0.1:9999", "http://127.0.0.1:9999"}, + } + for _, tc := range accept { + got, err := requireOriginRoot(tc.in, "origin") + if err != nil { + t.Errorf("requireOriginRoot(%q) error = %v, want nil", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("requireOriginRoot(%q) = %q, want %q", tc.in, got, tc.want) + } + } + + reject := []string{ + "http://api.example.com", // plain http, non-localhost + "https://api.example.com:notaport", // malformed port + "https://h:99999", // numeric but out-of-range port + "https://h:0", // port below the valid range + "http://[::1]:notaport", // malformed IPv6 port + "https://api.example.com/tenant/1", // path beyond "/" + "https://api.example.com?x=1", // query + "https://api.example.com#frag", // fragment + "https://user:pass@api.example.com", // userinfo + "ftp://api.example.com", // non-http(s) scheme + "not a url", // unparseable + } + for _, in := range reject { + if _, err := requireOriginRoot(in, "origin"); err == nil { + t.Errorf("requireOriginRoot(%q) = nil error, want usage error", in) + } else { + var be *basecamp.Error + if !errors.As(err, &be) || be.Code != basecamp.CodeUsage { + t.Errorf("requireOriginRoot(%q) error = %v, want usage", in, err) + } + } + } +} + +func TestDiscover_DeviceOnlyAS(t *testing.T) { + var origin string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"issuer":"` + origin + `","token_endpoint":"` + origin + `/oauth/token",` + + `"device_authorization_endpoint":"` + origin + `/oauth/device",` + + `"grant_types_supported":["urn:ietf:params:oauth:grant-type:device_code","refresh_token"]}`)) + })) + defer srv.Close() + origin = srv.URL + + cfg, err := NewDiscoverer(srv.Client()).Discover(context.Background(), origin) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if cfg.AuthorizationEndpoint != nil { + t.Errorf("AuthorizationEndpoint = %v, want nil (device-only AS omits it)", *cfg.AuthorizationEndpoint) + } + if cfg.DeviceAuthorizationEndpoint == nil || *cfg.DeviceAuthorizationEndpoint != origin+"/oauth/device" { + t.Errorf("DeviceAuthorizationEndpoint = %v, want %q", cfg.DeviceAuthorizationEndpoint, origin+"/oauth/device") + } + found := false + for _, g := range cfg.GrantTypesSupported { + if g == "urn:ietf:params:oauth:grant-type:device_code" { + found = true + } + } + if !found { + t.Errorf("GrantTypesSupported = %v, want device_code", cfg.GrantTypesSupported) + } +} + +func codeForCategory(category string) string { + switch category { + case "usage": + return basecamp.CodeUsage + case "validation": + return basecamp.CodeValidation + case "api_error": + return basecamp.CodeAPI + case "network": + return basecamp.CodeNetwork + case "auth_required": + return basecamp.CodeAuth + default: + return category + } +} + +func assertRaise(t *testing.T, fx fixture, err error) { + t.Helper() + + // Cross-SDK coarse-category assertion: the thrown error must map to the + // fixture's errorCategory via the shared basecamp.Error taxonomy. + if fx.Expect.ErrorCategory != "" { + var bce *basecamp.Error + if !errors.As(err, &bce) { + t.Fatalf("no basecamp.Error in chain for %v (%T)", err, err) + } + if want := codeForCategory(fx.Expect.ErrorCategory); bce.Code != want { + t.Errorf("error category = %q, want %q (%v)", bce.Code, want, err) + } + } + + var be *basecamp.Error + if fx.Expect.Error == "usage" { + if !errors.As(err, &be) || be.Code != basecamp.CodeUsage { + t.Errorf("expected usage error, got %v (%T)", err, err) + } + return + } + + if fx.Operation == "discoverFromResource" { + var se *SelectionError + if !errors.As(err, &se) { + t.Fatalf("expected *SelectionError, got %v (%T)", err, err) + } + want := sentinelFor(fx.Expect.Error) + if want == nil { + t.Fatalf("no sentinel mapped for error %q", fx.Expect.Error) + } + if !errors.Is(err, want) { + t.Errorf("error %v is not %v", err, want) + } + return + } + + // discover / discoverProtectedResource hard failures are api_error + // (invalid_metadata and api_error both map to the api_error code). + if !errors.As(err, &be) || be.Code != basecamp.CodeAPI { + t.Errorf("expected api_error, got %v (%T)", err, err) + } +} + +// TestSelectionError_PreservesCauseStatus guards finding B: a SelectionError +// wrapping an AS 5xx (or a network cause) must expose the cause's HTTP status and +// retryability to an errors.As(&basecamp.Error) consumer, not a stripped +// {Code, Message} view — while keeping the taxonomy Code and errors.Is sentinel +// matching intact. +func TestSelectionError_PreservesCauseStatus(t *testing.T) { + cause := &basecamp.Error{Code: basecamp.CodeAPI, Message: "boom", HTTPStatus: 503, Retryable: true, RequestID: "req-1"} + se := newSelectionError(ErrASFetchFailed, "authorization server metadata fetch failed", cause) + + var be *basecamp.Error + if !errors.As(se, &be) { + t.Fatal("expected a *basecamp.Error in the chain") + } + if be.Code != basecamp.CodeAPI { + t.Errorf("Code = %q, want %q", be.Code, basecamp.CodeAPI) + } + if be.HTTPStatus != 503 { + t.Errorf("HTTPStatus = %d, want 503 (cause status must not be stripped)", be.HTTPStatus) + } + if !be.Retryable { + t.Error("Retryable = false, want true (cause retryability must not be stripped)") + } + if be.RequestID != "req-1" { + t.Errorf("RequestID = %q, want %q", be.RequestID, "req-1") + } + if !errors.Is(se, ErrASFetchFailed) { + t.Error("errors.Is(se, ErrASFetchFailed) = false, want true") + } +} + +// TestSelectionError_NoCauseStillCoded confirms that when there is no cause +// carrying a *basecamp.Error (e.g. an ambiguous-issuer selection), the +// taxonomy-coded view is still present with the right Code and a zero status. +func TestSelectionError_NoCauseStillCoded(t *testing.T) { + se := newSelectionError(ErrAmbiguousIssuers, "multiple non-Launchpad issuers advertised", nil) + + var be *basecamp.Error + if !errors.As(se, &be) { + t.Fatal("expected a *basecamp.Error in the chain") + } + if be.Code != basecamp.CodeAPI { + t.Errorf("Code = %q, want %q", be.Code, basecamp.CodeAPI) + } + if be.HTTPStatus != 0 { + t.Errorf("HTTPStatus = %d, want 0", be.HTTPStatus) + } + if !errors.Is(se, ErrAmbiguousIssuers) { + t.Error("errors.Is(se, ErrAmbiguousIssuers) = false, want true") + } +} + +// TestDiscoverFromResource_ASFetchFailurePreservesStatus is the end-to-end form +// of finding B: a committed BC5 issuer whose AS metadata hop returns 503 must +// surface that status through DiscoverFromResource's *SelectionError. +func TestDiscoverFromResource_ASFetchFailurePreservesStatus(t *testing.T) { + bc5 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":"unavailable"}`, http.StatusServiceUnavailable) + })) + defer bc5.Close() + + var resourceOrigin string + resource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"resource":"` + resourceOrigin + `","authorization_servers":["` + bc5.URL + `"]}`)) + })) + defer resource.Close() + resourceOrigin = resource.URL + + _, err := NewDiscoverer(resource.Client()).DiscoverFromResource(context.Background(), resourceOrigin) + if err == nil { + t.Fatal("expected an error") + } + if !errors.Is(err, ErrASFetchFailed) { + t.Errorf("errors.Is(err, ErrASFetchFailed) = false; err = %v", err) + } + var be *basecamp.Error + if !errors.As(err, &be) { + t.Fatalf("expected *basecamp.Error in chain, got %T", err) + } + if be.HTTPStatus != http.StatusServiceUnavailable { + t.Errorf("HTTPStatus = %d, want %d", be.HTTPStatus, http.StatusServiceUnavailable) + } + if be.Code != basecamp.CodeAPI { + t.Errorf("Code = %q, want api_error", be.Code) + } +} diff --git a/go/pkg/basecamp/oauth/types.go b/go/pkg/basecamp/oauth/types.go index 628cbff7d..7747f495e 100644 --- a/go/pkg/basecamp/oauth/types.go +++ b/go/pkg/basecamp/oauth/types.go @@ -1,15 +1,188 @@ // Package oauth provides OAuth 2.0 discovery, token exchange, and refresh functionality. package oauth -import "time" +import ( + "errors" + "time" -// Config represents an OAuth 2.0 server configuration from discovery. + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" +) + +// Config represents an OAuth 2.0 authorization server configuration (RFC 8414). type Config struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - RegistrationEndpoint string `json:"registration_endpoint,omitempty"` - ScopesSupported []string `json:"scopes_supported,omitempty"` + // Issuer is the authorization server's issuer identifier. It equals the URL + // the metadata was retrieved from, code-point exact (RFC 8414 §3.3/§4). + Issuer string `json:"issuer"` + + // AuthorizationEndpoint is the URL of the authorization endpoint. + // + // Optional as of BC5 resource-first discovery: device-only authorization + // servers omit it, so absent (nil) and present-empty are preserved + // distinctly. Authorization-code consumers MUST assert its presence before + // use. + AuthorizationEndpoint *string `json:"authorization_endpoint,omitempty"` + + // TokenEndpoint is the URL of the token endpoint (required). + TokenEndpoint string `json:"token_endpoint"` + + // DeviceAuthorizationEndpoint is the URL of the RFC 8628 device + // authorization endpoint (optional; nil when absent). + DeviceAuthorizationEndpoint *string `json:"device_authorization_endpoint,omitempty"` + + // RegistrationEndpoint is the URL of the dynamic client registration + // endpoint (optional). + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + + // GrantTypesSupported lists the OAuth 2.0 grant types the server supports. + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + // ScopesSupported lists the OAuth 2.0 scopes the server supports. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // CodeChallengeMethodsSupported lists the PKCE code challenge methods the + // server supports. + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +// ProtectedResourceMetadata is RFC 9728 protected-resource metadata (hop 1 of +// resource-first discovery). +type ProtectedResourceMetadata struct { + // Resource is the resource identifier; it equals the requested resource + // origin, code-point exact. + Resource string `json:"resource"` + + // AuthorizationServers lists the authorization servers advertised for this + // resource. + // + // It is a pointer slice so absent (nil) and present-but-empty (non-nil, + // len 0) stay distinct: BC5 omits the key while dark (RFC 9728 §3.2). Both + // nonetheless select Launchpad, but the distinction is meaningful to callers + // inspecting metadata. + AuthorizationServers *[]string `json:"authorization_servers,omitempty"` +} + +// FallbackReason is a soft resource-first fallback outcome — the ONLY two +// outcomes under which DiscoverFromResource yields a fallback (Launchpad) rather +// than a selected config. Every other failure is a hard, sentinel-wrapped error. +type FallbackReason string + +const ( + // FallbackResourceDiscoveryFailed indicates hop-1 resource metadata could + // not be fetched, parsed, or bound before any BC5 issuer was committed. + FallbackResourceDiscoveryFailed FallbackReason = "resource_discovery_failed" + // FallbackNoASAdvertised indicates valid resource metadata advertised no + // non-Launchpad issuer (absent / empty / only-Launchpad). + FallbackNoASAdvertised FallbackReason = "no_as_advertised" +) + +// DiscoveryResult is the outcome of DiscoverFromResource: either a selected +// authorization-server config, or a soft fallback to Launchpad. Hard failures +// are returned as sentinel-wrapped errors, never represented here. +type DiscoveryResult struct { + // Config is the selected authorization-server config; nil on fallback. + Config *Config + // Issuer is the selected issuer identifier; empty on fallback. + Issuer string + // FallbackReason is non-empty when discovery fell back to Launchpad. + FallbackReason FallbackReason +} + +// IsFallback reports whether the result is a soft fallback to Launchpad rather +// than a selected authorization-server config. +func (r *DiscoveryResult) IsFallback() bool { + return r.FallbackReason != "" +} + +// Sentinel errors for the hard resource-first selection/validation failures. +// These are returned wrapped in a *SelectionError; match them with errors.Is. +// A hard failure MUST NOT be converted by any consumer into a Launchpad request. +var ( + // ErrAmbiguousIssuers is returned when two or more non-Launchpad issuers are + // advertised and no expected issuer was provided to disambiguate. + ErrAmbiguousIssuers = errors.New("ambiguous issuers advertised") + // ErrExpectedIssuerUnavailable is returned when an expected issuer was + // provided but is not advertised by the resource. + ErrExpectedIssuerUnavailable = errors.New("expected issuer not advertised") + // ErrInvalidIssuerOrigin is returned when a selected advertised issuer is + // not a valid origin root. + ErrInvalidIssuerOrigin = errors.New("advertised issuer is not a valid origin root") + // ErrASFetchFailed is returned when the authorization-server metadata fetch + // fails (5xx / network) for a committed BC5 issuer. + ErrASFetchFailed = errors.New("authorization server metadata fetch failed") + // ErrIssuerMismatch is returned when a committed BC5 issuer's metadata does + // not bind to the advertised issuer (code-point mismatch). + ErrIssuerMismatch = errors.New("issuer binding mismatch") + // ErrCapabilityUnavailable is returned when a committed BC5 issuer lacks a + // per-grant endpoint/capability the consumer requires. + ErrCapabilityUnavailable = errors.New("required capability unavailable") +) + +// errIssuerBindingMismatch is the internal marker attached to an AS-metadata +// binding failure so DiscoverFromResource can distinguish an issuer mismatch +// from a generic fetch failure without matching on message text. +var errIssuerBindingMismatch = errors.New("oauth: issuer binding mismatch") + +// SelectionError is a hard resource-first selection/validation failure. It wraps +// one of the sentinel errors above (match with errors.Is) and carries a SDK +// error code so it maps to the standard error taxonomy (errors.As a +// *basecamp.Error). It is returned — never yielded as a fallback — so no +// consumer can convert it into a Launchpad request. +type SelectionError struct { + // Reason is the sentinel error identifying the failure class. + Reason error + // Code is the SDK error taxonomy code, derived from Reason: every hard + // discovery failure is basecamp.CodeAPI except ErrCapabilityUnavailable + // (consumer-asserted), which is basecamp.CodeValidation — matching the + // other four SDKs. + Code string + // Message is the human-readable description. + Message string + // Cause is the underlying error, if any. + Cause error +} + +// Error implements the error interface. +func (e *SelectionError) Error() string { + return e.Message +} + +// Unwrap exposes the sentinel reason, a taxonomy-coded *basecamp.Error, and the +// underlying cause for errors.Is / errors.As traversal. +// +// The taxonomy-coded view keeps e.Code (the SDK's classification of the failure) +// but inherits the cause's HTTP status and retryability when the cause carries a +// *basecamp.Error — e.g. an AS 5xx fetch (ErrASFetchFailed) or a network failure +// on the committed-issuer hop. Without this, an errors.As(&basecamp.Error) +// consumer would match a stripped {Code, Message} that hid the underlying status +// and retryable flag. The sentinel reason stays first so errors.Is keeps working. +func (e *SelectionError) Unwrap() []error { + coded := &basecamp.Error{Code: e.Code, Message: e.Message} + var cause *basecamp.Error + if errors.As(e.Cause, &cause) { + coded.HTTPStatus = cause.HTTPStatus + coded.Retryable = cause.Retryable + coded.RequestID = cause.RequestID + } + + errs := make([]error, 0, 3) + errs = append(errs, e.Reason, coded) + if e.Cause != nil { + errs = append(errs, e.Cause) + } + return errs +} + +// selectionErrorCode derives the taxonomy code from the sentinel reason. All hard +// discovery failures are api_error except capability_unavailable (validation). +func selectionErrorCode(reason error) string { + if errors.Is(reason, ErrCapabilityUnavailable) { + return basecamp.CodeValidation + } + return basecamp.CodeAPI +} + +func newSelectionError(reason error, message string, cause error) *SelectionError { + return &SelectionError{Reason: reason, Code: selectionErrorCode(reason), Message: message, Cause: cause} } // Token represents an OAuth 2.0 access token response. From 1d7db433721f0df21d13ef0e7de610f32019f4fa Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 14 Jul 2026 17:13:28 -0700 Subject: [PATCH 05/38] Ruby: resource-first OAuth discovery + issuer binding + SSRF + empty-string fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Ask #1 for the Ruby SDK (SPEC.md §16), mirroring the TS reference: - Oauth.discover_protected_resource (RFC 9728, new resource.rb) + Oauth.discover_from_resource orchestrator (discovery_result.rb) alongside Oauth.discover (RFC 8414), now with code-point issuer binding and an origin-root profile via URI (Security.require_origin_root!). - Fixes the empty-string endpoint bug ("" is truthy in Ruby): present-but-empty endpoints are now rejected. authorization_endpoint kw defaults to nil (optional); added device_authorization_endpoint / grant_types_supported. Non-2xx → api_error (was network). - SSRF (P0): new fetcher.rb hardens the Faraday connection at construction — no redirect middleware, HTTPS enforced pre-fetch, and a streaming on_data read that raises BodyTooLarge the instant the cap is exceeded (bounded memory, not a post-hoc check). Proven at runtime against a real injected connection. - Stage-sensitive fallback: DiscoverySelectionError (carries reason) for hard cases; DiscoveryResult selected?/fallback? for the two soft reasons. - Generated-file relocation (P0): git mv generated/services/authorization_service.rb → services/authorization_service.rb (no loader.collapse; Zeitwerk still resolves Basecamp::Services::AuthorizationService; zeitwerk_test green). - Fatal-after-selection (P0): AuthorizationService#discover_authorization_url no longer blanket-rescues — it branches on the two soft reasons → Launchpad and lets hard errors propagate. authorization_service_test proves AS-500 / issuer-mismatch after BC5 advertisement raise with zero Launchpad requests. - New oauth_resource_discovery_test.rb (drives the shared fixtures) + oauth_ssrf_test.rb. README documents the new model. make rb-check green (638 runs, 0 failures, rubocop clean). Note: the existing cross-origin credential guard on http.get_absolute still only trusts Launchpad, so a genuinely cross-origin BC5 issuer's authorization.json fetch would be refused — a separable security-guard change flagged for the cross-SDK effort. --- ruby/README.md | 44 +++++ ruby/lib/basecamp/http.rb | 55 +++++- ruby/lib/basecamp/oauth.rb | 168 +++++++++++++++++ ruby/lib/basecamp/oauth/config.rb | 21 ++- ruby/lib/basecamp/oauth/discovery.rb | 161 ++++++++++------- ruby/lib/basecamp/oauth/discovery_result.rb | 33 ++++ .../oauth/discovery_selection_error.rb | 28 +++ ruby/lib/basecamp/oauth/fetcher.rb | 142 +++++++++++++++ .../oauth/protected_resource_metadata.rb | 22 +++ ruby/lib/basecamp/oauth/resource.rb | 79 ++++++++ ruby/lib/basecamp/security.rb | 54 +++++- .../services/authorization_service.rb | 32 ++-- .../basecamp/oauth_resource_discovery_test.rb | 170 ++++++++++++++++++ ruby/test/basecamp/oauth_ssrf_test.rb | 118 ++++++++++++ ruby/test/basecamp/oauth_test.rb | 81 ++++++++- ruby/test/basecamp/same_origin_test.rb | 69 ++++++- ruby/test/basecamp/security_test.rb | 53 ++++++ .../services/authorization_service_test.rb | 105 +++++++++-- ruby/test/test_helper.rb | 19 +- 19 files changed, 1319 insertions(+), 135 deletions(-) create mode 100644 ruby/lib/basecamp/oauth/discovery_result.rb create mode 100644 ruby/lib/basecamp/oauth/discovery_selection_error.rb create mode 100644 ruby/lib/basecamp/oauth/fetcher.rb create mode 100644 ruby/lib/basecamp/oauth/protected_resource_metadata.rb create mode 100644 ruby/lib/basecamp/oauth/resource.rb create mode 100644 ruby/test/basecamp/oauth_resource_discovery_test.rb create mode 100644 ruby/test/basecamp/oauth_ssrf_test.rb diff --git a/ruby/README.md b/ruby/README.md index 8a0aa7475..4579b2f14 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -133,6 +133,50 @@ if token.expired? end ``` +### Resource-First Discovery (RFC 9728 + RFC 8414) + +BC5's Authorization Server (AS) metadata lives only at the canonical issuer (the +web host), so discovery starts from the **resource** (the API host) rather than +probing the API host for AS metadata. Three composable operations are provided: + +```ruby +# RFC 8414 — AS metadata for a known issuer, bound to the requested issuer by +# code-point. token_endpoint is required; authorization_endpoint is OPTIONAL +# (device-only servers omit it) — authorization-code consumers must assert it. +config = Basecamp::Oauth.discover("https://launchpad.37signals.com") + +# RFC 9728 — protected-resource metadata for a resource origin. resource is +# bound by code-point; authorization_servers preserves absent (nil) vs []. +resource = Basecamp::Oauth.discover_protected_resource("https://3.basecampapi.com") + +# Orchestrator — resource-first selection + stage-sensitive fallback. +result = Basecamp::Oauth.discover_from_resource( + "https://3.basecampapi.com", + expected_issuer: nil # optional: authoritative issuer selection +) + +if result.selected? + config = result.config # bound AS config for the selected issuer +else + # Only two SOFT reasons ever yield a fallback (→ Launchpad): + # "resource_discovery_failed" | "no_as_advertised" + result.reason +end +``` + +`discover_from_resource` returns a `DiscoveryResult` that is either **selected** +or a **soft fallback**. Every *hard* failure — an ambiguous advertised set, an +unavailable `expected_issuer`, an invalid advertised issuer origin, an AS-metadata +fetch failure, or an issuer-binding mismatch after a BC5 issuer was selected — +raises `Basecamp::Oauth::DiscoverySelectionError` (carrying a `reason`). A hard +failure is **never** silently converted into a Launchpad request. + +All discovery fetches are SSRF-hardened: origins are validated against the +origin-root profile (HTTPS-only, localhost exempt) with Ruby's `URI` parser before +any socket opens, redirects are not followed, timeouts are bounded, non-2xx maps +to `api_error`, and the response body is read under a genuine streaming cap that +aborts before an oversized body is buffered. + ## Services The SDK provides 37 services covering the complete Basecamp API: diff --git a/ruby/lib/basecamp/http.rb b/ruby/lib/basecamp/http.rb index 31b88db9a..fbfabec99 100644 --- a/ruby/lib/basecamp/http.rb +++ b/ruby/lib/basecamp/http.rb @@ -61,20 +61,58 @@ def get(path, params: {}) end # Performs a GET request to an absolute URL. - # Used for endpoints not on the base API (e.g., Launchpad). + # Used for endpoints not on the base API. + # + # This is the PUBLIC, general path and it credentials cross-origin for ONE + # destination only: the exact Launchpad authorization URL + # ({Basecamp::Security::LAUNCHPAD_AUTHORIZATION_URL}). Every other foreign + # origin — including an endpoint-shaped URL such as + # +https://evil.example/authorization.json+ — trips the same-origin guard, so + # the bearer token only ever reaches Launchpad, the configured base URL, or + # localhost. There is deliberately NO raw-string trusted-origin parameter: a + # syntactically valid origin does not prove discovery provenance, so the ONE + # legitimate cross-origin discovery destination goes through the narrow + # {#get_authorization_document}, which derives its issuer from internal + # discovery of the configured base URL rather than any caller argument. + # # @param url [String] absolute URL # @param params [Hash] query parameters # @return [Response] def get_absolute(url, params: {}) Security.require_https_unless_localhost!(url, "absolute URL") - # Cross-origin is permitted only for the trusted Launchpad authorization - # endpoint; any other foreign origin (including an endpoint-shaped URL such - # as https://evil.example/authorization.json) still trips the same-origin - # guard, so the bearer token never leaks off the configured host. + allow_cross_origin = url == Security::LAUNCHPAD_AUTHORIZATION_URL request(:get, url, params: params, allow_cross_origin: allow_cross_origin) end + # Fetches the credentialed authorization document (the fixed +authorization.json+ + # path). This is the ONE sanctioned cross-origin credential path besides + # Launchpad, and the origin that receives the bearer token is NOT + # caller-supplied. + # + # The issuer is derived HERE by running resource-first discovery (SPEC.md §16) + # against this client's OWN configured base URL, then binding to whatever + # issuer discovery selects and validates (RFC 8414 issuer binding). A soft + # fallback fetches Launchpad's fixed URL; a hard discovery failure raises. The + # request URL is CONSTRUCTED from the discovered issuer origin + the fixed path + # (string concatenation, never URL re-parsing). Because no caller-supplied + # config, origin, or path reaches this method, there is no public API through + # which a forged issuer could redirect the credential to a foreign host — + # discovery provenance is structural, not a claim about a passed-in object. + # + # @return [Response] + # @raise [Oauth::DiscoverySelectionError] on a hard discovery failure + # @raise [Basecamp::UsageError] when the discovered issuer is not an origin root + def get_authorization_document + result = Oauth.discover_from_resource(@config.base_url) + if result.selected? + issuer_origin = Security.require_origin_root!(result.issuer, "selected issuer origin") + request(:get, "#{issuer_origin}/authorization.json", allow_cross_origin: true) + else + get_absolute(Security::LAUNCHPAD_AUTHORIZATION_URL) + end + end + # Performs a POST request. # @param path [String] URL path # @param body [Hash, nil] request body @@ -498,9 +536,10 @@ def build_url(path, allow_cross_origin: false) # Attach-point backstop: refuse to attach credentials to a foreign origin # before the auth strategy adds the bearer token. Localhost is carved out - # for dev/test. allow_cross_origin is granted only by get_absolute, and only - # for the trusted Launchpad authorization endpoint; every other absolute URL - # must be same-origin with the configured base URL. + # for dev/test. allow_cross_origin is granted only by get_absolute (for the + # trusted Launchpad authorization endpoint) or get_authorization_document (for + # a URL constructed against an issuer that internal discovery selected and + # validated); every other absolute URL must be same-origin with the base URL. def assert_credential_origin!(url, allow_cross_origin) return if allow_cross_origin return if Security.localhost?(url) || Security.same_origin?(url, @config.base_url) diff --git a/ruby/lib/basecamp/oauth.rb b/ruby/lib/basecamp/oauth.rb index 910a3c7ff..5612e06c2 100644 --- a/ruby/lib/basecamp/oauth.rb +++ b/ruby/lib/basecamp/oauth.rb @@ -46,6 +46,18 @@ module Basecamp module Oauth LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com" + # Soft fallback reasons — the ONLY two outcomes under which + # {discover_from_resource} yields a fallback (Launchpad) rather than a + # selected config. Every other failure raises {DiscoverySelectionError}. + FALLBACK_RESOURCE_DISCOVERY_FAILED = "resource_discovery_failed" + FALLBACK_NO_AS_ADVERTISED = "no_as_advertised" + + # Discovers RFC 8414 Authorization Server Metadata and binds +issuer+ to + # +base_url+ by code-point. + # + # @param base_url [String] the OAuth server's issuer origin + # @param timeout [Integer] request timeout in seconds + # @return [Config] def self.discover(base_url, timeout: 10) Discovery.new(timeout: timeout).discover(base_url) end @@ -54,6 +66,55 @@ def self.discover_launchpad(timeout: 10) discover(LAUNCHPAD_BASE_URL, timeout: timeout) end + # Discovers RFC 9728 protected-resource metadata for a resource origin. + # + # @param resource_origin [String] the API/resource host origin + # @param timeout [Integer] request timeout in seconds + # @return [ProtectedResourceMetadata] + def self.discover_protected_resource(resource_origin, timeout: 10) + Resource.new(timeout: timeout).discover(resource_origin) + end + + # Resource-first discovery orchestrator (SPEC.md §16). Composes RFC 9728 + # (resource metadata) and RFC 8414 (AS metadata) and applies the + # stage-sensitive fallback state machine. + # + # Returns a {DiscoveryResult} that is either +selected+ (a bound AS config) + # or a soft +fallback+ whose reason is +resource_discovery_failed+ or + # +no_as_advertised+ ONLY. Every hard failure raises + # {DiscoverySelectionError} — callers MUST NOT convert a raise into a + # Launchpad request. + # + # @param resource_origin [String] the API/resource host origin + # @param expected_issuer [String, nil] explicit, authoritative issuer + # selection. When provided, the advertised member equal by code-point is + # selected; if none matches, +expected_issuer_unavailable+ is raised (never + # a fallback). Omit to use the Basecamp-profile exclusion heuristic. + # @param timeout [Integer] request timeout in seconds + # @return [DiscoveryResult] + # @raise [Basecamp::UsageError] on a malformed caller +resource_origin+ + # @raise [DiscoverySelectionError] on any hard selection/validation failure + def self.discover_from_resource(resource_origin, expected_issuer: nil, timeout: 10) + # Origin-root validation of the *caller's* input is a usage error — let it + # propagate as-is (not a soft fallback). + origin = Basecamp::Security.require_origin_root!(resource_origin, "resource origin") + + # Hop 1: resource metadata. Any failure here is soft (before selection). + resource = begin + discover_protected_resource(origin, timeout: timeout) + rescue Basecamp::UsageError + raise + rescue OauthError + nil + end + + if resource.nil? + DiscoveryResult.fallback(FALLBACK_RESOURCE_DISCOVERY_FAILED) + else + select_and_bind(resource.authorization_servers || [], expected_issuer, timeout) + end + end + def self.exchange_code( token_endpoint:, code:, redirect_uri:, client_id:, client_secret: nil, code_verifier: nil, @@ -84,5 +145,112 @@ def self.refresh_token( def self.token_expired?(token, buffer_seconds = 60) token.expired?(buffer_seconds) end + + # Selects an issuer from the advertised set and — once a BC5 issuer is + # committed — binds its AS metadata. From this point on, every failure is + # fatal: no Launchpad request may be issued. + # + # @return [DiscoveryResult] +selected+, or +fallback(no_as_advertised)+ when + # the advertised set carries no non-Launchpad issuer. + def self.select_and_bind(advertised, expected_issuer, timeout) + selected_issuer = + if expected_issuer + select_expected(advertised, expected_issuer) + else + select_by_exclusion(advertised) + end + + if selected_issuer.nil? + # Valid resource metadata omits BC5 — soft fallback (before selection). + DiscoveryResult.fallback(FALLBACK_NO_AS_ADVERTISED) + else + bind_issuer(selected_issuer, timeout) + end + end + + # Explicit, authoritative selection: the advertised member equal by + # code-point, else a hard +expected_issuer_unavailable+. + def self.select_expected(advertised, expected_issuer) + match = advertised.find { |server| server == expected_issuer } + if match.nil? + raise DiscoverySelectionError.new( + "expected_issuer_unavailable", + "Expected issuer #{expected_issuer.inspect} is not advertised by the resource" + ) + end + + match + end + + # Basecamp-profile heuristic: identification by exclusion. Exactly one + # non-Launchpad issuer selects it; two or more is a hard +ambiguous_issuers+ + # (never guess); zero returns +nil+ (caller yields the soft fallback). + def self.select_by_exclusion(advertised) + non_launchpad = advertised.reject { |server| launchpad_issuer?(server) } + if non_launchpad.length >= 2 + raise DiscoverySelectionError.new( + "ambiguous_issuers", + "Multiple non-Launchpad issuers advertised; pass expected_issuer to disambiguate: #{non_launchpad.join(", ")}" + ) + end + + non_launchpad.first + end + + # BC5 is committed: validate the advertised issuer origin then fetch + bind + # its AS metadata. Every failure here is a hard {DiscoverySelectionError}. + def self.bind_issuer(selected_issuer, timeout) + issuer_origin = begin + Basecamp::Security.require_origin_root!(selected_issuer, "advertised issuer") + rescue Basecamp::UsageError => e + raise DiscoverySelectionError.new( + "invalid_issuer_origin", + "Advertised issuer #{selected_issuer.inspect} is not a valid origin root: #{e.message}" + ) + end + + config = begin + discover(issuer_origin, timeout: timeout) + rescue OauthError => e + raise as_failure_error(issuer_origin, e) + end + + DiscoveryResult.selected(config) + end + + # Distinguishes an issuer-binding mismatch from a generic AS fetch failure by + # the error's CLASS — a structured marker ({Discovery::IssuerBindingError}) + # raised by the binding check — not by matching its message text. + def self.as_failure_error(issuer_origin, error) + case error + when Discovery::IssuerBindingError + DiscoverySelectionError.new("issuer_mismatch", error.message) + else + DiscoverySelectionError.new( + "as_fetch_failed", + "AS metadata fetch failed for committed issuer #{issuer_origin.inspect}: #{error.message}" + ) + end + end + + # True when an advertised issuer string denotes the Launchpad origin. A + # non-origin-root advertised value is (correctly) treated as non-Launchpad; + # its later origin-root validation raises +invalid_issuer_origin+. Comparison + # is origin-aware (scheme + normalized host + port) via {Security.same_origin?} + # so a case-variant host (e.g. +Launchpad.37signals.COM+) still classifies as + # Launchpad rather than slipping through as a BC5 issuer. + def self.launchpad_issuer?(issuer) + origin = Basecamp::Security.require_origin_root!(issuer, "issuer") + Basecamp::Security.same_origin?(origin, launchpad_origin) + rescue Basecamp::UsageError + false + end + + def self.launchpad_origin + @launchpad_origin ||= Basecamp::Security.require_origin_root!(LAUNCHPAD_BASE_URL, "Launchpad base URL") + end + + private_class_method :select_and_bind, :select_expected, :select_by_exclusion, + :bind_issuer, :as_failure_error, :launchpad_issuer?, :launchpad_origin end end diff --git a/ruby/lib/basecamp/oauth/config.rb b/ruby/lib/basecamp/oauth/config.rb index 1d83462b2..d5bed9288 100644 --- a/ruby/lib/basecamp/oauth/config.rb +++ b/ruby/lib/basecamp/oauth/config.rb @@ -2,26 +2,37 @@ module Basecamp module Oauth - # OAuth 2 server configuration from discovery endpoint. + # OAuth 2 server configuration from an Authorization Server Metadata document + # (RFC 8414). + # + # As of BC5 resource-first discovery, +authorization_endpoint+ is OPTIONAL: + # device-only authorization servers omit it, so authorization-code consumers + # MUST assert its presence before use. +token_endpoint+ stays required. # # @attr issuer [String] The authorization server's issuer identifier - # @attr authorization_endpoint [String] URL of the authorization endpoint + # @attr authorization_endpoint [String, nil] URL of the authorization endpoint (optional) # @attr token_endpoint [String] URL of the token endpoint + # @attr device_authorization_endpoint [String, nil] URL of the RFC 8628 device authorization endpoint # @attr registration_endpoint [String, nil] URL of the dynamic client registration endpoint # @attr scopes_supported [Array, nil] List of OAuth 2 scopes supported + # @attr grant_types_supported [Array, nil] OAuth 2 grant types the server supports Config = Data.define( :issuer, :authorization_endpoint, :token_endpoint, + :device_authorization_endpoint, :registration_endpoint, - :scopes_supported + :scopes_supported, + :grant_types_supported ) do def initialize( issuer:, - authorization_endpoint:, token_endpoint:, + authorization_endpoint: nil, + device_authorization_endpoint: nil, registration_endpoint: nil, - scopes_supported: nil + scopes_supported: nil, + grant_types_supported: nil ) super end diff --git a/ruby/lib/basecamp/oauth/discovery.rb b/ruby/lib/basecamp/oauth/discovery.rb index 640397426..b0d2dc044 100644 --- a/ruby/lib/basecamp/oauth/discovery.rb +++ b/ruby/lib/basecamp/oauth/discovery.rb @@ -1,91 +1,120 @@ # frozen_string_literal: true -require "faraday" -require "json" - module Basecamp module Oauth - # Fetches OAuth 2 server configuration from discovery endpoints. + # Fetches RFC 8414 OAuth 2 Authorization Server Metadata and binds the + # returned +issuer+ to the requested issuer origin (hop 2 of resource-first + # discovery). class Discovery - # @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil) - # @param timeout [Integer] Request timeout in seconds (default: 10) - def initialize(http_client: nil, timeout: 10) - @http_client = http_client || build_default_client(timeout) + # Structured marker: AS metadata failed the RFC 8414 issuer code-point + # bind. Raised (never message-matched) so {Oauth.discover_from_resource} + # classifies an issuer mismatch by CLASS via {Oauth.as_failure_error} — + # brittle, locale-sensitive substring matching is gone. Kept in the + # discovery layer, deliberately NOT in the device error files that + # {DeviceFlowError} shares. + class IssuerBindingError < OauthError + def initialize(message, http_status: nil) + super("api_error", message, http_status: http_status) + end end - # Discovers OAuth configuration from the well-known endpoint. - # - # Fetches the OAuth 2 Authorization Server Metadata from: - # `{base_url}/.well-known/oauth-authorization-server` + # @param http_client [Faraday::Connection, nil] HTTP client (SSRF-hardened default if nil) + # @param timeout [Integer] request timeout in seconds (default: 10) + # @param max_body_bytes [Integer] bounded read cap in bytes + def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) + Fetcher.ensure_redirects_suppressed!(http_client) if http_client + @http_client = http_client || Fetcher.build_client(timeout) + @timeout = timeout + @max_body_bytes = max_body_bytes + end + + # Discovers OAuth configuration from + # {base_url}/.well-known/oauth-authorization-server, binding the + # returned +issuer+ to +base_url+ by code-point (RFC 8414 §3.3/§4, no + # normalization beyond origin-root parsing). +token_endpoint+ is required; + # +authorization_endpoint+ is optional (device-only servers omit it). # - # @param base_url [String] The OAuth server's base URL (e.g., "https://launchpad.37signals.com") - # @return [Config] The OAuth server configuration - # @raise [OauthError] on network or parsing errors + # @param base_url [String] the OAuth server's issuer origin + # @return [Config] the OAuth server configuration + # @raise [Basecamp::UsageError] on a malformed origin + # @raise [OauthError] +api_error+ on invalid metadata / issuer mismatch # # @example - # discovery = Basecamp::Oauth::Discovery.new - # config = discovery.discover("https://launchpad.37signals.com") - # puts config.token_endpoint - # # => "https://launchpad.37signals.com/authorization/token" + # config = Basecamp::Oauth::Discovery.new.discover("https://launchpad.37signals.com") + # config.token_endpoint # => "https://launchpad.37signals.com/authorization/token" def discover(base_url) - Basecamp::Security.require_https_unless_localhost!(base_url, "discovery base URL") + issuer_origin = Basecamp::Security.require_origin_root!(base_url, "OAuth discovery base URL") + discovery_url = "#{issuer_origin}/.well-known/oauth-authorization-server" + data = Fetcher.fetch_json(@http_client, discovery_url, timeout: @timeout, max_body_bytes: @max_body_bytes) + parse_and_bind(data, issuer_origin) + end - normalized_base = base_url.chomp("/") - discovery_url = "#{normalized_base}/.well-known/oauth-authorization-server" + private - response = @http_client.get(discovery_url) do |req| - req.headers["Accept"] = "application/json" - end + # Universal validation only: +issuer+ + +token_endpoint+ present and + # non-empty, issuer identical by code-point, and any present +*_endpoint+ + # field non-empty. Per-grant endpoint checks are the consumer's job. + def parse_and_bind(data, expected_issuer_origin) + issuer = data["issuer"] + if !issuer.is_a?(String) || issuer.empty? + raise OauthError.new("api_error", "Invalid OAuth discovery response: missing required fields: issuer") + end - unless response.success? - raise OauthError.new( - "network", - "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(response.body)}", - http_status: response.status - ) - end + # RFC 8414 §3.3/§4: issuer identical by code-point. No normalization. + unless issuer == expected_issuer_origin + raise IssuerBindingError.new( + "OAuth issuer mismatch: metadata issuer #{issuer.inspect} does not equal #{expected_issuer_origin.inspect}" + ) + end - Basecamp::Security.check_body_size!(response.body, Basecamp::Security::MAX_ERROR_BODY_BYTES, "Discovery") + token_endpoint = data["token_endpoint"] + if !token_endpoint.is_a?(String) || token_endpoint.empty? + raise OauthError.new("api_error", "Invalid OAuth discovery response: missing required fields: token_endpoint") + end - data = JSON.parse(response.body) - validate_discovery_response!(data) + reject_empty_endpoints!(data) + validate_string_array!(data, "grant_types_supported") + validate_string_array!(data, "scopes_supported") - Config.new( - issuer: data["issuer"], - authorization_endpoint: data["authorization_endpoint"], - token_endpoint: data["token_endpoint"], - registration_endpoint: data["registration_endpoint"], - scopes_supported: data["scopes_supported"] - ) - rescue Faraday::Error => e - raise OauthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true) - rescue JSON::ParserError => e - raise OauthError.new("api_error", "Failed to parse discovery response: #{e.message}") - end + Config.new( + issuer: issuer, + authorization_endpoint: data["authorization_endpoint"], + token_endpoint: token_endpoint, + device_authorization_endpoint: data["device_authorization_endpoint"], + registration_endpoint: data["registration_endpoint"], + scopes_supported: data["scopes_supported"], + grant_types_supported: data["grant_types_supported"] + ) + end - private + # Any endpoint field that IS present must be a non-empty String: "" is a + # truthy value in Ruby and must be rejected, and a non-string endpoint + # (array/number/object) is malformed metadata. + def reject_empty_endpoints!(data) + data.each do |key, value| + next unless key.end_with?("_endpoint") && !value.nil? - def build_default_client(timeout) - Faraday.new do |conn| - conn.options.timeout = timeout - conn.options.open_timeout = timeout - conn.adapter Faraday.default_adapter + unless value.is_a?(String) && !value.empty? + raise OauthError.new("api_error", "Invalid OAuth discovery response: invalid #{key}") + end + end end - end - - def validate_discovery_response!(data) - missing = [] - missing << "issuer" unless data["issuer"] - missing << "authorization_endpoint" unless data["authorization_endpoint"] - missing << "token_endpoint" unless data["token_endpoint"] - return if missing.empty? + # A metadata list field (e.g. +grant_types_supported+, +scopes_supported+), + # when present, must be an array of strings. A bare string must never be + # accepted: substring-matching +grant_types_supported+ could falsely enable + # a grant such as device_code, and a non-array is malformed metadata. + def validate_string_array!(data, key) + value = data[key] + return if value.nil? - raise OauthError.new( - "api_error", - "Invalid OAuth discovery response: missing required fields: #{missing.join(", ")}" - ) - end + unless value.is_a?(Array) && value.all?(String) + raise OauthError.new( + "api_error", + "Invalid OAuth discovery response: #{key} must be an array of strings" + ) + end + end end end end diff --git a/ruby/lib/basecamp/oauth/discovery_result.rb b/ruby/lib/basecamp/oauth/discovery_result.rb new file mode 100644 index 000000000..9bd9f620a --- /dev/null +++ b/ruby/lib/basecamp/oauth/discovery_result.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Basecamp + module Oauth + # Result of {Oauth.discover_from_resource}: either a *selected* AS config, or + # a *soft* fallback to Launchpad. Hard failures are raised as + # {DiscoverySelectionError}, never represented here — so no consumer can + # convert a hard failure into a Launchpad request. + # + # @attr kind [Symbol] +:selected+ or +:fallback+ + # @attr config [Config, nil] the selected AS config (when +:selected+) + # @attr issuer [String, nil] the selected issuer (when +:selected+) + # @attr reason [String, nil] the soft fallback reason (when +:fallback+): + # +"resource_discovery_failed"+ or +"no_as_advertised"+ + DiscoveryResult = Data.define(:kind, :config, :issuer, :reason) do + def self.selected(config) + new(kind: :selected, config: config, issuer: config.issuer, reason: nil) + end + + def self.fallback(reason) + new(kind: :fallback, config: nil, issuer: nil, reason: reason) + end + + def selected? + kind == :selected + end + + def fallback? + kind == :fallback + end + end + end +end diff --git a/ruby/lib/basecamp/oauth/discovery_selection_error.rb b/ruby/lib/basecamp/oauth/discovery_selection_error.rb new file mode 100644 index 000000000..4ed9c3bcb --- /dev/null +++ b/ruby/lib/basecamp/oauth/discovery_selection_error.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Basecamp + module Oauth + # A hard resource-first selection/validation failure. Raised — never returned + # as a fallback — so no consumer can convert it into a Launchpad request. + # + # The +reason+ is one of: + # +ambiguous_issuers+, +expected_issuer_unavailable+, +invalid_issuer_origin+, + # +as_fetch_failed+, +issuer_mismatch+, +capability_unavailable+. + # + # @attr reason [String] the hard-failure classification + class DiscoverySelectionError < OauthError + attr_reader :reason + + # @param reason [String] the hard-failure classification + # @param message [String] human-readable description + # @param http_status [Integer, nil] HTTP status code, if applicable + def initialize(reason, message, http_status: nil) + # capability/expected-issuer are consumer/usage-shaped; the rest are AS + # metadata faults surfaced as api_error. + type = reason == "capability_unavailable" ? "validation" : "api_error" + super(type, message, http_status: http_status) + @reason = reason + end + end + end +end diff --git a/ruby/lib/basecamp/oauth/fetcher.rb b/ruby/lib/basecamp/oauth/fetcher.rb new file mode 100644 index 000000000..8fa9f2f27 --- /dev/null +++ b/ruby/lib/basecamp/oauth/fetcher.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "faraday" +require "json" + +module Basecamp + module Oauth + # SSRF-hardened fetch of a small OAuth discovery JSON document, shared by both + # discovery hops (RFC 9728 resource metadata and RFC 8414 AS metadata). + # + # RFC 9728 §7.7 flags SSRF via attacker-influenced metadata: advertised AS + # URLs are untrusted input. Every fetch therefore: + # + # 1. requires HTTPS (localhost exempt) — enforced by the origin-root profile + # ({Basecamp::Security.require_origin_root!}) before this is called; + # 2. bounds the timeout (set on the injected connection); + # 3. suppresses redirects — the default Faraday connection carries no redirect + # middleware, so an attacker-controlled 3xx +Location+ is surfaced as a + # non-2xx +api_error+ rather than chased; + # 4. reads the body under a genuine bounded/streaming cap that aborts the read + # the moment the accumulated size exceeds the limit (via Faraday's +on_data+ + # streaming callback) — NOT a post-hoc size check on an already-buffered + # body. + # + # Non-2xx on either hop maps to +api_error+ (not +network+). + module Fetcher + # Discovery documents are tiny; cap the read at 1 MiB by default. + DEFAULT_MAX_BODY_BYTES = 1 * 1024 * 1024 + + # Raised internally to abort a streaming read once the cap is exceeded. + # Never escapes this module — it is mapped to an OauthError. + class BodyTooLarge < StandardError; end + + # Builds a +[chunks, on_data]+ pair for a genuine bounded/streaming read. + # Assign +on_data+ to a request's +req.options.on_data+; after the request + # returns, +chunks.join+ is the accumulated body. The proc raises + # {BodyTooLarge} the moment the accumulated size exceeds +max_body_bytes+, + # so an oversized body is never fully buffered. Callers rescue + # {BodyTooLarge} and map it to their own error. Shared by both discovery + # hops and the device flow so every OAuth response reads under the same cap. + # + # @param max_body_bytes [Integer] bounded read cap in bytes + # @return [Array(Array, Proc)] the chunk buffer and the +on_data+ proc + def self.bounded_reader(max_body_bytes) + chunks = [] + total = 0 + reader = proc do |chunk, _received| + total += chunk.bytesize + raise BodyTooLarge if total > max_body_bytes + + chunks << chunk + end + [ chunks, reader ] + end + + # Builds the default SSRF-hardened Faraday connection. No redirect + # middleware is registered, so redirects are not followed. + # + # @param timeout [Integer] request + connect timeout in seconds + # @return [Faraday::Connection] + def self.build_client(timeout) + Faraday.new do |conn| + conn.options.timeout = timeout + conn.options.open_timeout = timeout + conn.adapter Faraday.default_adapter + end + end + + # Rejects an INJECTED connection that carries redirect-following middleware. + # Redirect suppression is a load-bearing SSRF control (RFC 9728 §7.7): a + # caller-supplied client with +FollowRedirects+ would silently chase an + # attacker-controlled +Location+, so we refuse it rather than harden it in + # place (the middleware stack of a foreign connection is not ours to + # rewrite). The default {build_client} connection carries no such + # middleware and passes. + # + # @param client [Faraday::Connection] + # @raise [OauthError] +validation+ when redirect middleware is present + def self.ensure_redirects_suppressed!(client) + handlers = client.respond_to?(:builder) ? Array(client.builder.handlers) : [] + offending = handlers.find { |h| h.respond_to?(:klass) && h.klass.name.to_s.match?(/redirect/i) } + return unless offending + + raise OauthError.new( + "validation", + "Injected OAuth discovery client must not follow redirects " \ + "(found #{offending.klass.name}); redirects are suppressed for SSRF safety" + ) + end + + # Fetches +url+ and returns the parsed JSON object (a Hash). + # + # The request timeout is applied per-request (not only on the connection) + # so a bounded read is enforced even when the caller INJECTS its own + # connection: an injected client's adapter default would otherwise leave the + # requested +timeout+ unenforced. This mirrors the device flow's +post_form+. + # + # @param http_client [Faraday::Connection] the SSRF-hardened connection + # @param url [String] fully-qualified well-known URL to fetch + # @param timeout [Integer] per-request timeout in seconds + # @param max_body_bytes [Integer] bounded read cap in bytes + # @return [Hash] the parsed JSON document + # @raise [OauthError] +api_error+ on non-2xx, oversized body, non-object + # JSON, or parse failure; +network+ on transport failure + def self.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES) + chunks, on_data = bounded_reader(max_body_bytes) + + response = http_client.get(url) do |req| + req.headers["Accept"] = "application/json" + # Bounded streaming read: abort the moment the cap is exceeded so an + # oversized body is never fully buffered. + req.options.on_data = on_data + # Apply the request timeout on every request — even an injected client — + # so a stalled socket can't hang discovery under the adapter default. + req.options.timeout = timeout + req.options.open_timeout = timeout + end + + body = chunks.join.force_encoding(Encoding::UTF_8) + + unless (200..299).cover?(response.status) + raise OauthError.new( + "api_error", + "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(body)}", + http_status: response.status + ) + end + + data = JSON.parse(body) + raise OauthError.new("api_error", "OAuth discovery response is not a JSON object") unless data.is_a?(Hash) + + data + rescue BodyTooLarge + raise OauthError.new("api_error", "OAuth discovery response exceeds size cap") + rescue Faraday::Error => e + raise OauthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true) + rescue JSON::ParserError => e + raise OauthError.new("api_error", "Failed to parse OAuth discovery response: #{e.message}") + end + end + end +end diff --git a/ruby/lib/basecamp/oauth/protected_resource_metadata.rb b/ruby/lib/basecamp/oauth/protected_resource_metadata.rb new file mode 100644 index 000000000..7f2600f38 --- /dev/null +++ b/ruby/lib/basecamp/oauth/protected_resource_metadata.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Basecamp + module Oauth + # RFC 9728 protected-resource metadata (hop 1 of resource-first discovery). + # + # +authorization_servers+ preserves "key absent" and "present but empty + # []" distinctly: BC5 omits the key while dark, per RFC 9728 §3.2. + # Both select Launchpad, but the distinction is meaningful to callers + # inspecting metadata directly (absent => +nil+, empty => +[]+). + # + # @attr resource [String] The resource identifier; equals the requested + # resource origin by code-point. + # @attr authorization_servers [Array, nil] Advertised authorization + # servers; +nil+ when the key is absent, +[]+ when present but empty. + ProtectedResourceMetadata = Data.define(:resource, :authorization_servers) do + def initialize(resource:, authorization_servers: nil) + super + end + end + end +end diff --git a/ruby/lib/basecamp/oauth/resource.rb b/ruby/lib/basecamp/oauth/resource.rb new file mode 100644 index 000000000..f8a6b910c --- /dev/null +++ b/ruby/lib/basecamp/oauth/resource.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Basecamp + module Oauth + # Fetches RFC 9728 protected-resource metadata (hop 1 of resource-first + # discovery) and binds the returned +resource+ to the requested origin. + class Resource + # @param http_client [Faraday::Connection, nil] HTTP client (SSRF-hardened default if nil) + # @param timeout [Integer] request timeout in seconds (default: 10) + # @param max_body_bytes [Integer] bounded read cap in bytes + def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) + Fetcher.ensure_redirects_suppressed!(http_client) if http_client + @http_client = http_client || Fetcher.build_client(timeout) + @timeout = timeout + @max_body_bytes = max_body_bytes + end + + # Discovers protected-resource metadata from + # {resource_origin}/.well-known/oauth-protected-resource. + # +resource+ is required and must equal the requested origin by code-point; + # +authorization_servers+ is preserved distinctly as absent (+nil+) vs + # present-empty (+[]+). + # + # @param resource_origin [String] the API/resource host origin + # @return [ProtectedResourceMetadata] + # @raise [Basecamp::UsageError] on a malformed caller origin + # @raise [OauthError] +api_error+ on invalid metadata / resource mismatch + def discover(resource_origin) + origin = Basecamp::Security.require_origin_root!(resource_origin, "resource origin") + url = "#{origin}/.well-known/oauth-protected-resource" + data = Fetcher.fetch_json(@http_client, url, timeout: @timeout, max_body_bytes: @max_body_bytes) + + resource = data["resource"] + # Type-check, don't just probe truthiness: a wrong-typed resource (number, + # object, array) is malformed metadata, and calling +.empty?+ on it would + # raise a NoMethodError rather than a clean api_error. + if !resource.is_a?(String) || resource.empty? + raise OauthError.new("api_error", "Invalid resource metadata: missing required field: resource") + end + + # Bind the resource identifier to the requested origin, code-point exact. + unless resource == origin + raise OauthError.new( + "api_error", + "Resource identifier mismatch: metadata resource #{resource.inspect} does not equal #{origin.inspect}" + ) + end + + ProtectedResourceMetadata.new( + resource: resource, + authorization_servers: extract_authorization_servers(data) + ) + end + + private + + # Preserve absent (+nil+) vs present-empty (+[]+); a present +null+ is + # normalized to +[]+ to match the "present but empty" posture. A present + # value that is not an array of strings is malformed metadata and must be + # rejected — never iterated (a bare string would otherwise be treated as a + # sequence of single-character issuers during selection). + def extract_authorization_servers(data) + return nil unless data.key?("authorization_servers") + + servers = data["authorization_servers"] + return [] if servers.nil? + + unless servers.is_a?(Array) && servers.all?(String) + raise OauthError.new( + "api_error", + "Invalid resource metadata: authorization_servers must be an array of strings" + ) + end + + servers + end + end + end +end diff --git a/ruby/lib/basecamp/security.rb b/ruby/lib/basecamp/security.rb index a3a9699dc..11cd46ebb 100644 --- a/ruby/lib/basecamp/security.rb +++ b/ruby/lib/basecamp/security.rb @@ -11,8 +11,12 @@ module Security MAX_ERROR_BODY_BYTES = 1 * 1024 * 1024 # 1 MB # The Launchpad authorization endpoint is on a different origin than the - # configured API base URL, so it is the one sanctioned destination for a - # credentialed cross-origin request. Every other foreign origin is rejected. + # configured API base URL, so it is a sanctioned destination for a + # credentialed cross-origin request. Resource-first discovery may also + # authorize one specific discovered-and-validated issuer origin (see + # Http#get_authorization_document, whose issuer comes from internal discovery + # of the configured base URL, not a caller argument); every other foreign + # origin is rejected. LAUNCHPAD_AUTHORIZATION_URL = "https://launchpad.37signals.com/authorization.json" def self.truncate(str, max = MAX_ERROR_MESSAGE_BYTES) @@ -88,6 +92,52 @@ def self.require_https_unless_localhost!(url, label = "URL") require_https!(url, label) end + # Parses a caller- or metadata-supplied origin and enforces the origin-root + # profile (SPEC.md §16): https (or http on localhost), host present, optional + # valid numeric port, path empty or exactly "/", and no query, fragment, or + # userinfo. Parsing uses Ruby's +URI+ (never a regex) so bracketed IPv6 + # (+http://[::1]:3000+) and ports agree with the host the client dials. + # + # A bad *caller* origin is a usage error; callers validating an *advertised* + # origin rescue {UsageError} and reclassify. + # + # @param raw [String] the origin to validate + # @param label [String] a label for error messages + # @return [String] the normalized origin (+scheme://host[:port]+, no trailing slash) + # @raise [UsageError] on any profile violation or parse failure + def self.require_origin_root!(raw, label = "origin") + uri = URI.parse(raw.to_s) + scheme = uri.scheme&.downcase + + unless scheme == "https" || (scheme == "http" && localhost?(raw)) + raise UsageError.new("#{label} must use HTTPS (or http on localhost): #{raw}") + end + raise UsageError.new("#{label} has no host: #{raw}") if uri.host.nil? || uri.host.empty? + raise UsageError.new("#{label} must not contain userinfo: #{raw}") if uri.userinfo + raise UsageError.new("#{label} must not contain a query or fragment: #{raw}") if uri.query || uri.fragment + unless uri.path.nil? || uri.path.empty? || uri.path == "/" + raise UsageError.new("#{label} must be an origin root (no path): #{raw}") + end + + # URI.parse rejects a non-numeric port, but it happily accepts a numeric + # port outside the valid TCP range (e.g. :0 or :99999). Reject anything + # outside 1–65535 so a structurally-parseable-but-undialable port can never + # be treated as a trusted origin. + if uri.port && !uri.port.between?(1, 65_535) + raise UsageError.new("#{label} has an out-of-range port: #{raw}") + end + + # A surviving uri now has a structurally valid, in-range (or default) port. + # Drop the default port. + if uri.port && uri.port != uri.default_port + "#{scheme}://#{uri.host}:#{uri.port}" + else + "#{scheme}://#{uri.host}" + end + rescue URI::InvalidURIError + raise UsageError.new("Invalid #{label}: not a valid absolute URL: #{raw}") + end + # Headers that contain sensitive values and should be redacted. SENSITIVE_HEADERS = %w[ authorization diff --git a/ruby/lib/basecamp/services/authorization_service.rb b/ruby/lib/basecamp/services/authorization_service.rb index 4b2cc1ee1..bea0961c1 100644 --- a/ruby/lib/basecamp/services/authorization_service.rb +++ b/ruby/lib/basecamp/services/authorization_service.rb @@ -12,36 +12,28 @@ module Services # puts "Account: #{account["name"]} (#{account["id"]})" # end class AuthorizationService < BaseService - # Fallback Launchpad endpoint for authorization - LAUNCHPAD_AUTHORIZATION_URL = "https://launchpad.37signals.com/authorization.json" - # Gets authorization information for the current user. # - # Attempts to use the authorization endpoint discovered via OAuth discovery - # on the configured base URL. Falls back to Launchpad if discovery fails. + # Delegates to {Basecamp::Http#get_authorization_document}, which resolves + # the issuer via resource-first OAuth discovery (SPEC.md §16) on the client's + # own configured base URL and fetches the fixed +authorization.json+ path. + # Only the two *soft* fallback outcomes (+resource_discovery_failed+, + # +no_as_advertised+) fall back to Launchpad; every *hard* selection failure + # propagates as a {Oauth::DiscoverySelectionError} — a hard failure is never + # silently converted into a Launchpad request. This service passes NO issuer, + # config, or origin to the credentialed fetch, so there is no caller-supplied + # path through which the bearer token could be sent to a foreign host. # # Returns the authenticated user's identity and list of accounts # they have access to. # # @return [Hash] authorization info with :identity and :accounts + # @raise [Oauth::DiscoverySelectionError] on a hard discovery failure after + # a BC5 issuer was advertised and selected # @see https://github.com/basecamp/bc3-api/blob/master/sections/authentication.md def get - url = discover_authorization_url - response = http.get_absolute(url) - response.json + http.get_authorization_document.json end - - private - - def discover_authorization_url - # Try OAuth discovery on the configured base URL - config = Oauth.discover(http.base_url) - # Use issuer as base for authorization.json - "#{config.issuer.chomp("/")}/authorization.json" - rescue Oauth::OauthError - # Fall back to Launchpad - LAUNCHPAD_AUTHORIZATION_URL - end end end end diff --git a/ruby/test/basecamp/oauth_resource_discovery_test.rb b/ruby/test/basecamp/oauth_resource_discovery_test.rb new file mode 100644 index 000000000..b169146c3 --- /dev/null +++ b/ruby/test/basecamp/oauth_resource_discovery_test.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "test_helper" + +# Drives the shared, data-only fixtures in conformance/oauth/fixtures with this +# harness's mock origins substituted for the {{...}} placeholders, so issuer / +# resource binding stays code-point-exact against the mocked hosts. +class OAuthResourceDiscoveryTest < Minitest::Test + include TestHelper + + FIXTURE_DIR = File.expand_path("../../../conformance/oauth/fixtures", __dir__) + + # Mock origins substituted for fixture placeholders. LAUNCHPAD must be the real + # origin because the fallback consumer targets it. + ORIGINS = { + "{{RESOURCE_ORIGIN}}" => "https://api.basecamp-test.example", + "{{ISSUER_ORIGIN}}" => "https://issuer.basecamp-test.example", + "{{LAUNCHPAD_ORIGIN}}" => "https://launchpad.37signals.com", + "{{BC5_ISSUER}}" => "https://bc5.basecamp-test.example" + }.freeze + + WELL_KNOWN_RESOURCE = "/.well-known/oauth-protected-resource" + WELL_KNOWN_AS = "/.well-known/oauth-authorization-server" + + # Generate one test method per fixture file. + Dir.glob(File.join(FIXTURE_DIR, "*.json")).sort.each do |path| + fixture = JSON.parse(File.read(path)) + # The oversized-body scenario needs a genuine streaming transport; it is + # exercised by a dedicated test below (WebMock delivers the body as one + # chunk, so it can't demonstrate a bounded read). + next if fixture.dig("hop1", "oversized") || fixture.dig("hop2", "oversized") + + define_method(:"test_fixture_#{fixture["name"].tr("-", "_")}") do + drive_fixture(substitute(fixture)) + end + end + + private + + def substitute(value) + json = JSON.generate(value) + ORIGINS.each { |placeholder, origin| json = json.gsub(placeholder, origin) } + JSON.parse(json) + end + + def drive_fixture(fixture) + # Track any request to the Launchpad well-known endpoints. The orchestrator + # itself never contacts Launchpad; hard cases must never reach here. + stub_launchpad_well_known + + # A bracketed IPv6 origin (e.g. +http://[::1]:3000+) is driven through the + # transport end-to-end: WebMock stubs the well-known endpoint at the literal + # bracketed origin by exact match (only regex/pattern matching breaks on + # brackets, not exact-string stubs), so the fixture exercises the real fetch + # path where a regex-based origin parser would fail. + stub_hops(fixture) + evaluate(fixture) + + if fixture.dig("expect", "launchpadContacted") == false + assert_not_requested :get, "#{ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}#{WELL_KNOWN_RESOURCE}" + assert_not_requested :get, "#{ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}#{WELL_KNOWN_AS}" + end + end + + def stub_launchpad_well_known + launchpad = ORIGINS["{{LAUNCHPAD_ORIGIN}}"] + stub_request(:get, "#{launchpad}#{WELL_KNOWN_RESOURCE}") + .to_return(status: 200, body: { resource: launchpad }.to_json, + headers: { "Content-Type" => "application/json" }) + stub_request(:get, "#{launchpad}#{WELL_KNOWN_AS}") + .to_return(status: 200, body: { + issuer: launchpad, + authorization_endpoint: "#{launchpad}/authorization/new", + token_endpoint: "#{launchpad}/authorization/token" + }.to_json, headers: { "Content-Type" => "application/json" }) + end + + def stub_hops(fixture) + if (hop1 = fixture["hop1"]) + stub_exchange("#{fixture["resourceOrigin"]}#{WELL_KNOWN_RESOURCE}", hop1) + end + + if (hop2 = fixture["hop2"]) + origin = hop2["origin"] || fixture["issuerOrigin"] + stub_exchange("#{origin}#{WELL_KNOWN_AS}", hop2) + end + end + + def stub_exchange(url, exchange) + request = stub_request(:get, url) + + if exchange["transportError"] + request.to_timeout + elsif exchange["redirectTo"] + request.to_return(status: exchange["status"] || 302, + headers: { "Location" => exchange["redirectTo"] }) + elsif exchange.key?("body") + request.to_return(status: exchange["status"] || 200, + body: exchange["body"].to_json, + headers: { "Content-Type" => "application/json" }) + else + request.to_return(status: exchange["status"] || 200, body: "") + end + end + + def run_operation(fixture) + case fixture["operation"] + when "discoverFromResource" + Basecamp::Oauth.discover_from_resource(fixture["resourceOrigin"], expected_issuer: fixture["expectedIssuer"]) + when "discoverProtectedResource" + Basecamp::Oauth.discover_protected_resource(fixture["resourceOrigin"]) + when "discover" + Basecamp::Oauth.discover(fixture["issuerOrigin"]) + end + end + + def evaluate(fixture) + expect = fixture["expect"] + case expect["outcome"] + when "raise" + assert_raise_outcome(fixture, expect) + when "fallback" + result = run_operation(fixture) + assert result.fallback?, "expected a fallback result" + assert_equal expect["fallbackReason"], result.reason + when "selected" + assert_selected_outcome(fixture, expect) + end + end + + def assert_raise_outcome(fixture, expect) + error = assert_raises(StandardError) { run_operation(fixture) } + + if expect["error"] == "usage" + assert_instance_of Basecamp::UsageError, error + elsif fixture["operation"] == "discoverFromResource" + assert_instance_of Basecamp::Oauth::DiscoverySelectionError, error + assert_equal expect["error"], error.reason + else + # discover / discoverProtectedResource hard failures are api_error. + assert_kind_of Basecamp::Oauth::OauthError, error + assert_equal "api_error", error.type + end + + # Cross-SDK coarse-category assertion. + assert_equal expect["errorCategory"], error_category(error) if expect["errorCategory"] + end + + def error_category(error) + case error + when Basecamp::UsageError then "usage" + when Basecamp::Oauth::OauthError then error.type + end + end + + def assert_selected_outcome(fixture, expect) + result = run_operation(fixture) + if fixture["operation"] == "discoverFromResource" + assert result.selected?, "expected a selected result" + assert_equal expect["selectedIssuer"], result.issuer if expect["selectedIssuer"] + elsif fixture["operation"] == "discover" && expect["selectedIssuer"] + # discover returns a Config; binding success means issuer matches. + assert_equal expect["selectedIssuer"], result.issuer + elsif fixture["operation"] == "discoverProtectedResource" && expect["selectedIssuer"] + # discoverProtectedResource returns ProtectedResourceMetadata; a bound + # resource equal to the requested origin (code-point exact) is success. + assert_equal expect["selectedIssuer"], result.resource + end + end +end diff --git a/ruby/test/basecamp/oauth_ssrf_test.rb b/ruby/test/basecamp/oauth_ssrf_test.rb new file mode 100644 index 000000000..36b6ecae2 --- /dev/null +++ b/ruby/test/basecamp/oauth_ssrf_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "test_helper" +require "faraday" + +# SSRF hardening for OAuth discovery: bounded/streaming body reads and redirect +# suppression, exercised at runtime against a real injected Faraday connection. +class OAuthSsrfTest < Minitest::Test + include TestHelper + + # A real Faraday adapter that streams a body to the request's +on_data+ + # callback in fixed-size chunks, recording how many bytes were delivered before + # the consumer aborts. This lets the test prove the read is genuinely bounded — + # it stops before the whole oversized body is buffered — rather than relying on + # a post-hoc size check (WebMock delivers a body as a single chunk). + class StreamingAdapter < Faraday::Adapter + def initialize(app = nil, body:, chunk_size:, meter:, status: 200) + super(app) + @body = body + @chunk_size = chunk_size + @meter = meter + @status = status + end + + def call(env) + on_data = env.request.on_data + if on_data + deliver_chunked(on_data) + save_response(env, @status, "", { "Content-Type" => "application/json" }) + else + save_response(env, @status, @body, { "Content-Type" => "application/json" }) + end + @app.call(env) + end + + private + + def deliver_chunked(on_data) + sent = 0 + while sent < @body.bytesize + piece = @body.byteslice(sent, @chunk_size) + sent += piece.bytesize + @meter[:delivered] = sent + on_data.call(piece, sent) + end + end + end + + def test_over_cap_body_aborts_before_buffering_whole_body + issuer = "https://issuer.ssrf-test.example" + # A well-formed but oversized document: valid JSON padded far past the cap. + oversized = { "issuer" => issuer, "token_endpoint" => "#{issuer}/t", "pad" => "x" * (256 * 1024) }.to_json + + meter = { delivered: 0 } + connection = Faraday.new do |conn| + conn.adapter StreamingAdapter, body: oversized, chunk_size: 4096, meter: meter + end + + cap = 8 * 1024 + discovery = Basecamp::Oauth::Discovery.new(http_client: connection, max_body_bytes: cap) + + error = assert_raises(Basecamp::Oauth::OauthError) do + discovery.discover(issuer) + end + assert_equal "api_error", error.type + + # The streaming read aborted once the accumulated bytes exceeded the cap, so + # only a bounded prefix — not the whole 256 KiB body — was ever delivered. + assert_operator meter[:delivered], :<=, cap + 4096 + assert_operator meter[:delivered], :<, oversized.bytesize + end + + # A middleware whose name matches the redirect detector. Standing in for + # faraday-follow_redirects without taking the dependency. + class RedirectFollowingMiddleware < Faraday::Middleware + def call(env) + @app.call(env) + end + end + + def test_injected_client_carrying_redirect_middleware_is_rejected + connection = Faraday.new do |conn| + conn.use RedirectFollowingMiddleware + conn.adapter Faraday.default_adapter + end + + # The hardening lives on the CONNECTION, so an injected redirect-following + # client must be refused at construction — not silently trusted. + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth::Discovery.new(http_client: connection) + end + assert_equal "validation", error.type + + resource_error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth::Resource.new(http_client: connection) + end + assert_equal "validation", resource_error.type + end + + def test_redirect_is_not_followed + issuer = "https://issuer.redirect-test.example" + attacker = "https://attacker.example.com" + + stub_request(:get, "#{issuer}/.well-known/oauth-authorization-server") + .to_return(status: 302, headers: { "Location" => "#{attacker}/.well-known/oauth-authorization-server" }) + attacker_stub = stub_request(:get, "#{attacker}/.well-known/oauth-authorization-server") + .to_return(status: 200, body: { issuer: attacker, token_endpoint: "#{attacker}/t" }.to_json, + headers: { "Content-Type" => "application/json" }) + + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth.discover(issuer) + end + # The 3xx is surfaced as a non-2xx api_error rather than chased. + assert_equal "api_error", error.type + assert_equal 302, error.http_status + assert_not_requested(attacker_stub) + end +end diff --git a/ruby/test/basecamp/oauth_test.rb b/ruby/test/basecamp/oauth_test.rb index 19206e0fd..fe7f0bd76 100644 --- a/ruby/test/basecamp/oauth_test.rb +++ b/ruby/test/basecamp/oauth_test.rb @@ -64,14 +64,63 @@ def test_discover_raises_on_missing_fields assert_includes error.message, "missing required fields" end - def test_discover_raises_on_network_error + def test_discover_raises_api_error_on_non_2xx stub_request(:get, "https://launchpad.37signals.com/.well-known/oauth-authorization-server") .to_return(status: 500, body: "Internal Server Error") error = assert_raises(Basecamp::Oauth::OauthError) do Basecamp::Oauth.discover_launchpad end - assert_equal "network", error.type + # Non-2xx is a server-side api_error, not a transport (network) error. + assert_equal "api_error", error.type + assert_equal 500, error.http_status + end + + def test_discover_rejects_wrong_typed_endpoint + # A non-string endpoint is malformed metadata: reject it rather than trusting + # a truthy value. + discovery_response = { + "issuer" => "https://launchpad.37signals.com", + "token_endpoint" => "https://launchpad.37signals.com/token", + "device_authorization_endpoint" => 42 + } + stub_request(:get, "https://launchpad.37signals.com/.well-known/oauth-authorization-server") + .to_return(status: 200, body: discovery_response.to_json, headers: { "Content-Type" => "application/json" }) + + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth.discover_launchpad + end + assert_equal "api_error", error.type + assert_includes error.message, "device_authorization_endpoint" + end + + def test_discover_rejects_scopes_supported_not_array_of_strings + discovery_response = { + "issuer" => "https://launchpad.37signals.com", + "token_endpoint" => "https://launchpad.37signals.com/token", + "scopes_supported" => "read write" # a bare string, not an array + } + stub_request(:get, "https://launchpad.37signals.com/.well-known/oauth-authorization-server") + .to_return(status: 200, body: discovery_response.to_json, headers: { "Content-Type" => "application/json" }) + + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth.discover_launchpad + end + assert_equal "api_error", error.type + assert_includes error.message, "scopes_supported" + end + + def test_discover_protected_resource_rejects_wrong_typed_resource + # A numeric resource must not be indexed/`.empty?`-probed as if it were a + # string; it is malformed metadata → api_error. + stub_request(:get, "https://api.example.com/.well-known/oauth-protected-resource") + .to_return(status: 200, body: { resource: 12_345 }.to_json, + headers: { "Content-Type" => "application/json" }) + + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth.discover_protected_resource("https://api.example.com") + end + assert_equal "api_error", error.type end def test_exchange_code @@ -175,4 +224,32 @@ def test_config_struct assert_equal "https://example.com/token", config.token_endpoint assert_equal %w[read write], config.scopes_supported end + + # --- Issuer-mismatch classified by CLASS, never by message text ------------- + + def test_as_failure_error_classifies_binding_mismatch_by_class + # The structured marker raised by the RFC 8414 binding check is classified + # as issuer_mismatch by its CLASS. + marker = Basecamp::Oauth::Discovery::IssuerBindingError.new( + "OAuth issuer mismatch: metadata issuer \"x\" does not equal \"y\"" + ) + + error = Basecamp::Oauth.send(:as_failure_error, "https://bc5.example.test", marker) + + assert_instance_of Basecamp::Oauth::DiscoverySelectionError, error + assert_equal "issuer_mismatch", error.reason + end + + def test_as_failure_error_does_not_message_match_for_generic_failure + # A generic AS-fetch OauthError whose MESSAGE merely contains "issuer + # mismatch" must NOT be misclassified: the old substring match would have + # called this issuer_mismatch; class-based dispatch keeps it as_fetch_failed. + generic = Basecamp::Oauth::OauthError.new( + "api_error", "Server error mentioning issuer mismatch in passing" + ) + + error = Basecamp::Oauth.send(:as_failure_error, "https://bc5.example.test", generic) + + assert_equal "as_fetch_failed", error.reason + end end diff --git a/ruby/test/basecamp/same_origin_test.rb b/ruby/test/basecamp/same_origin_test.rb index 456c67e4e..7c619bd5a 100644 --- a/ruby/test/basecamp/same_origin_test.rb +++ b/ruby/test/basecamp/same_origin_test.rb @@ -112,12 +112,67 @@ def test_get_absolute_rejects_foreign_authorization_shaped_url assert_not_requested(:get, "https://evil.example/authorization.json") end - def test_launchpad_authorization_url_stays_in_lockstep - # get_absolute scopes its cross-origin allowance to Security's constant, while - # the (generated) AuthorizationService resolves the fallback to its own copy. - # If a regeneration ever changes the generated literal, this catches the drift - # before it silently breaks the legitimate Launchpad authorization call. - assert_equal Basecamp::Security::LAUNCHPAD_AUTHORIZATION_URL, - Basecamp::Services::AuthorizationService::LAUNCHPAD_AUTHORIZATION_URL + def test_get_absolute_no_longer_accepts_raw_trusted_origin + # The raw-string trusted-origin escape hatch is GONE: get_absolute takes no + # allow_origin parameter, so the classic attack + # get_absolute("https://evil.example/authorization.json", + # allow_origin: "https://evil.example") + # (same-origin, valid https, yet no discovery provenance) cannot even be + # expressed — it raises ArgumentError rather than leaking the token. + assert_raises(ArgumentError) do + @http.get_absolute( + "https://evil.example/authorization.json", + allow_origin: "https://evil.example" + ) + end + assert_not_requested(:get, "https://evil.example/authorization.json") + end + + def test_manually_constructed_config_cannot_egress_credentials_cross_origin + # Security regression. Basecamp::Oauth::Config is publicly constructible, so a + # caller can forge one pointing at an attacker origin. The pre-fix vector — a + # public method (get_from_selected_issuer) that credentialed whatever origin a + # caller-supplied Config named — has been REMOVED. No public API consumes a + # caller-supplied config/issuer/origin to authorize a credentialed request, so + # a forged issuer is never contacted. + forged = Basecamp::Oauth::Config.new( + issuer: "https://evil.example", + token_endpoint: "https://evil.example/token" + ) + evil = stub_request(:get, "https://evil.example/authorization.json") + + assert_not @http.respond_to?(:get_from_selected_issuer), + "the config-taking credentialed fetch must not exist" + # The sole credentialed-authorization fetch takes NO caller argument: its + # issuer is derived from discovery of the configured base URL, so a forged + # Config/origin can never reach it. + assert_equal 0, @http.method(:get_authorization_document).arity + assert_not_nil forged + assert_not_requested(evil) + end + + def test_get_authorization_document_credentials_only_the_discovered_issuer + # The sanctioned cross-origin path: resource-first discovery of the CONFIGURED + # base URL selects a distinct web issuer, and ONLY that discovered-and-validated + # issuer is credentialed at the fixed authorization.json path. A foreign origin + # is never contacted, and no caller supplies the issuer or the path. + base = @config.base_url + bc5 = "https://bc5.example.test" + stub_request(:get, "#{base}/.well-known/oauth-protected-resource") + .to_return(status: 200, + body: { resource: base, authorization_servers: [ bc5, "https://launchpad.37signals.com" ] }.to_json, + headers: { "Content-Type" => "application/json" }) + stub_request(:get, "#{bc5}/.well-known/oauth-authorization-server") + .to_return(status: 200, + body: { issuer: bc5, token_endpoint: "#{bc5}/oauth/token" }.to_json, + headers: { "Content-Type" => "application/json" }) + doc = stub_request(:get, "#{bc5}/authorization.json") + .with(headers: { "Authorization" => "Bearer #{access_token}" }) + .to_return(status: 200, body: "{}", headers: { "Content-Type" => "application/json" }) + + response = @http.get_authorization_document + assert_equal 200, response.status + assert_requested(doc) + assert_not_requested(:get, "https://evil.example/authorization.json") end end diff --git a/ruby/test/basecamp/security_test.rb b/ruby/test/basecamp/security_test.rb index 2f9d1f4e7..a7db26bb7 100644 --- a/ruby/test/basecamp/security_test.rb +++ b/ruby/test/basecamp/security_test.rb @@ -187,6 +187,59 @@ def test_nil_body end end +class SecurityRequireOriginRootTest < Minitest::Test + def test_accepts_https_origin_root + assert_equal "https://example.com", + Basecamp::Security.require_origin_root!("https://example.com") + end + + def test_accepts_and_preserves_valid_port + assert_equal "https://example.com:8443", + Basecamp::Security.require_origin_root!("https://example.com:8443") + end + + def test_accepts_bracketed_ipv6_localhost + # IPv6 brackets survive the URI parser (never a regex), so the localhost + # carve-out over plain HTTP applies. + assert_equal "http://[::1]:3000", + Basecamp::Security.require_origin_root!("http://[::1]:3000") + end + + def test_rejects_port_above_range + # URI.parse accepts a numerically out-of-range port; require_origin_root! + # must reject anything outside 1..65535 as a usage error. + error = assert_raises(Basecamp::UsageError) do + Basecamp::Security.require_origin_root!("https://example.com:99999") + end + assert_match(/port/, error.message) + end + + def test_rejects_port_zero + error = assert_raises(Basecamp::UsageError) do + Basecamp::Security.require_origin_root!("https://example.com:0") + end + assert_match(/port/, error.message) + end + + def test_rejects_path_beyond_root + assert_raises(Basecamp::UsageError) do + Basecamp::Security.require_origin_root!("https://example.com/foo") + end + end + + def test_rejects_query_and_fragment + assert_raises(Basecamp::UsageError) do + Basecamp::Security.require_origin_root!("https://example.com/?a=1") + end + end + + def test_rejects_non_https_non_localhost + assert_raises(Basecamp::UsageError) do + Basecamp::Security.require_origin_root!("http://example.com") + end + end +end + # ============================================================================= # HTTP Integration Tests # ============================================================================= diff --git a/ruby/test/basecamp/services/authorization_service_test.rb b/ruby/test/basecamp/services/authorization_service_test.rb index ff16bf587..21e59c08f 100644 --- a/ruby/test/basecamp/services/authorization_service_test.rb +++ b/ruby/test/basecamp/services/authorization_service_test.rb @@ -5,12 +5,18 @@ class AuthorizationServiceTest < Minitest::Test include TestHelper + RESOURCE_WELL_KNOWN = "/.well-known/oauth-protected-resource" + AS_WELL_KNOWN = "/.well-known/oauth-authorization-server" + BC5_ISSUER = "https://bc5.example.test" + def setup @client = create_client end - def test_get_authorization - stub_discovery_failure + # --- Soft fallback paths → Launchpad --------------------------------------- + + def test_get_authorization_falls_back_to_launchpad_when_resource_discovery_fails + stub_discovery_failure # 404 on the protected-resource well-known stub_launchpad_get("/authorization.json", response_body: sample_authorization) auth = @client.authorization.get @@ -20,34 +26,101 @@ def test_get_authorization assert_equal 12_345, auth["accounts"].first["id"] end - def test_authorization_returns_accounts - stub_discovery_failure + def test_get_authorization_falls_back_when_only_launchpad_advertised + stub_discovery_success # advertises only Launchpad → no_as_advertised stub_launchpad_get("/authorization.json", response_body: sample_authorization) auth = @client.authorization.get - accounts = auth["accounts"] - assert_equal "Test Account", accounts.first["name"] - assert_equal "bc3", accounts.first["product"] + assert_equal "Test Account", auth["accounts"].first["name"] + assert_equal "bc3", auth["accounts"].first["product"] end - def test_authorization_returns_identity - stub_discovery_failure - stub_launchpad_get("/authorization.json", response_body: sample_authorization) + # --- Happy path: a discovered (same-origin) issuer is used ----------------- + + def test_get_authorization_uses_discovered_issuer + # Resource metadata advertises exactly one non-Launchpad issuer (here the API + # host itself, so the authorization.json fetch stays same-origin), and its AS + # metadata binds by code-point. + stub_resource_metadata(authorization_servers: [ BASE_URL, LAUNCHPAD_URL ]) + stub_as_metadata(BASE_URL) + stub_get("/authorization.json", response_body: sample_authorization) auth = @client.authorization.get - identity = auth["identity"] - assert_equal "Test", identity["first_name"] - assert_equal "User", identity["last_name"] + assert_equal "Test", auth["identity"]["first_name"] + assert_not_requested :get, "#{LAUNCHPAD_URL}/authorization.json" end - def test_authorization_uses_discovered_endpoint - stub_discovery_success - stub_launchpad_get("/authorization.json", response_body: sample_authorization) + def test_get_authorization_uses_cross_origin_discovered_issuer + # The real BC5 topology: the selected issuer is a DISTINCT web origin from the + # configured API base. The authorization.json fetch is therefore cross-origin + # and must be permitted because discovery selected AND validated that issuer. + stub_resource_metadata(authorization_servers: [ BC5_ISSUER, LAUNCHPAD_URL ]) + stub_as_metadata(BC5_ISSUER) + bc5_auth = stub_request(:get, "#{BC5_ISSUER}/authorization.json") + .to_return(status: 200, body: sample_authorization.to_json, + headers: { "Content-Type" => "application/json" }) auth = @client.authorization.get assert_equal "test@example.com", auth["identity"]["email_address"] + assert_requested(bc5_auth) + assert_not_requested :get, "#{LAUNCHPAD_URL}/authorization.json" end + + # --- Hard failures after BC5 advertisement raise, never hit Launchpad ------ + + def test_as_metadata_500_after_bc5_advertised_raises_with_zero_launchpad_requests + stub_resource_metadata(authorization_servers: [ BC5_ISSUER, LAUNCHPAD_URL ]) + stub_request(:get, "#{BC5_ISSUER}#{AS_WELL_KNOWN}") + .to_return(status: 500, body: { error: "internal_server_error" }.to_json, + headers: { "Content-Type" => "application/json" }) + launchpad_auth = stub_launchpad_get("/authorization.json", response_body: sample_authorization) + launchpad_as = stub_request(:get, "#{LAUNCHPAD_URL}#{AS_WELL_KNOWN}") + + error = assert_raises(Basecamp::Oauth::DiscoverySelectionError) do + @client.authorization.get + end + assert_equal "as_fetch_failed", error.reason + assert_not_requested(launchpad_auth) + assert_not_requested(launchpad_as) + end + + def test_issuer_mismatch_after_bc5_advertised_raises_with_zero_launchpad_requests + stub_resource_metadata(authorization_servers: [ BC5_ISSUER, LAUNCHPAD_URL ]) + stub_request(:get, "#{BC5_ISSUER}#{AS_WELL_KNOWN}") + .to_return(status: 200, body: { + issuer: "https://impostor.example.com", + authorization_endpoint: "#{BC5_ISSUER}/oauth/authorize", + token_endpoint: "#{BC5_ISSUER}/oauth/token" + }.to_json, headers: { "Content-Type" => "application/json" }) + launchpad_auth = stub_launchpad_get("/authorization.json", response_body: sample_authorization) + launchpad_as = stub_request(:get, "#{LAUNCHPAD_URL}#{AS_WELL_KNOWN}") + + error = assert_raises(Basecamp::Oauth::DiscoverySelectionError) do + @client.authorization.get + end + assert_equal "issuer_mismatch", error.reason + assert_not_requested(launchpad_auth) + assert_not_requested(launchpad_as) + end + + private + + def stub_resource_metadata(authorization_servers:) + body = { resource: BASE_URL, authorization_servers: authorization_servers } + stub_request(:get, "#{BASE_URL}#{RESOURCE_WELL_KNOWN}") + .to_return(status: 200, body: body.to_json, headers: { "Content-Type" => "application/json" }) + end + + def stub_as_metadata(issuer) + body = { + issuer: issuer, + authorization_endpoint: "#{issuer}/oauth/authorize", + token_endpoint: "#{issuer}/oauth/token" + } + stub_request(:get, "#{issuer}#{AS_WELL_KNOWN}") + .to_return(status: 200, body: body.to_json, headers: { "Content-Type" => "application/json" }) + end end diff --git a/ruby/test/test_helper.rb b/ruby/test/test_helper.rb index 8b510ce31..22fa4c100 100644 --- a/ruby/test/test_helper.rb +++ b/ruby/test/test_helper.rb @@ -106,23 +106,24 @@ def stub_launchpad_get(path, response_body:, status: 200, headers: {}) ) end - # Stub OAuth discovery to fail (triggers fallback to launchpad) + # Stub resource-first discovery hop 1 to fail (404 on the protected-resource + # well-known), yielding a soft resource_discovery_failed → Launchpad fallback. def stub_discovery_failure - stub_request(:get, "#{BASE_URL}/.well-known/oauth-authorization-server") + stub_request(:get, "#{BASE_URL}/.well-known/oauth-protected-resource") .to_return(status: 404, body: "Not Found") end - # Stub OAuth discovery to succeed with launchpad config + # Stub resource-first discovery hop 1 to advertise only Launchpad, yielding a + # soft no_as_advertised → Launchpad fallback. def stub_discovery_success - discovery_response = { - issuer: LAUNCHPAD_URL, - authorization_endpoint: "#{LAUNCHPAD_URL}/authorization/new", - token_endpoint: "#{LAUNCHPAD_URL}/authorization/token" + resource_metadata = { + resource: BASE_URL, + authorization_servers: [ LAUNCHPAD_URL ] } - stub_request(:get, "#{BASE_URL}/.well-known/oauth-authorization-server") + stub_request(:get, "#{BASE_URL}/.well-known/oauth-protected-resource") .to_return( status: 200, - body: discovery_response.to_json, + body: resource_metadata.to_json, headers: { "Content-Type" => "application/json" } ) end From 7e63ff0add26acacb24bf8f908894914d7707f5c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 14 Jul 2026 17:14:19 -0700 Subject: [PATCH 06/38] Kotlin: resource-first OAuth discovery + issuer binding + SSRF + shared URL helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Ask #1 for the Kotlin SDK (SPEC.md §16), mirroring the TS reference: - Extracted parseAbsoluteUrl + isLocalhost from Pagination.kt into a shared commonMain Urls.kt, adding requireSecureEndpoint + requireOriginRoot; reused in Pagination, discovery, and the (previously TLS-unguarded) token POST in Exchange.kt. Pagination behavior unchanged. - discoverProtectedResource (RFC 9728) + discoverFromResource orchestrator alongside discover (RFC 8414), now with code-point issuer binding and the origin-root profile. Wraps SerializationException/MissingFieldException as BasecampException.Api instead of letting them propagate unwrapped. - Fixes empty-string endpoint acceptance. OAuthConfig.authorizationEndpoint is now String? = null; added deviceAuthorizationEndpoint / grantTypesSupported. ProtectedResourceMetadata.authorizationServers is List? (null vs [] distinct). - SSRF (Kotlin had none): HttpClient wrapper with followRedirects = false + bounded HttpTimeout, and a streaming bodyAsChannel read that cancels once the 1 MiB cap is exceeded (bounded memory). Non-2xx → api_error. - sealed DiscoveryResult (Selected | FallBack) + FallbackReason enum (2 values) for soft cases; new BasecampException.DiscoverySelection(reason) for hard cases. Updated the exhaustive when in ErrorTest.kt + the BasecampException KDoc example so the project still compiles. - New OAuthDiscoveryTest.kt (23 tests) mirrors the shared conformance/oauth fixtures against a MockEngine and asserts zero Launchpad requests on hard failures. README documents the new model. make kt-check green (kt-check-drift clean). --- kotlin/README.md | 39 +- .../com/basecamp/sdk/BasecampException.kt | 27 + .../kotlin/com/basecamp/sdk/Pagination.kt | 40 +- .../kotlin/com/basecamp/sdk/Urls.kt | 121 ++++ .../com/basecamp/sdk/oauth/Discovery.kt | 504 ++++++++++++++- .../kotlin/com/basecamp/sdk/oauth/Exchange.kt | 4 + .../kotlin/com/basecamp/sdk/ErrorTest.kt | 2 + .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 604 ++++++++++++++++++ 8 files changed, 1274 insertions(+), 67 deletions(-) create mode 100644 kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt create mode 100644 kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt diff --git a/kotlin/README.md b/kotlin/README.md index c42d230e5..0171c2dbc 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -107,6 +107,38 @@ val client = BasecampClient { The SDK includes full OAuth 2.0 support with PKCE for Basecamp's Launchpad identity provider. +### Discovery + +Three composable operations follow the resource-first model (RFC 9728 + RFC 8414): + +```kotlin +import com.basecamp.sdk.oauth.* + +// Direct RFC 8414 Authorization Server metadata (issuer bound by code-point). +val config = discover("https://launchpad.37signals.com") + +// Resource-first discovery: start from the API/resource host, select the +// advertised authorization server, and fall back to Launchpad when appropriate. +when (val result = discoverFromResource("https://3.basecampapi.com")) { + is DiscoveryResult.Selected -> useConfig(result.config) // a BC5 AS was committed + is DiscoveryResult.FallBack -> useConfig(discoverLaunchpad()) // reason: resource_discovery_failed | no_as_advertised +} +``` + +`discoverFromResource` returns a `DiscoveryResult` for the two soft outcomes and +**throws** `BasecampException.DiscoverySelection` for every hard failure (e.g. +`ambiguous_issuers`, `issuer_mismatch`, `as_fetch_failed`) once a BC5 issuer is +committed — a hard failure is never converted into a Launchpad request. Pass an +`expectedIssuer` to select authoritatively instead of by exclusion. + +All discovery fetches are SSRF-hardened: HTTPS-only origins (localhost exempt), +origin validated with the transport URL parser before any socket opens, redirects +suppressed, timeouts bounded, and response bodies read under a bounded cap. + +`OAuthConfig.authorizationEndpoint` is **nullable** (`String?`): device-only +authorization servers omit it, so authorization-code consumers must assert its +presence before use (`token_endpoint` is always present). + ### Authorization Flow ```kotlin @@ -120,9 +152,12 @@ val pkce = generatePkce() val state = generateState() // Store pkce.verifier and state in session -// 3. Build authorization URL +// 3. Build authorization URL (authorizationEndpoint is nullable; assert presence) +val authorizationEndpoint = requireNotNull(config.authorizationEndpoint) { + "Authorization server does not support the authorization-code flow" +} val authUrl = buildString { - append(config.authorizationEndpoint) + append(authorizationEndpoint) append("?type=web_server") append("&client_id=$CLIENT_ID") append("&redirect_uri=$REDIRECT_URI") diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt index 6c9c65141..387bd3a19 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt @@ -17,6 +17,7 @@ package com.basecamp.sdk * is BasecampException.Network -> println("Network error") * is BasecampException.Api -> println("Server error: ${e.httpStatus}") * is BasecampException.Usage -> println("Bad arguments: ${e.message}") + * is BasecampException.DiscoverySelection -> println("OAuth discovery: ${e.reason}") * } * } * ``` @@ -114,6 +115,32 @@ sealed class BasecampException( hint: String? = null, ) : BasecampException(message, CODE_USAGE, hint) + /** + * Hard resource-first OAuth discovery selection/validation failure + * (SPEC.md §16). THROWN, never returned as a Launchpad fallback, so no + * consumer can convert it into a Launchpad request. [reason] carries the + * typed failure token (e.g. `ambiguous_issuers`, `issuer_mismatch`). + * + * Its [code] is `validation` for consumer/capability-shaped reasons + * (`capability_unavailable`) and `api_error` for advertised-metadata faults, + * mirroring the TypeScript reference. + */ + class DiscoverySelection( + /** Typed selection failure token; see SPEC.md §16 fallback table. */ + val reason: String, + message: String, + hint: String? = null, + cause: Throwable? = null, + ) : BasecampException( + message, + if (reason == "capability_unavailable") CODE_VALIDATION else CODE_API, + hint, + null, + false, + null, + cause, + ) + companion object { const val CODE_AUTH = "auth_required" const val CODE_FORBIDDEN = "forbidden" diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Pagination.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Pagination.kt index 8cf0f7e56..d0ea86ecb 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Pagination.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Pagination.kt @@ -1,8 +1,5 @@ package com.basecamp.sdk -import io.ktor.http.Url -import io.ktor.http.parseUrl - /** * Metadata about a paginated list response. */ @@ -98,40 +95,9 @@ internal fun isSameOrigin(url1: String, url2: String): Boolean { a.port == b.port } -/** - * Parses an absolute URL with Ktor's own parser — the SAME parser the - * transport uses to dial — so the guard can never disagree with the client - * about which host a URL targets. A hand-rolled parser here previously let - * `http://evil.example\.localhost/x` pass the localhost carve-out while Ktor - * treats `\` as a path separator and dials `evil.example`. - * - * Returns null (fail closed) when the input is malformed or not absolute: - * Ktor parses a scheme-less string as a relative reference against - * `http://localhost`, which must never be blessed as localhost/same-origin. - */ -private fun parseAbsoluteUrl(url: String): Url? { - val parsed = parseUrl(url) ?: return null - if (!url.startsWith("${parsed.protocol.name}://", ignoreCase = true)) return null - return parsed -} - -/** Returns true if the URL points to localhost over HTTP(S) (for dev/test). */ -internal fun isLocalhost(url: String): Boolean { - val parsed = parseAbsoluteUrl(url) ?: return false - // The carve-out is limited to HTTP(S) so the credential backstop fails - // closed on any other scheme (e.g. ws://localhost). - when (parsed.protocol.name) { - "http", "https" -> {} - else -> return false - } - // Hostnames are case-insensitive (RFC 3986). Ktor's Url.host excludes - // userinfo and port; strip IPv6 brackets in case the engine retains them. - val host = parsed.host.lowercase().removePrefix("[").removeSuffix("]") - return host == "localhost" || - host == "127.0.0.1" || - host == "::1" || - host.endsWith(".localhost") // RFC 6761 .localhost TLD -} +// parseAbsoluteUrl and isLocalhost now live in Urls.kt alongside the shared +// origin-root / secure-endpoint guards; they remain in this same package so +// isSameOrigin below calls them unqualified. /** * Parses the Retry-After header value. diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt new file mode 100644 index 000000000..7e6a15231 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt @@ -0,0 +1,121 @@ +package com.basecamp.sdk + +import io.ktor.http.Url +import io.ktor.http.parseUrl + +/** + * Shared URL helpers used by pagination guards, OAuth discovery, and the token + * POST. Centralizing them keeps every SSRF/same-origin decision on the SAME + * parser the transport dials with (Ktor's [parseUrl]) rather than a hand-rolled + * regex that could disagree about the host actually contacted. + */ + +/** + * Parses an absolute URL with Ktor's own parser — the SAME parser the + * transport uses to dial — so a guard can never disagree with the client + * about which host a URL targets. A hand-rolled parser here previously let + * `http://evil.example\.localhost/x` pass the localhost carve-out while Ktor + * treats `\` as a path separator and dials `evil.example`. + * + * Returns null (fail closed) when the input is malformed or not absolute: + * Ktor parses a scheme-less string as a relative reference against + * `http://localhost`, which must never be blessed as localhost/same-origin. + */ +internal fun parseAbsoluteUrl(url: String): Url? { + val parsed = parseUrl(url) ?: return null + if (!url.startsWith("${parsed.protocol.name}://", ignoreCase = true)) return null + return parsed +} + +/** + * True when a bare host (no scheme/port) denotes loopback: `localhost`, + * `127.0.0.1`, `::1`, or the RFC 6761 `.localhost` TLD. Case-insensitive; + * tolerates bracketed IPv6 literals (`[::1]`) that some parsers retain. + */ +internal fun isLocalhostHost(host: String): Boolean { + val h = host.lowercase().removePrefix("[").removeSuffix("]") + return h == "localhost" || + h == "127.0.0.1" || + h == "::1" || + h.endsWith(".localhost") +} + +/** Returns true if the URL points to localhost over HTTP(S) (for dev/test). */ +internal fun isLocalhost(url: String): Boolean { + val parsed = parseAbsoluteUrl(url) ?: return false + // The carve-out is limited to HTTP(S) so the credential backstop fails + // closed on any other scheme (e.g. ws://localhost). + when (parsed.protocol.name) { + "http", "https" -> {} + else -> return false + } + return isLocalhostHost(parsed.host) +} + +/** + * Validates that an endpoint URL is secure: HTTPS everywhere, with plain HTTP + * permitted only for localhost (RFC 6761) during local development. Any other + * scheme is rejected. Used to guard caller-supplied token/authorization + * endpoints before credentials are attached. + * + * @throws BasecampException.Validation if the URL is malformed or insecure. + */ +internal fun requireSecureEndpoint(url: String, label: String) { + val parsed = parseAbsoluteUrl(url) + ?: throw BasecampException.Validation("Invalid $label: ${BasecampException.truncateMessage(url)}") + val isLocalhostHttp = parsed.protocol.name == "http" && isLocalhostHost(parsed.host) + if (parsed.protocol.name != "https" && !isLocalhostHttp) { + throw BasecampException.Validation("$label must use HTTPS: ${BasecampException.truncateMessage(url)}") + } +} + +/** + * Parses a caller- or metadata-supplied origin and enforces the origin-root + * profile (SPEC.md §16): https (or http on localhost), host present, a valid or + * absent port, path empty or exactly "/", and no query/fragment/userinfo. Uses + * [parseAbsoluteUrl] (never a regex) so bracketed IPv6 (`http://[::1]:3000`) and + * ports agree with the host the client actually dials. + * + * Throws [BasecampException.Usage] on violation — a bad *caller* origin is a + * usage error. Callers validating an *advertised* origin catch and reclassify. + * + * @return the normalized origin (scheme://host[:port], no trailing slash). + */ +internal fun requireOriginRoot(raw: String, label: String = "origin"): String { + val url = parseAbsoluteUrl(raw) + ?: throw BasecampException.Usage("Invalid $label: not a valid absolute URL: ${BasecampException.truncateMessage(raw)}") + + val scheme = url.protocol.name + val isLocalhostHttp = scheme == "http" && isLocalhostHost(url.host) + if (scheme != "https" && !isLocalhostHttp) { + throw BasecampException.Usage("$label must use HTTPS (or http on localhost): ${BasecampException.truncateMessage(raw)}") + } + if (url.host.isEmpty()) { + throw BasecampException.Usage("$label has no host: ${BasecampException.truncateMessage(raw)}") + } + // Reject ANY userinfo, including an empty one (e.g. `https://@host`): Ktor's + // Url.user/Url.password are null for empty userinfo, so inspect the raw + // authority substring for an '@' rather than trusting the parsed fields. + val authority = raw.substringAfter("://", "") + .substringBefore('/') + .substringBefore('?') + .substringBefore('#') + if (authority.contains('@') || !url.user.isNullOrEmpty() || !url.password.isNullOrEmpty()) { + throw BasecampException.Usage("$label must not contain userinfo: ${BasecampException.truncateMessage(raw)}") + } + if (url.encodedQuery.isNotEmpty() || url.encodedFragment.isNotEmpty()) { + throw BasecampException.Usage("$label must not contain a query or fragment: ${BasecampException.truncateMessage(raw)}") + } + val path = url.encodedPath + if (path.isNotEmpty() && path != "/") { + throw BasecampException.Usage("$label must be an origin root (no path): ${BasecampException.truncateMessage(raw)}") + } + + // parseAbsoluteUrl fails closed on a malformed port, so a surviving url has a + // structurally valid (possibly default) port. Rebuild the origin explicitly, + // re-bracketing IPv6 literals and dropping a default port. + val host = url.host + val hostForOrigin = if (host.contains(':') && !host.startsWith("[")) "[$host]" else host + val portPart = if (url.port != url.protocol.defaultPort) ":${url.port}" else "" + return "$scheme://$hostForOrigin$portPart" +} diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index 3c2e0f294..b67822b98 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -1,69 +1,517 @@ package com.basecamp.sdk.oauth -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* +import com.basecamp.sdk.BasecampException +import com.basecamp.sdk.isSameOrigin +import com.basecamp.sdk.requireOriginRoot +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.accept +import io.ktor.client.request.prepareGet +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.CancellationException import kotlinx.serialization.SerialName +import kotlinx.serialization.SerializationException import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import com.basecamp.sdk.BasecampException +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject + +/** + * OAuth 2.0 discovery for the Basecamp SDK. + * + * Two composable operations plus an orchestrator (SPEC.md §16, "Resource-First + * Discovery"): + * - [discover] — RFC 8414 AS metadata + issuer binding. + * - [discoverProtectedResource] — RFC 9728 resource metadata. + * - [discoverFromResource] — resource-first selection + stage-sensitive fallback. + * + * All fetches are SSRF-hardened: HTTPS-only origins (localhost exempt), origin + * parsed/validated with the transport URL parser before any socket opens, + * redirects suppressed ([HttpClient.followRedirects] = false), timeouts bounded, + * and bodies read under a bounded/streaming cap that aborts before an oversized + * body is fully buffered. + */ /** - * OAuth server configuration from the discovery endpoint. + * OAuth 2.0 server configuration from an RFC 8414 discovery endpoint. + * + * As of BC5 resource-first discovery, [authorizationEndpoint] is OPTIONAL: + * device-only authorization servers omit it, so authorization-code consumers + * MUST assert its presence before use. [tokenEndpoint] remains required. */ @Serializable data class OAuthConfig( val issuer: String, - @SerialName("authorization_endpoint") val authorizationEndpoint: String, + @SerialName("authorization_endpoint") val authorizationEndpoint: String? = null, @SerialName("token_endpoint") val tokenEndpoint: String, + @SerialName("device_authorization_endpoint") val deviceAuthorizationEndpoint: String? = null, @SerialName("registration_endpoint") val registrationEndpoint: String? = null, @SerialName("scopes_supported") val scopesSupported: List = emptyList(), + @SerialName("grant_types_supported") val grantTypesSupported: List = emptyList(), ) -/** Basecamp's Launchpad OAuth server URL. */ +/** + * RFC 9728 protected-resource metadata (hop 1 of resource-first discovery). + * + * [authorizationServers] is a nullable list so "key absent" (`null`, BC5's dark + * posture) and "present but empty" (`[]`) stay distinguishable at the type + * level, even though both select Launchpad. + */ +data class ProtectedResourceMetadata( + val resource: String, + val authorizationServers: List? = null, +) + +/** + * Soft fallback reasons — the ONLY two outcomes under which + * [discoverFromResource] yields a [DiscoveryResult.FallBack] (Launchpad) rather + * than a selected config. Every other failure raises + * [BasecampException.DiscoverySelection]. + */ +enum class FallbackReason(val code: String) { + RESOURCE_DISCOVERY_FAILED("resource_discovery_failed"), + NO_AS_ADVERTISED("no_as_advertised"), +} + +/** + * Result of [discoverFromResource]: either a selected AS config, or a soft + * fallback to Launchpad. Hard failures are thrown + * ([BasecampException.DiscoverySelection]), never represented here. + */ +sealed interface DiscoveryResult { + /** A BC5 authorization server was selected and its metadata bound. */ + data class Selected(val config: OAuthConfig, val issuer: String) : DiscoveryResult + + /** No BC5 AS was committed; the caller should fall back to Launchpad. */ + data class FallBack(val reason: FallbackReason) : DiscoveryResult +} + +/** Basecamp's Launchpad OAuth server URL (the fallback authorization server). */ const val LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com" +/** Cap on a discovery response body (1 MiB) — discovery docs are tiny. */ +private const val MAX_DISCOVERY_BODY_BYTES = 1L * 1024 * 1024 + +/** Bounded timeout for a single discovery fetch. */ +private const val DISCOVERY_TIMEOUT_MS = 10_000L + private val discoveryJson = Json { ignoreUnknownKeys = true } +/** Raw AS metadata (RFC 8414). All fields nullable to validate manually. */ +@Serializable +private data class RawDiscoveryResponse( + val issuer: String? = null, + @SerialName("authorization_endpoint") val authorizationEndpoint: String? = null, + @SerialName("token_endpoint") val tokenEndpoint: String? = null, + @SerialName("device_authorization_endpoint") val deviceAuthorizationEndpoint: String? = null, + @SerialName("registration_endpoint") val registrationEndpoint: String? = null, + @SerialName("scopes_supported") val scopesSupported: List = emptyList(), + @SerialName("grant_types_supported") val grantTypesSupported: List = emptyList(), +) + +/** Raw resource metadata (RFC 9728). */ +@Serializable +private data class RawResourceResponse( + val resource: String? = null, + // Nullable so absent (null) and present-empty ([]) stay distinct. + @SerialName("authorization_servers") val authorizationServers: List? = null, +) + /** - * Fetches OAuth server metadata from the well-known discovery endpoint. + * Discovers OAuth 2.0 Authorization Server Metadata (RFC 8414) from + * `{baseUrl}/.well-known/oauth-authorization-server`, and binds it: the returned + * `issuer` MUST equal the requested issuer origin by code-point (no + * normalization beyond origin-root parsing). `token_endpoint` is required; + * `authorization_endpoint` is optional (device-only servers omit it); any + * present `*_endpoint` must be non-empty. * * ```kotlin * val config = discover("https://launchpad.37signals.com") - * val authUrl = config.authorizationEndpoint + * val tokenUrl = config.tokenEndpoint * ``` * - * @param baseUrl Base URL of the OAuth server. - * @param client Optional HTTP client (a default one is created if not provided). + * @param baseUrl The OAuth server's issuer origin. + * @param client Optional HTTP client (a hardened one is created if not provided). + * @throws BasecampException.Usage on a malformed origin. + * @throws BasecampException.Api on invalid/mismatched metadata. + * @throws BasecampException.Network on transport failure or timeout. */ -suspend fun discover(baseUrl: String, client: HttpClient? = null): OAuthConfig { - val url = "${baseUrl.trimEnd('/')}/.well-known/oauth-authorization-server" +suspend fun discover(baseUrl: String, client: HttpClient? = null): OAuthConfig = + try { + fetchAndBindAsMetadata(baseUrl, client) + } catch (marker: IssuerBindingException) { + // The binding failure is signalled internally by a module-private marker + // so discoverFromResource can classify it by type. To external callers it + // MUST surface as an ordinary api_error — exactly like any other invalid + // AS metadata — never as this private type. + throw BasecampException.Api( + marker.message ?: "OAuth issuer mismatch", + httpStatus = 200, + cause = marker, + ) + } - val httpClient = client ?: HttpClient() - val shouldClose = client == null +/** + * Fetches and binds RFC 8414 AS metadata, letting the module-private + * [IssuerBindingException] escape on an issuer code-point mismatch. [discover] + * wraps this to convert the marker into a public [BasecampException.Api], while + * [discoverFromResource] calls it directly so it can branch on the marker type + * to classify `issuer_mismatch` vs `as_fetch_failed`. + */ +private suspend fun fetchAndBindAsMetadata(baseUrl: String, client: HttpClient?): OAuthConfig { + val issuerOrigin = requireOriginRoot(baseUrl, "OAuth discovery base URL") + val url = "$issuerOrigin/.well-known/oauth-authorization-server" - try { - val response = httpClient.get(url) { - accept(ContentType.Application.Json) - } + val body = fetchDiscoveryDocument(url, client) + // Inspect the raw JSON before decoding so an endpoint key present with an + // explicit JSON `null` is rejected as invalid metadata rather than collapsed to + // the same absent-key state a nullable Kotlin field cannot distinguish. + val obj = try { + discoveryJson.parseToJsonElement(body) as? JsonObject + } catch (e: SerializationException) { + throw BasecampException.Api("Failed to parse OAuth discovery response", httpStatus = 200, cause = e) + } ?: throw BasecampException.Api("Failed to parse OAuth discovery response", httpStatus = 200) + rejectExplicitNullEndpoints(obj) + val raw = try { + discoveryJson.decodeFromString(body) + } catch (e: SerializationException) { + throw BasecampException.Api("Failed to parse OAuth discovery response", httpStatus = 200, cause = e) + } + return bindAsMetadata(raw, issuerOrigin) +} - if (!response.status.isSuccess()) { +/** + * Rejects any `*_endpoint` key present with an explicit JSON `null`. A nullable + * Kotlin field collapses "key omitted" (valid: endpoint absent) and "key present + * but `null`" (invalid metadata) to the same `null`, so the distinction is + * enforced at the JSON layer. Present-but-empty strings are rejected downstream in + * [bindAsMetadata]; non-string values fail the structural decode. + */ +private fun rejectExplicitNullEndpoints(obj: JsonObject) { + for (key in listOf( + "authorization_endpoint", + "token_endpoint", + "device_authorization_endpoint", + "registration_endpoint", + )) { + if (obj[key] is JsonNull) { throw BasecampException.Api( - "OAuth discovery failed: HTTP ${response.status.value}", - httpStatus = response.status.value, + "Invalid OAuth discovery response: $key must not be null", + httpStatus = 200, ) } + } +} + +/** + * Module-private structural marker for an RFC 8414 issuer-binding failure: the AS + * metadata's `issuer` did not equal the requested issuer origin by code-point. + * + * [discoverFromResource] branches on this by type to classify `issuer_mismatch` + * vs `as_fetch_failed` — a structured tag, never a match on the message text. + * Kept private to this module and deliberately NOT a [BasecampException] subtype + * (that sealed type lives in another package, and device flow shares its file): + * [discover] converts it to a plain [BasecampException.Api] before it can escape, + * so to any external caller a binding failure is an ordinary api_error. + */ +private class IssuerBindingException(message: String) : Exception(message) + +/** + * Validates AS metadata and binds `issuer` to [expectedIssuerOrigin] by + * code-point. Universal validation only: `issuer` + `token_endpoint` present and + * non-empty; any present endpoint field non-empty. Per-grant endpoint checks are + * the consumer's responsibility. + */ +private fun bindAsMetadata(raw: RawDiscoveryResponse, expectedIssuerOrigin: String): OAuthConfig { + val issuer = raw.issuer + if (issuer.isNullOrEmpty()) { + throw BasecampException.Api( + "Invalid OAuth discovery response: missing required fields (issuer)", + httpStatus = 200, + ) + } + // RFC 8414 §3.3/§4: issuer identical by code-point. No normalization. Raised + // as the structural IssuerBindingException so discoverFromResource classifies + // it by type without matching the message text. + if (issuer != expectedIssuerOrigin) { + throw IssuerBindingException( + "OAuth issuer mismatch: metadata issuer \"$issuer\" does not equal \"$expectedIssuerOrigin\"", + ) + } + val token = raw.tokenEndpoint + if (token.isNullOrEmpty()) { + throw BasecampException.Api( + "Invalid OAuth discovery response: missing required fields (token_endpoint)", + httpStatus = 200, + ) + } + // Reject present-but-empty endpoint strings. + for ((name, value) in listOf( + "authorization_endpoint" to raw.authorizationEndpoint, + "device_authorization_endpoint" to raw.deviceAuthorizationEndpoint, + "registration_endpoint" to raw.registrationEndpoint, + )) { + if (value != null && value.isEmpty()) { + throw BasecampException.Api("Invalid OAuth discovery response: empty $name", httpStatus = 200) + } + } - val body = response.bodyAsText() - return discoveryJson.decodeFromString(body) - } finally { - if (shouldClose) httpClient.close() + return OAuthConfig( + issuer = issuer, + authorizationEndpoint = raw.authorizationEndpoint, + tokenEndpoint = token, + deviceAuthorizationEndpoint = raw.deviceAuthorizationEndpoint, + registrationEndpoint = raw.registrationEndpoint, + scopesSupported = raw.scopesSupported, + grantTypesSupported = raw.grantTypesSupported, + ) +} + +/** + * Discovers RFC 9728 protected-resource metadata from + * `{resourceOrigin}/.well-known/oauth-protected-resource`. `resource` is + * required and MUST equal the requested origin by code-point. + * `authorization_servers` is preserved distinctly as absent (`null`) vs `[]`. + * + * @throws BasecampException.Usage on a malformed caller origin. + * @throws BasecampException.Api on invalid/mismatched metadata. + * @throws BasecampException.Network on transport failure or timeout. + */ +suspend fun discoverProtectedResource( + resourceOrigin: String, + client: HttpClient? = null, +): ProtectedResourceMetadata { + val origin = requireOriginRoot(resourceOrigin, "resource origin") + val url = "$origin/.well-known/oauth-protected-resource" + + val body = fetchDiscoveryDocument(url, client) + val raw = try { + discoveryJson.decodeFromString(body) + } catch (e: SerializationException) { + throw BasecampException.Api("Failed to parse resource metadata response", httpStatus = 200, cause = e) + } + + val resource = raw.resource + if (resource.isNullOrEmpty()) { + throw BasecampException.Api( + "Invalid resource metadata: missing required field (resource)", + httpStatus = 200, + ) + } + // Bind resource identifier to the requested origin, code-point exact. + if (resource != origin) { + throw BasecampException.Api( + "Resource identifier mismatch: metadata resource \"$resource\" does not equal \"$origin\"", + httpStatus = 200, + ) + } + + return ProtectedResourceMetadata(resource = resource, authorizationServers = raw.authorizationServers) +} + +/** + * Resource-first discovery orchestrator (SPEC.md §16). Composes RFC 9728 + RFC + * 8414 and applies the stage-sensitive fallback state machine. + * + * Returns [DiscoveryResult.Selected] or [DiscoveryResult.FallBack] where the + * reason is one of [FallbackReason]'s two values ONLY. Every hard failure throws + * [BasecampException.DiscoverySelection] — callers MUST NOT convert a throw into + * a Launchpad request. ("BC5 committed" = valid resource metadata advertised a + * BC5 issuer that was then selected; afterward every failure is fatal.) + * + * @param resourceOrigin Caller-supplied API/resource origin (hop 1). + * @param expectedIssuer Optional authoritative issuer selection. When provided, + * the advertised member equal by code-point is selected; if none matches, + * `expected_issuer_unavailable` is raised (never falls back). Omit to use the + * exactly-one-non-Launchpad exclusion heuristic. + * @param client Optional HTTP client (a hardened one is created if not provided). + * @throws BasecampException.Usage on a malformed caller origin. + * @throws BasecampException.DiscoverySelection on any hard selection failure. + */ +suspend fun discoverFromResource( + resourceOrigin: String, + expectedIssuer: String? = null, + client: HttpClient? = null, +): DiscoveryResult { + // Origin-root validation of the *caller's* input is a usage error — let it + // propagate as-is (not a soft fallback). + val origin = requireOriginRoot(resourceOrigin, "resource origin") + + // --- Hop 1: resource metadata. Failure here is soft (before selection). --- + val resource: ProtectedResourceMetadata = try { + discoverProtectedResource(origin, client) + } catch (e: BasecampException.Usage) { + throw e + } catch (e: BasecampException) { + return DiscoveryResult.FallBack(FallbackReason.RESOURCE_DISCOVERY_FAILED) + } + + val advertised = resource.authorizationServers ?: emptyList() + + // --- Selection --- + val selectedIssuer: String + if (expectedIssuer != null) { + selectedIssuer = advertised.firstOrNull { it == expectedIssuer } + ?: throw BasecampException.DiscoverySelection( + "expected_issuer_unavailable", + "Expected issuer \"$expectedIssuer\" is not advertised by the resource", + ) + } else { + val nonLaunchpad = advertised.filterNot { isLaunchpadIssuer(it) } + when { + nonLaunchpad.size >= 2 -> throw BasecampException.DiscoverySelection( + "ambiguous_issuers", + "Multiple non-Launchpad issuers advertised; pass expectedIssuer to disambiguate: " + + nonLaunchpad.joinToString(", "), + ) + // Valid resource metadata omits BC5 — soft fallback (before selection). + nonLaunchpad.isEmpty() -> return DiscoveryResult.FallBack(FallbackReason.NO_AS_ADVERTISED) + else -> selectedIssuer = nonLaunchpad[0] + } } + + // --- BC5 is now committed: every subsequent failure is fatal (no Launchpad). --- + val issuerOrigin = try { + requireOriginRoot(selectedIssuer, "advertised issuer") + } catch (e: BasecampException) { + throw BasecampException.DiscoverySelection( + "invalid_issuer_origin", + "Advertised issuer \"$selectedIssuer\" is not a valid origin root", + cause = e, + ) + } + + val config = try { + // Call the binding path directly (not the public discover, which converts + // the marker to api_error) so the structural marker reaches the branch. + fetchAndBindAsMetadata(issuerOrigin, client) + } catch (e: IssuerBindingException) { + // Structured marker — never the message text — decides issuer_mismatch. + throw BasecampException.DiscoverySelection("issuer_mismatch", e.message!!, cause = e) + } catch (e: BasecampException) { + // Every other committed-AS fault (5xx, missing token_endpoint, parse + // failure, transport, …) is a generic fetch failure. + throw BasecampException.DiscoverySelection( + "as_fetch_failed", + "AS metadata fetch failed for committed issuer \"$issuerOrigin\": ${e.message}", + cause = e, + ) + } + + return DiscoveryResult.Selected(config, config.issuer) } /** - * Discovers OAuth configuration for Basecamp's Launchpad server. + * Discovers OAuth configuration from Basecamp's Launchpad server. */ suspend fun discoverLaunchpad(client: HttpClient? = null): OAuthConfig = discover(LAUNCHPAD_BASE_URL, client) + +/** + * True when an issuer string is a valid origin root equal to Launchpad's. + * + * Both sides run through [requireOriginRoot], so an advertised look-alike that + * is not a clean origin root — e.g. `https://launchpad.37signals.com/path` + * (path), userinfo, or a query — is not treated as Launchpad. It stays a + * non-Launchpad candidate and later fails hard (`ambiguous_issuers` / + * `invalid_issuer_origin`) rather than being silently excluded. A + * trailing-slash-only origin root still matches because [requireOriginRoot] + * normalizes it away. + */ +private fun isLaunchpadIssuer(issuer: String): Boolean = + try { + requireOriginRoot(issuer, "issuer") == requireOriginRoot(LAUNCHPAD_BASE_URL, "issuer") + } catch (e: BasecampException.Usage) { + false + } + +/** + * SSRF-hardened GET of a discovery document. The origin must already be + * validated (via [requireOriginRoot]). Suppresses redirects, bounds the timeout, + * reads the body under a bounded/streaming cap, and maps non-2xx → api_error. + * + * When [baseClient] is supplied its engine is reused but wrapped in a hardened + * client so redirect suppression and the timeout apply regardless; the borrowed + * engine is not closed by the wrapper (Ktor only closes engines it created). + */ +private suspend fun fetchDiscoveryDocument(url: String, baseClient: HttpClient?): String { + val engine = baseClient?.engine + val httpClient = if (engine != null) { + HttpClient(engine) { + followRedirects = false + expectSuccess = false + install(HttpTimeout) { requestTimeoutMillis = DISCOVERY_TIMEOUT_MS } + } + } else { + HttpClient { + followRedirects = false + expectSuccess = false + install(HttpTimeout) { requestTimeoutMillis = DISCOVERY_TIMEOUT_MS } + } + } + + try { + return httpClient.prepareGet(url) { + accept(ContentType.Application.Json) + }.execute { response -> + val status = response.status.value + if (status < 200 || status >= 300) { + // Non-2xx (including a suppressed 3xx) surfaces as api_error, not network. + throw BasecampException.Api("OAuth discovery failed: HTTP $status", httpStatus = status) + } + readBoundedText(response, MAX_DISCOVERY_BODY_BYTES) + } + } catch (e: CancellationException) { + // Coroutine cancellation must propagate untouched — never soft-fall-back + // to Launchpad by masquerading a cancelled fetch as a Network error. + throw e + } catch (e: BasecampException) { + throw e + } catch (e: Throwable) { + // Transport failure / timeout. + throw BasecampException.Network( + "OAuth discovery failed: ${e.message ?: e::class.simpleName}", + cause = e, + ) + } finally { + httpClient.close() + } +} + +/** + * Reads a response body under a bounded, streaming cap. Aborts (cancels the + * channel) the moment the accumulated size exceeds [maxBytes], so an oversized + * body is never fully buffered — real memory bounding, not a post-hoc check. + */ +internal suspend fun readBoundedText(response: HttpResponse, maxBytes: Long): String { + val channel = response.bodyAsChannel() + val chunks = ArrayList() + var total = 0L + val buffer = ByteArray(16 * 1024) + + while (true) { + val read = channel.readAvailable(buffer, 0, buffer.size) + if (read == -1) break + if (read == 0) continue + total += read + if (total > maxBytes) { + channel.cancel(null) + throw BasecampException.Api( + "OAuth response exceeds size cap", + httpStatus = response.status.value, + ) + } + chunks.add(buffer.copyOf(read)) + } + + val merged = ByteArray(total.toInt()) + var offset = 0 + for (chunk in chunks) { + chunk.copyInto(merged, offset) + offset += chunk.size + } + return merged.decodeToString() +} diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Exchange.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Exchange.kt index acaa23ebe..5761d6c0c 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Exchange.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Exchange.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import com.basecamp.sdk.BasecampException +import com.basecamp.sdk.requireSecureEndpoint import com.basecamp.sdk.http.currentTimeMillis /** @@ -148,6 +149,9 @@ private suspend fun postTokenRequest( params: Parameters, client: HttpClient?, ): OAuthToken { + // Never POST credentials over cleartext (localhost exempt for dev/test). + requireSecureEndpoint(endpoint, "token endpoint") + val httpClient = client ?: HttpClient() val shouldClose = client == null diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt index 1b09b8512..023eb5dfa 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt @@ -153,6 +153,7 @@ class ErrorTest { BasecampException.Ambiguous("project"), BasecampException.Validation("invalid"), BasecampException.Usage("bad arg"), + BasecampException.DiscoverySelection("ambiguous_issuers", "ambiguous"), ) for (e in errors) { @@ -167,6 +168,7 @@ class ErrorTest { is BasecampException.Ambiguous -> "ambiguous" is BasecampException.Validation -> "validation" is BasecampException.Usage -> "usage" + is BasecampException.DiscoverySelection -> "discovery_selection" } assertTrue(code.isNotEmpty()) } diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt new file mode 100644 index 000000000..006cdc34a --- /dev/null +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -0,0 +1,604 @@ +package com.basecamp.sdk + +import com.basecamp.sdk.oauth.DiscoveryResult +import com.basecamp.sdk.oauth.OAuthConfig +import com.basecamp.sdk.oauth.ProtectedResourceMetadata +import com.basecamp.sdk.oauth.discover +import com.basecamp.sdk.oauth.discoverFromResource +import com.basecamp.sdk.oauth.discoverProtectedResource +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondBadRequest +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.fail + +/** + * Resource-first OAuth discovery tests (SPEC.md §16). + * + * These scenarios MIRROR the shared, data-only fixtures in + * `conformance/oauth/fixtures` (same names, same expected outcomes). + * commonTest can't portably read repo files, so the fixtures are embedded as + * Kotlin data with the placeholder origins already substituted for this + * harness's mock hosts and driven against a Ktor [MockEngine]. Hard cases assert + * the Launchpad host received ZERO requests (`launchpadContacted == false`). + */ +class OAuthDiscoveryTest { + + // Mock origins substituted for the fixture {{...}} placeholders. LAUNCHPAD + // must be the real origin because the fallback target is Launchpad. + private val RESOURCE = "https://api.basecamp-test.example" + private val ISSUER = "https://issuer.basecamp-test.example" + private val LAUNCHPAD = "https://launchpad.37signals.com" + private val BC5 = "https://bc5.basecamp-test.example" + + private enum class Op { FROM_RESOURCE, PROTECTED_RESOURCE, DISCOVER } + + /** One mocked HTTP hop. */ + private data class Hop( + val status: Int = 200, + val body: String? = null, + val transportError: Boolean = false, + val redirectTo: String? = null, + val oversized: Boolean = false, + ) + + /** One discovery scenario mirroring a shared fixture. */ + private data class Scenario( + val name: String, + val op: Op, + val resourceOrigin: String? = null, + val issuerOrigin: String? = null, + val expectedIssuer: String? = null, + val hop1: Hop? = null, + val hop2: Hop? = null, + // Exactly one of the outcome fields is set. + val selectedIssuer: String? = null, + val fallbackReason: String? = null, + val raiseReason: String? = null, // DiscoverySelection reason token + val raiseUsage: Boolean = false, // usage error + val raiseApiError: Boolean = false, // discover/discoverProtectedResource hard failure + // Coarse BasecampException.code the thrown error must map to, mirroring the + // shared fixtures' `errorCategory`. Set on every raise scenario. + val errorCategory: String? = null, + val launchpadMustBeSilent: Boolean = false, + ) + + // Tracks whether the Launchpad (or attacker) host was contacted at all. + private class Tracker { + var launchpadContacted = false + var attackerContacted = false + } + + private fun MockRequestHandleScope.serve(hop: Hop): HttpResponseData { + if (hop.transportError) throw RuntimeException("connection refused") + if (hop.redirectTo != null) { + return respond( + content = ByteReadChannel(""), + status = HttpStatusCode.fromValue(hop.status), + headers = headersOf(HttpHeaders.Location, hop.redirectTo), + ) + } + val content = if (hop.oversized) { + // A well-formed but oversized JSON document (> 1 MiB cap): the bounded + // streaming read must abort before buffering the whole body. + ByteReadChannel("{\"pad\":\"" + "x".repeat(1_100_000) + "\"}") + } else { + ByteReadChannel(hop.body ?: "") + } + return respond( + content = content, + status = HttpStatusCode.fromValue(hop.status), + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + private fun engineFor(scenario: Scenario, tracker: Tracker): HttpClient { + val engine = MockEngine { request -> + val host = request.url.host + val path = request.url.encodedPath + if (host.contains("launchpad")) tracker.launchpadContacted = true + if (host.contains("attacker")) tracker.attackerContacted = true + + when { + path.endsWith("oauth-protected-resource") -> + serve(scenario.hop1 ?: fail("${scenario.name}: no hop1 configured")) + + path.endsWith("oauth-authorization-server") && host.contains("launchpad") -> + // Documentary Launchpad fallback config; hard cases must never reach here. + respond( + content = ByteReadChannel( + "{\"issuer\":\"$LAUNCHPAD\",\"token_endpoint\":\"$LAUNCHPAD/authorization/token\"}" + ), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + + path.endsWith("oauth-authorization-server") -> + serve(scenario.hop2 ?: fail("${scenario.name}: no hop2 configured")) + + else -> respondBadRequest() + } + } + return HttpClient(engine) + } + + private suspend fun drive(scenario: Scenario, client: HttpClient): Any? = when (scenario.op) { + Op.FROM_RESOURCE -> discoverFromResource(scenario.resourceOrigin!!, scenario.expectedIssuer, client) + Op.PROTECTED_RESOURCE -> discoverProtectedResource(scenario.resourceOrigin!!, client) + Op.DISCOVER -> discover(scenario.issuerOrigin!!, client) + } + + private fun runScenario(scenario: Scenario) = runTest { + val tracker = Tracker() + val client = engineFor(scenario, tracker) + try { + val result: Any? + var thrown: Throwable? = null + try { + result = drive(scenario, client) + } catch (e: Throwable) { + thrown = e + // Assert on the throw path below. + when { + scenario.raiseUsage -> assertIs(e, "${scenario.name}: expected usage") + scenario.raiseApiError -> + assertIs(e, "${scenario.name}: expected BasecampException") + scenario.raiseReason != null -> { + assertIs(e, "${scenario.name}: expected DiscoverySelection") + assertEquals(scenario.raiseReason, e.reason, scenario.name) + } + else -> fail("${scenario.name}: unexpected throw: $e") + } + // Every raise scenario carries its coarse errorCategory (mirrors the + // shared fixtures); the thrown BasecampException.code must equal it. + if (scenario.errorCategory != null) { + assertIs(e, "${scenario.name}: expected BasecampException") + assertEquals(scenario.errorCategory, e.code, "${scenario.name}: errorCategory") + } + assertSilenceIfRequired(scenario, tracker) + return@runTest + } + + // No throw: must be a fallback or selected scenario. + when { + scenario.fallbackReason != null -> { + val r = assertIs(result, scenario.name) + assertEquals(scenario.fallbackReason, r.reason.code, scenario.name) + } + scenario.selectedIssuer != null -> when (scenario.op) { + Op.FROM_RESOURCE -> { + val r = assertIs(result, scenario.name) + assertEquals(scenario.selectedIssuer, r.issuer, scenario.name) + } + Op.PROTECTED_RESOURCE -> { + val r = assertIs(result, scenario.name) + assertEquals(scenario.selectedIssuer, r.resource, scenario.name) + } + Op.DISCOVER -> { + val r = assertIs(result, scenario.name) + assertEquals(scenario.selectedIssuer, r.issuer, scenario.name) + } + } + scenario.raiseUsage || scenario.raiseApiError || scenario.raiseReason != null -> + fail("${scenario.name}: expected a throw but got $result (thrown=$thrown)") + } + assertSilenceIfRequired(scenario, tracker) + } finally { + client.close() + } + } + + private fun assertSilenceIfRequired(scenario: Scenario, tracker: Tracker) { + if (scenario.launchpadMustBeSilent) { + assertFalse(tracker.launchpadContacted, "${scenario.name}: Launchpad must not be contacted") + assertFalse(tracker.attackerContacted, "${scenario.name}: attacker host must not be contacted") + } + } + + // JSON body helpers ------------------------------------------------------- + + private fun resourceBody(resource: String, servers: List?): String { + val serversJson = servers?.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" } + return if (serversJson == null) "{\"resource\":\"$resource\"}" + else "{\"resource\":\"$resource\",\"authorization_servers\":$serversJson}" + } + + // ========================================================================= + // Fixtures 01-20 mirrored + // ========================================================================= + + @Test fun `01 two-hop happy path`() = runScenario( + Scenario( + name = "two-hop-happy-path", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf(BC5, LAUNCHPAD))), + hop2 = Hop( + body = "{\"issuer\":\"$BC5\",\"authorization_endpoint\":\"$BC5/oauth/authorize\"," + + "\"token_endpoint\":\"$BC5/oauth/token\",\"device_authorization_endpoint\":\"$BC5/oauth/device\"," + + "\"grant_types_supported\":[\"urn:ietf:params:oauth:grant-type:device_code\",\"refresh_token\"]}" + ), + selectedIssuer = BC5, + launchpadMustBeSilent = true, + ) + ) + + @Test fun `02 no-as-advertised absent`() = runScenario( + Scenario( + name = "no-as-advertised-absent", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, null)), + fallbackReason = "no_as_advertised", + ) + ) + + @Test fun `03 no-as-advertised empty array`() = runScenario( + Scenario( + name = "no-as-advertised-empty-array", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, emptyList())), + fallbackReason = "no_as_advertised", + ) + ) + + @Test fun `04 only launchpad`() = runScenario( + Scenario( + name = "only-launchpad", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf(LAUNCHPAD))), + fallbackReason = "no_as_advertised", + ) + ) + + @Test fun `05 resource mismatch`() = runScenario( + Scenario( + name = "resource-mismatch", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody("https://attacker.example.com", listOf(BC5, LAUNCHPAD))), + fallbackReason = "resource_discovery_failed", + ) + ) + + @Test fun `06 hop1 transport failure`() = runScenario( + Scenario( + name = "hop1-transport-failure", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(transportError = true), + fallbackReason = "resource_discovery_failed", + ) + ) + + @Test fun `07 issuer binding mismatch`() = runScenario( + Scenario( + name = "issuer-binding-mismatch", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf(BC5, LAUNCHPAD))), + hop2 = Hop( + body = "{\"issuer\":\"https://impostor.example.com\"," + + "\"authorization_endpoint\":\"$BC5/oauth/authorize\",\"token_endpoint\":\"$BC5/oauth/token\"}" + ), + raiseReason = "issuer_mismatch", + errorCategory = "api_error", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `08 as metadata 500`() = runScenario( + Scenario( + name = "as-metadata-500", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf(BC5, LAUNCHPAD))), + hop2 = Hop(status = 500, body = "{\"error\":\"internal_server_error\"}"), + raiseReason = "as_fetch_failed", + errorCategory = "api_error", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `09 ambiguous issuers`() = runScenario( + Scenario( + name = "ambiguous-issuers", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf(BC5, "https://other-as.example.com", LAUNCHPAD))), + raiseReason = "ambiguous_issuers", + errorCategory = "api_error", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `10 expected issuer selected`() = runScenario( + Scenario( + name = "expected-issuer-selected", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + expectedIssuer = BC5, + hop1 = Hop(body = resourceBody(RESOURCE, listOf(BC5, "https://other-as.example.com", LAUNCHPAD))), + hop2 = Hop( + body = "{\"issuer\":\"$BC5\",\"authorization_endpoint\":\"$BC5/oauth/authorize\"," + + "\"token_endpoint\":\"$BC5/oauth/token\"}" + ), + selectedIssuer = BC5, + launchpadMustBeSilent = true, + ) + ) + + @Test fun `11 expected issuer unavailable`() = runScenario( + Scenario( + name = "expected-issuer-unavailable", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + expectedIssuer = "https://not-advertised.example.com", + hop1 = Hop(body = resourceBody(RESOURCE, listOf(BC5, LAUNCHPAD))), + raiseReason = "expected_issuer_unavailable", + errorCategory = "api_error", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `12 empty-string endpoint rejected`() = runScenario( + Scenario( + name = "empty-string-endpoint", + op = Op.DISCOVER, + issuerOrigin = ISSUER, + hop2 = Hop( + body = "{\"issuer\":\"$ISSUER\",\"authorization_endpoint\":\"$ISSUER/oauth/authorize\"," + + "\"token_endpoint\":\"\"}" + ), + raiseApiError = true, + errorCategory = "api_error", + ) + ) + + @Test fun `13 device-only AS`() = runScenario( + Scenario( + name = "device-only-as", + op = Op.DISCOVER, + issuerOrigin = ISSUER, + hop2 = Hop( + body = "{\"issuer\":\"$ISSUER\",\"token_endpoint\":\"$ISSUER/oauth/token\"," + + "\"device_authorization_endpoint\":\"$ISSUER/oauth/device\"," + + "\"grant_types_supported\":[\"urn:ietf:params:oauth:grant-type:device_code\",\"refresh_token\"]}" + ), + selectedIssuer = ISSUER, + ) + ) + + @Test fun `14 origin-root http non-localhost`() = runScenario( + Scenario( + name = "origin-root-http-nonlocalhost", + op = Op.PROTECTED_RESOURCE, + resourceOrigin = "http://api.example.com", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `15 origin-root malformed port`() = runScenario( + Scenario( + name = "origin-root-malformed-port", + op = Op.PROTECTED_RESOURCE, + resourceOrigin = "https://api.example.com:notaport", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `16 origin-root path rejected`() = runScenario( + Scenario( + name = "origin-root-path-rejected", + op = Op.PROTECTED_RESOURCE, + resourceOrigin = "https://api.example.com/tenant/1", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `17 origin-root ipv6 localhost accept`() = runScenario( + Scenario( + name = "origin-root-ipv6-localhost-accept", + op = Op.PROTECTED_RESOURCE, + resourceOrigin = "http://[::1]:3000", + hop1 = Hop(body = resourceBody("http://[::1]:3000", listOf(LAUNCHPAD))), + selectedIssuer = "http://[::1]:3000", + ) + ) + + @Test fun `18 ssrf oversized body`() = runScenario( + Scenario( + name = "ssrf-oversized-body", + op = Op.DISCOVER, + issuerOrigin = ISSUER, + hop2 = Hop(oversized = true), + raiseApiError = true, + errorCategory = "api_error", + ) + ) + + @Test fun `19 ssrf redirect not followed`() = runScenario( + Scenario( + name = "ssrf-redirect-not-followed", + op = Op.DISCOVER, + issuerOrigin = ISSUER, + hop2 = Hop( + status = 302, + redirectTo = "https://attacker.example.com/.well-known/oauth-authorization-server", + ), + raiseApiError = true, + errorCategory = "api_error", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `20 invalid issuer origin`() = runScenario( + Scenario( + name = "invalid-issuer-origin", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf("https://bc5.example.com/oauth", LAUNCHPAD))), + raiseReason = "invalid_issuer_origin", + errorCategory = "api_error", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `21 authorization_servers not array`() = runScenario( + Scenario( + name = "authorization-servers-not-array", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + // authorization_servers is a bare JSON string, not an array. Structural + // decode into List? must reject it as malformed hop-1 metadata + // (never iterate its characters as issuers) → soft fallback to Launchpad. + hop1 = Hop(body = "{\"resource\":\"$RESOURCE\",\"authorization_servers\":\"$BC5\"}"), + fallbackReason = "resource_discovery_failed", + ) + ) + + @Test fun `22 grant_types not array`() = runScenario( + Scenario( + name = "grant-types-not-array", + op = Op.DISCOVER, + issuerOrigin = ISSUER, + // grant_types_supported is a bare JSON string, not an array. Structural + // decode into List must reject it as invalid metadata (never + // substring-match it, which would falsely enable device flow) → api_error. + hop2 = Hop( + body = "{\"issuer\":\"$ISSUER\",\"token_endpoint\":\"$ISSUER/oauth/token\"," + + "\"grant_types_supported\":\"urn:ietf:params:oauth:grant-type:device_code\"}" + ), + raiseApiError = true, + errorCategory = "api_error", + ) + ) + + @Test fun `23 as metadata missing token_endpoint`() = runScenario( + Scenario( + name = "as-metadata-missing-token-endpoint", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf(BC5, LAUNCHPAD))), + // Committed AS: issuer binds correctly, but token_endpoint is absent. + // This is a non-mismatch AS fault whose message never mentions "issuer + // mismatch" — it must classify as as_fetch_failed via the marker TYPE, + // never via message text. + hop2 = Hop(body = "{\"issuer\":\"$BC5\",\"authorization_endpoint\":\"$BC5/oauth/authorize\"}"), + raiseReason = "as_fetch_failed", + errorCategory = "api_error", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `24 launchpad lookalike with path stays a candidate`() = runScenario( + Scenario( + name = "launchpad-lookalike-with-path", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + // A Launchpad look-alike carrying a path is NOT the Launchpad issuer + // under the origin-root profile, so isLaunchpadIssuer must not exclude + // it (comparing only isSameOrigin, which ignores the path, would). With + // BC5 also advertised there are two non-Launchpad candidates → HARD + // ambiguous_issuers, never a silent BC5 selection or Launchpad fallback. + hop1 = Hop(body = resourceBody(RESOURCE, listOf("$LAUNCHPAD/path", BC5))), + raiseReason = "ambiguous_issuers", + errorCategory = "api_error", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `discover surfaces issuer mismatch as api_error to external callers`() = runTest { + // The module-private binding marker must NOT leak: an external discover() + // caller sees an ordinary api_error, identical to any other invalid AS + // metadata. Only discoverFromResource branches on the marker type. + val engine = MockEngine { + respond( + content = ByteReadChannel( + "{\"issuer\":\"https://impostor.example.com\",\"token_endpoint\":\"$ISSUER/oauth/token\"}" + ), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val client = HttpClient(engine) + try { + val e = assertFailsWith { discover(ISSUER, client) } + assertEquals("api_error", e.code) + } finally { + client.close() + } + } + + // ========================================================================= + // Parser-boundary asserts for the origin-root profile (no transport). + // ========================================================================= + + @Test fun `origin-root accepts bracketed ipv6 localhost`() { + assertEquals("http://[::1]:3000", requireOriginRoot("http://[::1]:3000")) + } + + @Test fun `origin-root rejects malformed port`() { + assertFailsWith { requireOriginRoot("https://api.example.com:notaport") } + } + + @Test fun `origin-root rejects empty userinfo`() { + // `https://@host` carries an empty userinfo; Ktor's Url.user is null for it, + // so the raw authority must be inspected for an '@'. + assertFailsWith { requireOriginRoot("https://@api.example.com") } + } + + @Test fun `origin-root rejects userinfo`() { + assertFailsWith { requireOriginRoot("https://user@api.example.com") } + assertFailsWith { requireOriginRoot("https://user:pass@api.example.com") } + } + + @Test fun `discovery cancellation propagates and is not converted to network`() = runTest { + // A cancelled discovery fetch must surface the CancellationException, never + // soft-fall-back by masquerading as BasecampException.Network. + val engine = MockEngine { + delay(10_000) + respond( + content = ByteReadChannel("{}"), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val client = HttpClient(engine) + try { + assertFailsWith { + withTimeout(3_000) { discover(ISSUER, client) } + } + } finally { + client.close() + } + } + + @Test fun `origin-root drops default port and trailing slash`() { + assertEquals("https://api.example.com", requireOriginRoot("https://api.example.com/")) + assertEquals("https://api.example.com", requireOriginRoot("https://api.example.com:443")) + } +} From 2d5ba7f015779f9a01f1018ad786f37e00b4b0a0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 12:03:13 -0700 Subject: [PATCH 07/38] discovery: reject delimiter-only userinfo, keep OAuthConfig field order, classify read failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TS: the WHATWG URL parser normalizes delimiter-only userinfo (https://@host, https://:@host) to empty username/password, so the field-only check accepted and silently normalized malformed origins — requireOriginRoot now also inspects the raw authority, matching the Kotlin/Python/Go/Ruby presence-based rejection. - Python: restore OAuthConfig's original positional field order (issuer, authorization_endpoint, token_endpoint) — dataclasses generate a positional __init__, so the reorder silently swapped endpoints for existing positional callers. authorization_endpoint stays optional-typed but keeps its required slot (callers pass None explicitly). - Go: only errBodyTooLarge takes the size-cap api_error path when a 2xx discovery body fails mid-read; other I/O failures (peer reset, truncation) are transient network errors, retryable. --- go/pkg/basecamp/oauth/discovery.go | 17 ++++++-- go/pkg/basecamp/oauth/oauth_test.go | 40 +++++++++++++++++++ python/src/basecamp/oauth/config.py | 12 ++++-- python/tests/oauth/test_resource_discovery.py | 18 ++++++++- typescript/src/oauth/discovery.ts | 7 +++- .../tests/oauth/resource-discovery.test.ts | 13 ++++++ 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index ba8791fbb..e0c380684 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -267,10 +267,21 @@ func (d *Discoverer) fetchDiscoveryDocument(ctx context.Context, rawURL string, body, err := readBoundedBody(resp.Body, cfg.maxBodyBytes) if err != nil { + if errors.Is(err, errBodyTooLarge) { + return nil, &basecamp.Error{ + Code: basecamp.CodeAPI, + Message: fmt.Sprintf("OAuth discovery response too large: %v", err), + Cause: err, + } + } + // A mid-stream read failure (peer reset, timeout) on a 2xx is a + // transient transport fault, not malformed AS metadata — only the + // size-cap overflow above is an api_error. return nil, &basecamp.Error{ - Code: basecamp.CodeAPI, - Message: fmt.Sprintf("OAuth discovery response too large: %v", err), - Cause: err, + Code: basecamp.CodeNetwork, + Message: fmt.Sprintf("reading OAuth discovery response: %v", err), + Retryable: true, + Cause: err, } } return body, nil diff --git a/go/pkg/basecamp/oauth/oauth_test.go b/go/pkg/basecamp/oauth/oauth_test.go index 337087652..565d0006e 100644 --- a/go/pkg/basecamp/oauth/oauth_test.go +++ b/go/pkg/basecamp/oauth/oauth_test.go @@ -3,12 +3,15 @@ package oauth import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" ) func TestDiscoverer_Discover(t *testing.T) { @@ -77,6 +80,43 @@ func TestDiscoverer_Discover(t *testing.T) { // TestDiscoverer_Discover_TrailingSlash verifies the origin-root profile accepts // a trailing "/" (path exactly "/") and still binds the issuer to the origin. +func TestDiscoverer_Discover_MidStreamReadFailureIsNetwork(t *testing.T) { + // A 2xx whose body dies mid-read (peer reset, truncation) is a transient + // transport fault — network, retryable — never misclassified as the + // size-cap api_error, which is reserved for errBodyTooLarge. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("server does not support hijacking") + } + conn, buf, err := hj.Hijack() + if err != nil { + t.Fatalf("hijack: %v", err) + } + // Declare more bytes than are sent, then close the connection: the + // client's body read fails mid-stream with an unexpected EOF. + _, _ = buf.WriteString("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 4096\r\n\r\n{\"issuer\":") + _ = buf.Flush() + _ = conn.Close() + })) + defer srv.Close() + + d := NewDiscoverer(srv.Client()) + _, err := d.Discover(context.Background(), srv.URL) + + var bcErr *basecamp.Error + if !errors.As(err, &bcErr) { + t.Fatalf("want *basecamp.Error, got %v", err) + } + if bcErr.Code != basecamp.CodeNetwork { + t.Errorf("Code = %q, want %q (mid-stream read failure is transport, not malformed metadata)", + bcErr.Code, basecamp.CodeNetwork) + } + if !bcErr.Retryable { + t.Error("mid-stream read failure must be retryable") + } +} + func TestDiscoverer_Discover_TrailingSlash(t *testing.T) { var origin string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/python/src/basecamp/oauth/config.py b/python/src/basecamp/oauth/config.py index 9d9a9d835..86e65bbbb 100644 --- a/python/src/basecamp/oauth/config.py +++ b/python/src/basecamp/oauth/config.py @@ -10,11 +10,15 @@ class OAuthConfig: """OAuth 2 server configuration from discovery endpoint (RFC 8414).""" issuer: str + # Optional (``None``) as of BC5 resource-first discovery: device-only + # authorization servers omit it. Authorization-code consumers MUST assert + # its presence before use. Absent (``None``) and present-empty are + # preserved distinctly. Kept in its ORIGINAL positional slot (before + # token_endpoint) with no default: dataclasses generate a positional + # __init__, so reordering or defaulting this field would silently break + # existing ``OAuthConfig("iss", "auth", "token")`` callers. + authorization_endpoint: str | None token_endpoint: str - # Optional as of BC5 resource-first discovery: device-only authorization - # servers omit it. Authorization-code consumers MUST assert its presence - # before use. Absent (``None``) and present-empty are preserved distinctly. - authorization_endpoint: str | None = None device_authorization_endpoint: str | None = None registration_endpoint: str | None = None scopes_supported: list[str] | None = None diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 5daa77444..f06c5ce2f 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -333,10 +333,26 @@ def test_protected_resource_rejects_non_string_resource() -> None: assert exc_info.value.code == "api_error" +def test_oauth_config_preserves_positional_field_order() -> None: + # dataclasses generate a positional __init__ in field order — the pre-BC5 + # public order (issuer, authorization_endpoint, token_endpoint) must hold + # so existing positional callers don't silently swap endpoints. + from basecamp.oauth import OAuthConfig + + config = OAuthConfig("https://iss.example", "https://iss.example/auth", "https://iss.example/token") + + assert config.authorization_endpoint == "https://iss.example/auth" + assert config.token_endpoint == "https://iss.example/token" + + def test_selected_config_narrows_on_selected_result() -> None: from basecamp.oauth import DiscoveryResult, OAuthConfig - config = OAuthConfig(issuer="https://bc5.example", token_endpoint="https://bc5.example/oauth/token") + config = OAuthConfig( + issuer="https://bc5.example", + authorization_endpoint=None, + token_endpoint="https://bc5.example/oauth/token", + ) result = DiscoveryResult(kind="selected", config=config, issuer=config.issuer) # Narrows OAuthConfig | None → OAuthConfig for typed callers. diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 0d6d3085c..422d3d933 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -145,7 +145,12 @@ export function requireOriginRoot(raw: string, label = "origin"): string { if (!url.hostname) { throw new BasecampError("usage", `${label} has no host: ${raw}`); } - if (url.username || url.password) { + // The WHATWG URL parser normalizes delimiter-only userinfo ("https://@host", + // "https://:@host") to EMPTY username/password and drops it from href, so the + // parsed fields alone cannot catch it — also inspect the raw authority (the + // scheme check above guarantees raw starts with http(s)://). + const rawAuthority = raw.slice(raw.indexOf("//") + 2).split(/[/?#]/, 1)[0] ?? ""; + if (url.username || url.password || rawAuthority.includes("@")) { throw new BasecampError("usage", `${label} must not contain userinfo: ${raw}`); } if (url.search || url.hash) { diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index a1f119097..bf8aafc36 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -322,3 +322,16 @@ describe("SSRF hardening", () => { expect(attackerContacted).toBe(false); }); }); + +describe("requireOriginRoot userinfo rejection", () => { + // Rejection keys off the PRESENCE of userinfo, not its truthiness: the + // WHATWG URL parser normalizes delimiter-only userinfo ("https://@host") to + // empty username/password and drops it from href, so a field-only check + // would silently accept — and normalize away — a malformed caller origin. + it.each(["https://user@host", "https://@example.com", "https://:@host"])( + "rejects %s", + (raw) => { + expect(() => requireOriginRoot(raw)).toThrow(/userinfo/); + } + ); +}); From 1b1f8ffe1cc2c04f5343652f732dadd0e76396db Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 16:48:43 -0700 Subject: [PATCH 08/38] Go: case-insensitive host, reject present-null lists, non-string endpoints Review fixes for #369: - requireOriginRoot lowercases the host (DNS/scheme are case-insensitive), so a mixed-case Launchpad issuer (https://LAUNCHPAD.37signals.com) normalizes to the canonical origin and is correctly excluded instead of committed as a BC5 issuer. - Reject a present JSON null for optional list fields (grant_types_supported, scopes_supported, code_challenge_methods_supported, authorization_servers): a present list MUST be an array, distinct from an absent key. authorization_servers: null now yields resource_discovery_failed rather than no_as_advertised. - rejectEmptyEndpoints now rejects any present *_endpoint that is not a non-empty string (number, array, or null), not only empty strings. - Fix a misplaced test doc comment. Adds tests for mixed-case Launchpad exclusion, null-list rejection, and non-string endpoints. --- go/pkg/basecamp/oauth/discovery.go | 43 ++++++++++-- go/pkg/basecamp/oauth/oauth_test.go | 5 +- .../basecamp/oauth/resource_discovery_test.go | 65 +++++++++++++++++++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index e0c380684..4b8d8ad6c 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -1,6 +1,7 @@ package oauth import ( + "bytes" "context" "encoding/json" "errors" @@ -148,7 +149,12 @@ func requireOriginRoot(raw, label string) (string, error) { } } - host := u.Hostname() + // Lowercase the host: DNS names and schemes are case-insensitive (RFC 3986 + // §3.1/§6.2.2.1), so a mixed-case advertised issuer like + // https://Launchpad.37signals.com must normalize to the same origin as its + // canonical form — otherwise the Launchpad exclusion misses it and it is + // wrongly treated as a distinct BC5 issuer. + host := strings.ToLower(u.Hostname()) origin := scheme + "://" if strings.Contains(host, ":") { // IPv6 literal — re-bracket (Hostname strips the brackets). @@ -354,6 +360,9 @@ func parseAndBindASMetadata(body []byte, expectedIssuerOrigin string) (*Config, if err := rejectEmptyEndpoints(body); err != nil { return nil, err } + if err := rejectNullListFields(body, "grant_types_supported", "scopes_supported", "code_challenge_methods_supported"); err != nil { + return nil, err + } cfg := &Config{ Issuer: *raw.Issuer, @@ -370,8 +379,10 @@ func parseAndBindASMetadata(body []byte, expectedIssuerOrigin string) (*Config, return cfg, nil } -// rejectEmptyEndpoints rejects any present-but-empty "*_endpoint" field, matching -// the reference: a present endpoint must be non-empty. +// rejectEmptyEndpoints rejects any present "*_endpoint" field that is not a +// non-empty string. A present endpoint must be a non-empty string: an empty +// string, or a non-string value (number, array, object, or JSON null), is +// malformed metadata — not silently treated as absent. func rejectEmptyEndpoints(body []byte) error { var m map[string]json.RawMessage // The body already parsed as an object upstream; a decode failure here leaves @@ -382,10 +393,29 @@ func rejectEmptyEndpoints(body []byte) error { continue } var s string - if json.Unmarshal(v, &s) == nil && s == "" { + if json.Unmarshal(v, &s) != nil || s == "" { + return &basecamp.Error{ + Code: basecamp.CodeAPI, + Message: fmt.Sprintf("invalid OAuth discovery response: %s must be a non-empty string", k), + } + } + } + return nil +} + +// rejectNullListFields rejects any of the named optional list fields that is +// present with a JSON null value. A present list field must be an array (RFC +// 8414 / RFC 9728); a JSON null is malformed metadata, distinct from an absent +// key (which the typed decode legitimately treats as unset). +func rejectNullListFields(body []byte, keys ...string) error { + var m map[string]json.RawMessage + _ = json.Unmarshal(body, &m) + for _, k := range keys { + v, present := m[k] + if present && string(bytes.TrimSpace(v)) == "null" { return &basecamp.Error{ Code: basecamp.CodeAPI, - Message: fmt.Sprintf("invalid OAuth discovery response: empty %s", k), + Message: fmt.Sprintf("invalid OAuth discovery response: %s must be an array when present, not null", k), } } } @@ -420,6 +450,9 @@ func (d *Discoverer) fetchProtectedResource(ctx context.Context, origin string, if raw.Resource == nil || *raw.Resource == "" { return nil, &basecamp.Error{Code: basecamp.CodeAPI, Message: "invalid resource metadata: missing required field (resource)"} } + if err := rejectNullListFields(body, "authorization_servers"); err != nil { + return nil, err + } // Bind resource identifier to the requested origin, code-point exact. if *raw.Resource != origin { return nil, &basecamp.Error{ diff --git a/go/pkg/basecamp/oauth/oauth_test.go b/go/pkg/basecamp/oauth/oauth_test.go index 565d0006e..a87b08fd2 100644 --- a/go/pkg/basecamp/oauth/oauth_test.go +++ b/go/pkg/basecamp/oauth/oauth_test.go @@ -78,8 +78,9 @@ func TestDiscoverer_Discover(t *testing.T) { } } -// TestDiscoverer_Discover_TrailingSlash verifies the origin-root profile accepts -// a trailing "/" (path exactly "/") and still binds the issuer to the origin. +// TestDiscoverer_Discover_MidStreamReadFailureIsNetwork verifies that a 2xx +// whose body dies mid-stream (peer reset, truncation) is classified as network +// (retryable), not as the size-cap api_error. func TestDiscoverer_Discover_MidStreamReadFailureIsNetwork(t *testing.T) { // A 2xx whose body dies mid-read (peer reset, truncation) is a transient // transport fault — network, retryable — never misclassified as the diff --git a/go/pkg/basecamp/oauth/resource_discovery_test.go b/go/pkg/basecamp/oauth/resource_discovery_test.go index 907059e67..cbc033e19 100644 --- a/go/pkg/basecamp/oauth/resource_discovery_test.go +++ b/go/pkg/basecamp/oauth/resource_discovery_test.go @@ -555,3 +555,68 @@ func TestDiscoverFromResource_ASFetchFailurePreservesStatus(t *testing.T) { t.Errorf("Code = %q, want api_error", be.Code) } } + +func TestDiscoverFromResource_MixedCaseLaunchpadExcluded(t *testing.T) { + // A mixed-case Launchpad host must be recognized as Launchpad (hosts are + // case-insensitive), so it is excluded and discovery soft-falls-back with + // no_as_advertised rather than committing it as a distinct BC5 issuer. + var resourceOrigin string + resource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"resource":"` + resourceOrigin + `","authorization_servers":["https://LAUNCHPAD.37signals.com"]}`)) + })) + defer resource.Close() + resourceOrigin = resource.URL + + result, err := NewDiscoverer(resource.Client()).DiscoverFromResource(context.Background(), resourceOrigin) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Config != nil || result.FallbackReason != FallbackNoASAdvertised { + t.Errorf("want no_as_advertised fallback, got Config=%v reason=%q", result.Config, result.FallbackReason) + } +} + +func TestDiscoverFromResource_RejectsNullAuthorizationServers(t *testing.T) { + // authorization_servers: null is malformed metadata (must be an array when + // present), distinct from an absent key — so hop 1 fails and discovery + // classifies it as resource_discovery_failed, not no_as_advertised. + var resourceOrigin string + resource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"resource":"` + resourceOrigin + `","authorization_servers":null}`)) + })) + defer resource.Close() + resourceOrigin = resource.URL + + result, err := NewDiscoverer(resource.Client()).DiscoverFromResource(context.Background(), resourceOrigin) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.FallbackReason != FallbackResourceDiscoveryFailed { + t.Errorf("want resource_discovery_failed, got %q", result.FallbackReason) + } +} + +func TestParseAndBindASMetadata_RejectsNullGrantTypes(t *testing.T) { + // grant_types_supported: null is malformed (an array when present), not absent. + body := []byte(`{"issuer":"https://bc5.example","token_endpoint":"https://bc5.example/token","grant_types_supported":null}`) + _, err := parseAndBindASMetadata(body, "https://bc5.example") + var be *basecamp.Error + if !errors.As(err, &be) || be.Code != basecamp.CodeAPI { + t.Fatalf("want api_error for null grant_types_supported, got %v (%T)", err, err) + } +} + +func TestRejectEmptyEndpoints_RejectsNonStringEndpoint(t *testing.T) { + // A present *_endpoint that is not a non-empty string (number, array, null) is + // malformed metadata, not silently absent. + for _, body := range []string{ + `{"token_endpoint":"https://bc5.example/token","registration_endpoint":123}`, + `{"token_endpoint":"https://bc5.example/token","registration_endpoint":null}`, + } { + if err := rejectEmptyEndpoints([]byte(body)); err == nil { + t.Errorf("expected rejection for %s", body) + } + } +} From 634f69dd08ab49d00a38799ffc90c061db328f89 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 16:53:45 -0700 Subject: [PATCH 09/38] TypeScript: reject present-null authorization_servers, dedupe issuers, empty resourceBaseUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #369: - Resource metadata: a present authorization_servers that is not a string array (including JSON null) is malformed → resource_discovery_failed, not silently coerced to a present-empty list that yields no_as_advertised. - Selection dedupes exact issuer strings before the ambiguity check, so a resource that advertises the same non-Launchpad issuer twice selects it instead of raising ambiguous_issuers. - interactive-login checks resourceBaseUrl !== undefined (not truthiness), so an explicitly-supplied empty value reaches origin validation instead of falling through to a Launchpad login. - Fixture harness asserts launchpadContacted exactly (performing the consumer's Launchpad login on a soft fallback) so soft-fallback fixtures keep the launchpad-contact regression signal. Adds null/dedupe regression tests. --- typescript/src/oauth/discovery.ts | 19 ++++---- typescript/src/oauth/interactive-login.ts | 5 ++- .../tests/oauth/resource-discovery.test.ts | 44 ++++++++++++++++++- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 422d3d933..f92f60024 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -451,20 +451,21 @@ export async function discoverProtectedResource( ); } - // authorization_servers, when present, must be an array of strings. A bare - // string here previously slipped through and was iterated char-by-char during - // selection; reject it as malformed so the orchestrator soft-falls-back. + // authorization_servers, when present, MUST be an array of strings. A bare + // string slipped through and was iterated char-by-char during selection; a + // present JSON null is likewise malformed (an array is required when the key is + // present) — not a present-empty list. Reject every present non-array value so + // the orchestrator classifies it as resource_discovery_failed rather than + // silently taking no_as_advertised. let authorizationServers: string[] | undefined; if (Object.prototype.hasOwnProperty.call(data, "authorization_servers")) { const raw: unknown = (data as Record).authorization_servers; - if (raw === null || raw === undefined) { - authorizationServers = []; // present-but-null → present-empty - } else if (isStringArray(raw)) { + if (isStringArray(raw)) { authorizationServers = raw; } else { throw new BasecampError( "api_error", - "Invalid resource metadata: authorization_servers must be an array of strings" + "Invalid resource metadata: authorization_servers must be an array of strings when present" ); } } @@ -514,7 +515,9 @@ export async function discoverFromResource( } selectedIssuer = match; } else { - const nonLaunchpad = advertised.filter((s) => !isLaunchpadIssuer(s)); + // Deduplicate exact issuer strings first: a resource that advertises the same + // non-Launchpad issuer more than once denotes ONE issuer, not an ambiguous set. + const nonLaunchpad = [...new Set(advertised.filter((s) => !isLaunchpadIssuer(s)))]; if (nonLaunchpad.length >= 2) { throw new DiscoverySelectionError( "ambiguous_issuers", diff --git a/typescript/src/oauth/interactive-login.ts b/typescript/src/oauth/interactive-login.ts index cc6bf244f..c18fc7344 100644 --- a/typescript/src/oauth/interactive-login.ts +++ b/typescript/src/oauth/interactive-login.ts @@ -213,7 +213,10 @@ async function discoverEndpoints(opts: { }): Promise<{ config: import("./types.js").OAuthConfig; legacy: boolean }> { const { baseUrl, resourceBaseUrl, expectedIssuer, useLegacyFormat, onStatus } = opts; - if (resourceBaseUrl) { + // Check presence, not truthiness: an explicitly-supplied empty resourceBaseUrl + // is invalid resource-first input and must reach origin validation (which + // rejects it), not silently fall through to a Launchpad login. + if (resourceBaseUrl !== undefined) { const result = await discoverFromResource(resourceBaseUrl, { expectedIssuer }); if (result.kind === "selected") { // Derive the token format from the selected issuer unless the caller diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index bf8aafc36..eb9a91f2c 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -16,6 +16,7 @@ import { discover, discoverProtectedResource, discoverFromResource, + discoverLaunchpad, requireOriginRoot, DiscoverySelectionError, } from "../../src/oauth/index.js"; @@ -174,6 +175,13 @@ describe("resource-first discovery fixtures", () => { const result = (await run()) as { kind: string; reason?: string }; expect(result.kind).toBe("fallback"); expect(result.reason).toBe(fx.expect.fallbackReason); + // The orchestrator returns a soft fallback WITHOUT contacting Launchpad; + // the consumer performs the Launchpad login. Do that here so the + // launchpadContacted assertion below reflects the full flow (a + // regression that skipped the Launchpad request would then be caught). + if (fx.operation === "discoverFromResource") { + await discoverLaunchpad(); + } } else { // selected const result = await run(); @@ -185,8 +193,11 @@ describe("resource-first discovery fixtures", () => { // discover / discoverProtectedResource: absence of a throw is success. } - if (fx.expect.launchpadContacted === false) { - expect(launchpadContacted).toBe(false); + // Assert the exact launchpadContacted expectation when a fixture states one, + // so a soft-fallback fixture that expects Launchpad to be contacted keeps its + // regression signal too — not only the `false` (never-contacted) case. + if (fx.expect.launchpadContacted !== undefined) { + expect(launchpadContacted).toBe(fx.expect.launchpadContacted); } }); } @@ -335,3 +346,32 @@ describe("requireOriginRoot userinfo rejection", () => { } ); }); + +describe("resource metadata strictness (#369 review)", () => { + const RESOURCE = "https://api.strict-test.example"; + const BC5 = "https://bc5.strict-test.example"; + + it("rejects a present null authorization_servers as resource_discovery_failed", async () => { + server.use( + mswHttp.get(`${RESOURCE}/.well-known/oauth-protected-resource`, () => + HttpResponse.json({ resource: RESOURCE, authorization_servers: null }) + ) + ); + const result = await discoverFromResource(RESOURCE); + expect(result).toMatchObject({ kind: "fallback", reason: "resource_discovery_failed" }); + }); + + it("treats duplicate advertisements of one issuer as a single issuer, not ambiguous", async () => { + server.use( + mswHttp.get(`${RESOURCE}/.well-known/oauth-protected-resource`, () => + HttpResponse.json({ resource: RESOURCE, authorization_servers: [BC5, BC5] }) + ), + mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => + HttpResponse.json({ issuer: BC5, token_endpoint: `${BC5}/token` }) + ) + ); + const result = (await discoverFromResource(RESOURCE)) as { kind: string; issuer?: string }; + expect(result.kind).toBe("selected"); + expect(result.issuer).toBe(BC5); + }); +}); From 744e81ebcc7283522e491143d93d47a8f274e9f4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 16:56:23 -0700 Subject: [PATCH 10/38] Kotlin: lowercase origin host (case-insensitive), reject bare-query origins Review fixes for #369: - requireOriginRoot lowercases the host so a mixed-case Launchpad issuer (https://LAUNCHPAD.37signals.com) normalizes to the canonical origin and is correctly excluded by isLaunchpadIssuer (which compares requireOriginRoot outputs) instead of being committed as a distinct BC5 issuer. - Reject Url.trailingQuery too, so a bare '?' (empty query) origin is rejected like any other query-bearing origin rather than normalized away. Adds mixed-case-launchpad and bare-query scenarios. --- .../kotlin/com/basecamp/sdk/Urls.kt | 10 +++++-- .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt index 7e6a15231..f3f2e2ddf 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt @@ -103,7 +103,9 @@ internal fun requireOriginRoot(raw: String, label: String = "origin"): String { if (authority.contains('@') || !url.user.isNullOrEmpty() || !url.password.isNullOrEmpty()) { throw BasecampException.Usage("$label must not contain userinfo: ${BasecampException.truncateMessage(raw)}") } - if (url.encodedQuery.isNotEmpty() || url.encodedFragment.isNotEmpty()) { + // trailingQuery catches a bare '?' with an empty query (e.g. `https://host?`), + // whose encodedQuery is empty but which is still a query-bearing origin. + if (url.encodedQuery.isNotEmpty() || url.trailingQuery || url.encodedFragment.isNotEmpty()) { throw BasecampException.Usage("$label must not contain a query or fragment: ${BasecampException.truncateMessage(raw)}") } val path = url.encodedPath @@ -114,7 +116,11 @@ internal fun requireOriginRoot(raw: String, label: String = "origin"): String { // parseAbsoluteUrl fails closed on a malformed port, so a surviving url has a // structurally valid (possibly default) port. Rebuild the origin explicitly, // re-bracketing IPv6 literals and dropping a default port. - val host = url.host + // Lowercase the host: DNS names and schemes are case-insensitive (RFC 3986 + // §3.1/§6.2.2.1), so a mixed-case advertised issuer such as + // https://Launchpad.37signals.com normalizes to the same origin as its + // canonical form — otherwise the Launchpad exclusion misses it. + val host = url.host.lowercase() val hostForOrigin = if (host.contains(':') && !host.startsWith("[")) "[$host]" else host val portPart = if (url.port != url.protocol.defaultPort) ":${url.port}" else "" return "$scheme://$hostForOrigin$portPart" diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index 006cdc34a..4030aa345 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -531,6 +531,32 @@ class OAuthDiscoveryTest { ) ) + @Test fun `25 mixed-case launchpad host excluded`() = runScenario( + Scenario( + name = "mixed-case-launchpad-excluded", + op = Op.FROM_RESOURCE, + resourceOrigin = RESOURCE, + // Hosts are case-insensitive, so a mixed-case Launchpad host must be + // recognized as Launchpad and excluded — zero non-Launchpad issuers → + // no_as_advertised, not committed as a distinct BC5 issuer. + hop1 = Hop(body = resourceBody(RESOURCE, listOf("https://LAUNCHPAD.37signals.com"))), + fallbackReason = "no_as_advertised", + ) + ) + + @Test fun `26 origin-root bare query rejected`() = runScenario( + Scenario( + name = "origin-root-bare-query-rejected", + op = Op.PROTECTED_RESOURCE, + // A bare '?' is a query-bearing origin (empty query) and must be rejected + // like any other query, not normalized away. + resourceOrigin = "https://api.example.com?", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + @Test fun `discover surfaces issuer mismatch as api_error to external callers`() = runTest { // The module-private binding marker must NOT leak: an external discover() // caller sees an ordinary api_error, identical to any other invalid AS From d9ffe1df19dcb3c35ca424aa2090d9696d1b1e0a Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 17:03:03 -0700 Subject: [PATCH 11/38] Python: httpx.URL origin validation, appended field, result invariant, drip-timeout Review fixes for #369: - require_origin_root now parses with httpx.URL (the same parser the transport dials with) instead of urllib.urlsplit, closing a parser differential: an invalid IDNA label or an IPvFuture authority (https://[v1.foo]) is now rejected as a usage error rather than silently converted/normalized here and rewritten or rejected at request time. Port range is still enforced explicitly (httpx does not range-check). - OAuthConfig.device_authorization_endpoint is APPENDED after the pre-existing optional fields so a legacy 4-positional-arg caller still assigns registration_endpoint, not the new field. - DiscoveryResult.__post_init__ enforces the documented per-kind invariant. - Discovery streaming read gets a wall-clock deadline so a slow-drip peer (which keeps resetting httpx's per-chunk read timeout) is aborted as a retryable network timeout. Adds IDNA/IPvFuture, positional-order, and invariant tests. --- python/src/basecamp/_security.py | 46 +++++++++++-------- python/src/basecamp/oauth/config.py | 21 ++++++++- python/src/basecamp/oauth/discovery.py | 9 ++++ python/tests/oauth/test_resource_discovery.py | 35 ++++++++++++++ 4 files changed, 90 insertions(+), 21 deletions(-) diff --git a/python/src/basecamp/_security.py b/python/src/basecamp/_security.py index 5988615c4..c8960ca61 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -1,6 +1,6 @@ from __future__ import annotations -from urllib.parse import urljoin, urlparse, urlsplit +from urllib.parse import urljoin, urlparse import httpx @@ -61,40 +61,46 @@ def require_origin_root(raw: str, label: str = "origin") -> str: Returns the normalized origin (``scheme://host[:port]``, no trailing slash, default port dropped). """ + # Parse with httpx.URL — the SAME transport parser the client dials with (see + # is_localhost below and _http.py) — so validation can never disagree with the + # request about scheme/host/port. urllib and httpx diverge on IDNA labels and + # IPvFuture authorities: validating with urllib let a malformed value pass here + # yet be rewritten or rejected at request time (a parser differential). httpx + # rejects an invalid IDNA A-label (IDNAError, a UnicodeError) and an IPvFuture + # authority like "https://[v1.foo]" (InvalidURL) rather than converting them. try: - parts = urlsplit(raw) - except ValueError as exc: + url = httpx.URL(raw) + scheme = url.scheme + host = url.host + except (httpx.InvalidURL, ValueError) as exc: raise UsageError(f"Invalid {label}: not a valid absolute URL: {raw}") from exc - scheme = parts.scheme.lower() - host = parts.hostname if not scheme or not host: raise UsageError(f"Invalid {label}: not a valid absolute URL: {raw}") is_localhost_http = scheme == "http" and _is_localhost_host(host) if scheme != "https" and not is_localhost_http: raise UsageError(f"{label} must use HTTPS (or http on localhost): {raw}") - # Reject on the *presence* of userinfo, not its truthiness: urlsplit reports - # an empty (but non-None) username for authorities like "@host" or ":@host", - # so a truthiness test would let empty-credential forms slip through. An "@" - # in the netloc is a userinfo delimiter regardless of what surrounds it. - if parts.username is not None or parts.password is not None or "@" in parts.netloc: + # Reject on the *presence* of userinfo, not its truthiness: httpx drops an + # empty userinfo (authorities like "@host" report userinfo == b""), so also + # inspect the raw authority for an "@" delimiter regardless of what surrounds + # it — an "@" there is always a userinfo delimiter (a host cannot contain one). + authority = raw.split("://", 1)[-1].split("/", 1)[0].split("?", 1)[0].split("#", 1)[0] + if url.userinfo or "@" in authority: raise UsageError(f"{label} must not contain userinfo: {raw}") - if parts.query or parts.fragment: + if url.query or url.fragment: raise UsageError(f"{label} must not contain a query or fragment: {raw}") - if parts.path not in ("", "/"): + if url.path not in ("", "/"): raise UsageError(f"{label} must be an origin root (no path): {raw}") - # Accessing .port validates it (urlsplit defers port parsing); a non-numeric - # or out-of-range port raises ValueError here. - try: - port = parts.port - except ValueError as exc: - raise UsageError(f"{label} has an invalid port: {raw}") from exc + # httpx does not range-check the port (it accepts :99999), so enforce 1–65535 + # explicitly. httpx already drops an absent/default/dangling port to None. + port = url.port + if port is not None and not (1 <= port <= 65535): + raise UsageError(f"{label} has an invalid port: {raw}") host_part = f"[{host}]" if ":" in host else host - default_port = 443 if scheme == "https" else 80 - if port is None or port == default_port: + if port is None: return f"{scheme}://{host_part}" return f"{scheme}://{host_part}:{port}" diff --git a/python/src/basecamp/oauth/config.py b/python/src/basecamp/oauth/config.py index 86e65bbbb..f546638a3 100644 --- a/python/src/basecamp/oauth/config.py +++ b/python/src/basecamp/oauth/config.py @@ -19,11 +19,16 @@ class OAuthConfig: # existing ``OAuthConfig("iss", "auth", "token")`` callers. authorization_endpoint: str | None token_endpoint: str - device_authorization_endpoint: str | None = None registration_endpoint: str | None = None scopes_supported: list[str] | None = None grant_types_supported: list[str] | None = None code_challenge_methods_supported: list[str] | None = None + # APPENDED after the pre-existing optional fields, not inserted mid-list: the + # dataclass generates a positional __init__, so placing this new field ahead + # of registration_endpoint would shift every positional arg after it and + # silently misassign existing OAuthConfig("iss", "auth", "token", "reg", ...) + # callers. Keyword construction is unaffected either way. + device_authorization_endpoint: str | None = None @dataclass(frozen=True) @@ -72,6 +77,20 @@ class DiscoveryResult: issuer: str | None = None reason: FallbackReason | None = None + def __post_init__(self) -> None: + # Enforce the documented per-kind invariant that the ``| None`` types + # cannot: a public value that claims one shape but carries the other's + # fields would violate the contract callers rely on after branching on + # ``kind``. + if self.kind == "selected": + if self.config is None or self.issuer is None or self.reason is not None: + raise ValueError("selected DiscoveryResult requires config and issuer, and no reason") + elif self.kind == "fallback": + if self.reason is None or self.config is not None or self.issuer is not None: + raise ValueError("fallback DiscoveryResult requires a reason, and no config or issuer") + else: # pragma: no cover - Literal type already constrains this + raise ValueError(f"DiscoveryResult.kind must be 'selected' or 'fallback', got {self.kind!r}") + def selected_config(self) -> OAuthConfig: """Return the selected :class:`OAuthConfig`, narrowing away ``None`` for typed callers. Raises :class:`ValueError` if this is not a ``selected`` diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index 95f67367b..9e3631a49 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import time from typing import Any import httpx @@ -71,9 +72,17 @@ def _fetch_discovery_document(url: str, timeout: float, max_body_bytes: int) -> timeout=timeout, follow_redirects=False, ) as response: + # httpx's timeout is per-operation and RESETS after each received + # chunk, so a peer dripping one byte at a time never trips the read + # timeout and can hold the caller arbitrarily long. Bound the WHOLE + # response with a wall-clock deadline so a slow-drip stream is aborted + # as a retryable network timeout regardless of chunk cadence. + deadline = time.monotonic() + timeout chunks: list[bytes] = [] total = 0 for chunk in response.iter_bytes(): + if time.monotonic() > deadline: + raise OAuthError("network", "OAuth discovery timed out", retryable=True) total += len(chunk) if total > max_body_bytes: # Abort the stream — leaving the ``with`` closes the diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index f06c5ce2f..967c18542 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -17,7 +17,9 @@ from basecamp.errors import BasecampError, UsageError from basecamp.oauth import ( + DiscoveryResult, DiscoverySelectionError, + OAuthConfig, OAuthError, discover, discover_from_resource, @@ -194,6 +196,39 @@ def test_origin_root_accepts_legitimate_origins(raw: str, expected: str) -> None assert require_origin_root(raw) == expected +@pytest.mark.parametrize( + "raw", + [ + "https://[v1.foo]", # IPvFuture: urllib would strip brackets → https://v1.foo + "https://xn--invalid-.example", # invalid IDNA A-label (ends with a hyphen) + "https://example.com:99999", # out-of-range port (httpx accepts; we range-check) + ], +) +def test_origin_root_rejects_transport_unrepresentable(raw: str) -> None: + # Validate with the SAME parser the transport dials with (httpx.URL): a value + # httpx cannot represent must be rejected here, not silently converted by a + # different parser and then rewritten/rejected at request time. + with pytest.raises(UsageError): + require_origin_root(raw) + + +def test_oauthconfig_positional_registration_endpoint_slot() -> None: + # device_authorization_endpoint is APPENDED, so a legacy 4-positional-arg + # caller still lands its 4th value in registration_endpoint, not the new field. + cfg = OAuthConfig("https://iss", "https://iss/auth", "https://iss/token", "https://iss/register") + assert cfg.registration_endpoint == "https://iss/register" + assert cfg.device_authorization_endpoint is None + + +def test_discovery_result_enforces_per_kind_invariant() -> None: + with pytest.raises(ValueError): + DiscoveryResult(kind="selected", config=None, issuer=None) # missing config/issuer + with pytest.raises(ValueError): + DiscoveryResult(kind="fallback", reason=None) # missing reason + with pytest.raises(ValueError): + DiscoveryResult(kind="fallback", reason="no_as_advertised", issuer="https://iss") # stray issuer + + @respx.mock def test_device_only_as_omits_authorization_endpoint() -> None: issuer = ORIGINS["{{ISSUER_ORIGIN}}"] From 98971d962dcefe1eb76ad76a748a36da1d889a81 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 17:11:04 -0700 Subject: [PATCH 12/38] Ruby: issuer code-point bind, enforceable redirect policy, present-null + parser hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #369: - Issuer binding (P1): resource-first discovery binds the AS metadata issuer to the RAW advertised issuer by code-point (via a new discover(expected_issuer:)), fetching from the normalized origin only. An AS whose issuer matches the advertised trailing-slash/default-port form no longer yields a false issuer_mismatch. - Redirect suppression (P1): replace the bypassable class-NAME heuristic with an enforceable adapter-only policy — an injected connection carrying ANY middleware (a redirect follower under any name, etc.) is refused. - Reject a present JSON null for endpoints and for grant_types_supported/ scopes_supported (a present null is malformed, distinct from an absent key). - require_origin_root! rejects a dangling port delimiter (https://host:). - Resource normalizes max_body_bytes to a finite non-negative Integer so a nil/ float/Infinity cannot disable the streaming memory cap. - authorization.get wraps the credentialed fetch in with_operation (observability hooks) and documents its actual string-keyed return shape. Adds tests for each. --- ruby/lib/basecamp/oauth.rb | 10 ++-- ruby/lib/basecamp/oauth/discovery.rb | 24 +++++++--- ruby/lib/basecamp/oauth/fetcher.rb | 28 ++++++----- ruby/lib/basecamp/oauth/resource.rb | 10 +++- ruby/lib/basecamp/security.rb | 9 ++++ .../services/authorization_service.rb | 10 +++- ruby/test/basecamp/oauth_ssrf_test.rb | 22 +++++++++ ruby/test/basecamp/oauth_test.rb | 48 +++++++++++++++++++ ruby/test/basecamp/security_test.rb | 9 ++++ 9 files changed, 147 insertions(+), 23 deletions(-) diff --git a/ruby/lib/basecamp/oauth.rb b/ruby/lib/basecamp/oauth.rb index 5612e06c2..29eeca908 100644 --- a/ruby/lib/basecamp/oauth.rb +++ b/ruby/lib/basecamp/oauth.rb @@ -58,8 +58,8 @@ module Oauth # @param base_url [String] the OAuth server's issuer origin # @param timeout [Integer] request timeout in seconds # @return [Config] - def self.discover(base_url, timeout: 10) - Discovery.new(timeout: timeout).discover(base_url) + def self.discover(base_url, timeout: 10, expected_issuer: nil) + Discovery.new(timeout: timeout).discover(base_url, expected_issuer: expected_issuer) end def self.discover_launchpad(timeout: 10) @@ -210,7 +210,11 @@ def self.bind_issuer(selected_issuer, timeout) end config = begin - discover(issuer_origin, timeout: timeout) + # Fetch from the normalized origin, but bind the AS metadata issuer to the + # RAW advertised issuer by code-point: an AS whose issuer equals what the + # resource advertised must not be rejected merely because normalization + # dropped a trailing slash / default port before the bind. + discover(issuer_origin, timeout: timeout, expected_issuer: selected_issuer) rescue OauthError => e raise as_failure_error(issuer_origin, e) end diff --git a/ruby/lib/basecamp/oauth/discovery.rb b/ruby/lib/basecamp/oauth/discovery.rb index b0d2dc044..88e5a97e2 100644 --- a/ruby/lib/basecamp/oauth/discovery.rb +++ b/ruby/lib/basecamp/oauth/discovery.rb @@ -42,11 +42,19 @@ def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_M # @example # config = Basecamp::Oauth::Discovery.new.discover("https://launchpad.37signals.com") # config.token_endpoint # => "https://launchpad.37signals.com/authorization/token" - def discover(base_url) + # +expected_issuer+ is the identifier the AS metadata's +issuer+ must equal + # by code-point (RFC 8414 §3.3). It defaults to the normalized origin for a + # direct discover, but resource-first discovery passes the RAW advertised + # issuer so an AS whose issuer matches what the resource advertised (e.g. a + # trailing slash or explicit default port) binds instead of being normalized + # away into a false +issuer_mismatch+. The fetch URL is built from the + # normalized origin either way (that only cleans the origin string; the + # well-known path is identical). + def discover(base_url, expected_issuer: nil) issuer_origin = Basecamp::Security.require_origin_root!(base_url, "OAuth discovery base URL") discovery_url = "#{issuer_origin}/.well-known/oauth-authorization-server" data = Fetcher.fetch_json(@http_client, discovery_url, timeout: @timeout, max_body_bytes: @max_body_bytes) - parse_and_bind(data, issuer_origin) + parse_and_bind(data, expected_issuer || issuer_origin) end private @@ -89,10 +97,11 @@ def parse_and_bind(data, expected_issuer_origin) # Any endpoint field that IS present must be a non-empty String: "" is a # truthy value in Ruby and must be rejected, and a non-string endpoint - # (array/number/object) is malformed metadata. + # (array/number/object, or a present JSON null) is malformed metadata — + # a present null is NOT the same as an absent key. def reject_empty_endpoints!(data) data.each do |key, value| - next unless key.end_with?("_endpoint") && !value.nil? + next unless key.end_with?("_endpoint") unless value.is_a?(String) && !value.empty? raise OauthError.new("api_error", "Invalid OAuth discovery response: invalid #{key}") @@ -105,9 +114,12 @@ def reject_empty_endpoints!(data) # accepted: substring-matching +grant_types_supported+ could falsely enable # a grant such as device_code, and a non-array is malformed metadata. def validate_string_array!(data, key) - value = data[key] - return if value.nil? + # Distinguish an ABSENT key from a present JSON null: a present null is + # malformed (the field must be an array of strings when present), while + # an absent key is legitimately unset. + return unless data.key?(key) + value = data[key] unless value.is_a?(Array) && value.all?(String) raise OauthError.new( "api_error", diff --git a/ruby/lib/basecamp/oauth/fetcher.rb b/ruby/lib/basecamp/oauth/fetcher.rb index 8fa9f2f27..76288bf30 100644 --- a/ruby/lib/basecamp/oauth/fetcher.rb +++ b/ruby/lib/basecamp/oauth/fetcher.rb @@ -66,25 +66,31 @@ def self.build_client(timeout) end end - # Rejects an INJECTED connection that carries redirect-following middleware. - # Redirect suppression is a load-bearing SSRF control (RFC 9728 §7.7): a - # caller-supplied client with +FollowRedirects+ would silently chase an - # attacker-controlled +Location+, so we refuse it rather than harden it in - # place (the middleware stack of a foreign connection is not ours to - # rewrite). The default {build_client} connection carries no such - # middleware and passes. + # Rejects an INJECTED connection whose middleware stack we cannot verify to + # be redirect-free. Redirect suppression is a load-bearing SSRF control (RFC + # 9728 §7.7): a caller-supplied client that follows redirects would silently + # chase an attacker-controlled +Location+. A class-NAME heuristic (matching + # +/redirect/+) is bypassable by a follower whose class name does not contain + # "redirect", so we enforce a POLICY instead of guessing by name: an injected + # connection may carry ONLY adapter handlers. The default {build_client} + # connection (adapter only) and a test's mock adapter qualify; ANY request/ + # response middleware — which could follow redirects under any name, or + # otherwise rewrite the request — is refused rather than trusted. # # @param client [Faraday::Connection] - # @raise [OauthError] +validation+ when redirect middleware is present + # @raise [OauthError] +validation+ when non-adapter middleware is present def self.ensure_redirects_suppressed!(client) handlers = client.respond_to?(:builder) ? Array(client.builder.handlers) : [] - offending = handlers.find { |h| h.respond_to?(:klass) && h.klass.name.to_s.match?(/redirect/i) } + offending = handlers.find do |h| + h.respond_to?(:klass) && h.klass.is_a?(Class) && !(h.klass <= Faraday::Adapter) + end return unless offending raise OauthError.new( "validation", - "Injected OAuth discovery client must not follow redirects " \ - "(found #{offending.klass.name}); redirects are suppressed for SSRF safety" + "Injected OAuth discovery client must carry only an adapter (no middleware); " \ + "found #{offending.klass.name}. Redirects are suppressed for SSRF safety, so a " \ + "connection whose middleware stack cannot be verified redirect-free is refused" ) end diff --git a/ruby/lib/basecamp/oauth/resource.rb b/ruby/lib/basecamp/oauth/resource.rb index f8a6b910c..6375cfe38 100644 --- a/ruby/lib/basecamp/oauth/resource.rb +++ b/ruby/lib/basecamp/oauth/resource.rb @@ -12,7 +12,15 @@ def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_M Fetcher.ensure_redirects_suppressed!(http_client) if http_client @http_client = http_client || Fetcher.build_client(timeout) @timeout = timeout - @max_body_bytes = max_body_bytes + # Normalize the public cap to a finite non-negative Integer: a nil, float, + # or Float::INFINITY would otherwise disable the streaming memory bound + # (an infinite/undefined cap never trips), reintroducing an SSRF/OOM risk. + @max_body_bytes = + if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0 + max_body_bytes + else + Fetcher::DEFAULT_MAX_BODY_BYTES + end end # Discovers protected-resource metadata from diff --git a/ruby/lib/basecamp/security.rb b/ruby/lib/basecamp/security.rb index 11cd46ebb..b39a6a93a 100644 --- a/ruby/lib/basecamp/security.rb +++ b/ruby/lib/basecamp/security.rb @@ -119,6 +119,15 @@ def self.require_origin_root!(raw, label = "origin") raise UsageError.new("#{label} must be an origin root (no path): #{raw}") end + # A dangling port delimiter ("https://example.com:") parses to the + # default-port origin under URI, silently accepting a malformed authority. + # Reject the empty port explicitly. IPv6 authorities legitimately end with + # "]" (e.g. "[::1]"), so only a trailing ":" is a dangling port. + authority = raw.to_s.split("://", 2)[1].to_s.split(%r{[/?#]}, 2)[0].to_s + if authority.end_with?(":") + raise UsageError.new("#{label} has an invalid port: #{raw}") + end + # URI.parse rejects a non-numeric port, but it happily accepts a numeric # port outside the valid TCP range (e.g. :0 or :99999). Reject anything # outside 1–65535 so a structurally-parseable-but-undialable port can never diff --git a/ruby/lib/basecamp/services/authorization_service.rb b/ruby/lib/basecamp/services/authorization_service.rb index bea0961c1..b0949ea2e 100644 --- a/ruby/lib/basecamp/services/authorization_service.rb +++ b/ruby/lib/basecamp/services/authorization_service.rb @@ -27,12 +27,18 @@ class AuthorizationService < BaseService # Returns the authenticated user's identity and list of accounts # they have access to. # - # @return [Hash] authorization info with :identity and :accounts + # @return [Hash] authorization info with string keys "identity" and + # "accounts" (parsed JSON, not symbol keys) # @raise [Oauth::DiscoverySelectionError] on a hard discovery failure after # a BC5 issuer was advertised and selected # @see https://github.com/basecamp/bc3-api/blob/master/sections/authentication.md def get - http.get_authorization_document.json + # Wrap in with_operation like every other service call so observability + # hooks (logging, metrics, tracing) see this credentialed request and its + # failures too. + with_operation(service: "authorization", operation: "get", is_mutation: false) do + http.get_authorization_document.json + end end end end diff --git a/ruby/test/basecamp/oauth_ssrf_test.rb b/ruby/test/basecamp/oauth_ssrf_test.rb index 36b6ecae2..b5866361e 100644 --- a/ruby/test/basecamp/oauth_ssrf_test.rb +++ b/ruby/test/basecamp/oauth_ssrf_test.rb @@ -97,6 +97,28 @@ def test_injected_client_carrying_redirect_middleware_is_rejected assert_equal "validation", resource_error.type end + # A follower whose class name does NOT contain "redirect" — the old class-name + # heuristic would have missed it. + class SneakyLocationFollower < Faraday::Middleware + def call(env) + @app.call(env) + end + end + + def test_injected_client_with_non_redirect_named_middleware_is_rejected + # The enforceable policy (adapter-only) refuses ANY middleware, so a redirect + # follower under an innocuous name can no longer bypass the check. + connection = Faraday.new do |conn| + conn.use SneakyLocationFollower + conn.adapter Faraday.default_adapter + end + + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth::Discovery.new(http_client: connection) + end + assert_equal "validation", error.type + end + def test_redirect_is_not_followed issuer = "https://issuer.redirect-test.example" attacker = "https://attacker.example.com" diff --git a/ruby/test/basecamp/oauth_test.rb b/ruby/test/basecamp/oauth_test.rb index fe7f0bd76..55fe0ce49 100644 --- a/ruby/test/basecamp/oauth_test.rb +++ b/ruby/test/basecamp/oauth_test.rb @@ -94,6 +94,54 @@ def test_discover_rejects_wrong_typed_endpoint assert_includes error.message, "device_authorization_endpoint" end + def test_discover_rejects_present_null_endpoint + # A present JSON null endpoint is malformed metadata, NOT an absent key. + discovery_response = { + "issuer" => "https://launchpad.37signals.com", + "token_endpoint" => "https://launchpad.37signals.com/token", + "registration_endpoint" => nil + } + stub_request(:get, "https://launchpad.37signals.com/.well-known/oauth-authorization-server") + .to_return(status: 200, body: discovery_response.to_json, headers: { "Content-Type" => "application/json" }) + + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth.discover_launchpad + end + assert_equal "api_error", error.type + assert_includes error.message, "registration_endpoint" + end + + def test_discover_rejects_present_null_grant_types + discovery_response = { + "issuer" => "https://launchpad.37signals.com", + "token_endpoint" => "https://launchpad.37signals.com/token", + "grant_types_supported" => nil + } + stub_request(:get, "https://launchpad.37signals.com/.well-known/oauth-authorization-server") + .to_return(status: 200, body: discovery_response.to_json, headers: { "Content-Type" => "application/json" }) + + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth.discover_launchpad + end + assert_equal "api_error", error.type + assert_includes error.message, "grant_types_supported" + end + + def test_discover_binds_metadata_issuer_to_expected_issuer_code_point + # The RFC 8414 code-point bind is against the advertised issuer, so an AS + # whose issuer matches the advertised trailing-slash form must bind rather + # than be normalized away into a false issuer_mismatch. + discovery_response = { + "issuer" => "https://bc5.example/", + "token_endpoint" => "https://bc5.example/token" + } + stub_request(:get, "https://bc5.example/.well-known/oauth-authorization-server") + .to_return(status: 200, body: discovery_response.to_json, headers: { "Content-Type" => "application/json" }) + + config = Basecamp::Oauth.discover("https://bc5.example", expected_issuer: "https://bc5.example/") + assert_equal "https://bc5.example/", config.issuer + end + def test_discover_rejects_scopes_supported_not_array_of_strings discovery_response = { "issuer" => "https://launchpad.37signals.com", diff --git a/ruby/test/basecamp/security_test.rb b/ruby/test/basecamp/security_test.rb index a7db26bb7..4febaf114 100644 --- a/ruby/test/basecamp/security_test.rb +++ b/ruby/test/basecamp/security_test.rb @@ -221,6 +221,15 @@ def test_rejects_port_zero assert_match(/port/, error.message) end + def test_rejects_dangling_port_delimiter + # "https://example.com:" parses to the default-port origin under URI, silently + # accepting a malformed authority; the empty port must be rejected. + error = assert_raises(Basecamp::UsageError) do + Basecamp::Security.require_origin_root!("https://example.com:") + end + assert_match(/port/, error.message) + end + def test_rejects_path_beyond_root assert_raises(Basecamp::UsageError) do Basecamp::Security.require_origin_root!("https://example.com/foo") From 7ca2f1227596c2face1a0ef3b5a1fe819be06e9d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 17:11:59 -0700 Subject: [PATCH 13/38] conformance: require error on raise, fallbackReason on fallback (oauth fixtures) Review fix for #369: add if/then conditional validation to the oauth fixture schema so a raise fixture missing 'error' (or a fallback fixture missing 'fallbackReason') is rejected at schema-validation time rather than passing the schema and failing only at test runtime. --- conformance/oauth/schema.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/conformance/oauth/schema.json b/conformance/oauth/schema.json index c775c3f12..2f10b6aea 100644 --- a/conformance/oauth/schema.json +++ b/conformance/oauth/schema.json @@ -94,7 +94,19 @@ "enum": ["usage", "validation", "api_error", "network", "auth_required"], "description": "On `raise`, the coarse BasecampError category the thrown error must map to (asserted cross-SDK alongside the typed reason)." } - } + }, + "allOf": [ + { + "description": "A raise fixture must state the typed error it expects.", + "if": { "required": ["outcome"], "properties": { "outcome": { "const": "raise" } } }, + "then": { "required": ["error"] } + }, + { + "description": "A fallback fixture must state the soft reason it expects.", + "if": { "required": ["outcome"], "properties": { "outcome": { "const": "fallback" } } }, + "then": { "required": ["fallbackReason"] } + } + ] } } } From 42853243a660ac747e1ae54bfc3fa95b99117f9a Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 17:52:59 -0700 Subject: [PATCH 14/38] oauth: address #369 review round under the three shared discovery contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batches the remaining resource-first-discovery review findings under the reviewer's three contracts rather than thread-by-thread. Issuer-binding contract (preserve the advertised string; normalize only for routing): - TS: discover() gains an internal bindIssuer so discoverFromResource binds AS metadata against the exact advertised issuer while fetching from the normalized origin (a trailing slash / explicit :443 no longer mis-binds). - Ruby: document discover(expected_issuer:) as the code-point binding override. Fetch-discipline contract (transport parser, redirect policy, bounded reads): - Ruby fetcher: fold the terminal adapter handler into the redirects-suppressed policy check — a non-adapter follower smuggled into the conn.adapter slot lives outside builder.handlers and previously evaded the scan. - Ruby Discovery: normalize max_body_bytes to a finite non-negative Integer (as Resource already does) so an Infinity/float/nil cap can't disable the streaming memory bound. - Python _security: bind against the ASCII host httpx dials (raw_host), not the IDNA-decoded Unicode host, so an AS echoing its ASCII issuer still matches by code-point; still force IDNA validation to reject unrepresentable A-labels. - Python _security: reject a bare trailing "?"/"#" that httpx collapses to an empty (falsy) query/fragment, matching Kotlin/Ruby. - TS requireOriginRoot: reject the same bare query/fragment delimiter that WHATWG URL normalizes away. Selection strictness: - Dedupe the exclusion heuristic by code-point in Go/Python/Ruby/Kotlin (TS already did): the same non-Launchpad issuer advertised twice is one candidate, not an ambiguity. New shared fixture 24 enforces this across all five SDKs. - TS: validate scopes_supported as an array of strings before returning it, matching grant_types_supported / code_challenge_methods_supported. - Python DiscoveryResult: a fallback must carry a real FallbackReason member, not an arbitrary string. Tests added for each across TS/Python/Ruby; Go/Kotlin covered by fixture 24. --- .../fixtures/24-duplicate-issuer-deduped.json | 28 ++++++++++++ go/pkg/basecamp/oauth/discovery.go | 12 ++++- .../com/basecamp/sdk/oauth/Discovery.kt | 4 +- python/src/basecamp/_security.py | 16 ++++++- python/src/basecamp/oauth/config.py | 7 ++- python/src/basecamp/oauth/discovery.py | 4 +- python/tests/oauth/test_resource_discovery.py | 45 +++++++++++++++++++ ruby/lib/basecamp/oauth.rb | 9 +++- ruby/lib/basecamp/oauth/discovery.rb | 11 ++++- ruby/lib/basecamp/oauth/fetcher.rb | 14 +++++- ruby/test/basecamp/oauth_ssrf_test.rb | 27 +++++++++++ typescript/src/oauth/discovery.ts | 32 +++++++++++-- .../tests/oauth/resource-discovery.test.ts | 37 +++++++++++++++ 13 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 conformance/oauth/fixtures/24-duplicate-issuer-deduped.json diff --git a/conformance/oauth/fixtures/24-duplicate-issuer-deduped.json b/conformance/oauth/fixtures/24-duplicate-issuer-deduped.json new file mode 100644 index 000000000..5c0c5ff02 --- /dev/null +++ b/conformance/oauth/fixtures/24-duplicate-issuer-deduped.json @@ -0,0 +1,28 @@ +{ + "name": "duplicate-issuer-deduped", + "description": "The same non-Launchpad issuer advertised more than once is ONE candidate: the exclusion heuristic dedupes by code-point and selects it, rather than raising ambiguous_issuers.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": true, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": ["{{BC5_ISSUER}}", "{{BC5_ISSUER}}", "{{LAUNCHPAD_ORIGIN}}"] + } + }, + "hop2": { + "origin": "{{BC5_ISSUER}}", + "status": 200, + "body": { + "issuer": "{{BC5_ISSUER}}", + "authorization_endpoint": "{{BC5_ISSUER}}/oauth/authorize", + "token_endpoint": "{{BC5_ISSUER}}/oauth/token" + } + }, + "expect": { + "outcome": "selected", + "selectedIssuer": "{{BC5_ISSUER}}", + "launchpadContacted": false + } +} diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index 4b8d8ad6c..5cd4edd7c 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -511,11 +511,19 @@ func (d *Discoverer) DiscoverFromResource(ctx context.Context, resourceOrigin st fmt.Sprintf("expected issuer %q is not advertised by the resource", cfg.expectedIssuer), nil) } } else { + // Dedupe by code-point: the same non-Launchpad issuer advertised more than + // once is ONE candidate, not an ambiguity. nonLaunchpad := make([]string, 0, len(advertised)) + seen := make(map[string]struct{}, len(advertised)) for _, s := range advertised { - if !isLaunchpadIssuer(s) { - nonLaunchpad = append(nonLaunchpad, s) + if isLaunchpadIssuer(s) { + continue } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + nonLaunchpad = append(nonLaunchpad, s) } switch { case len(nonLaunchpad) >= 2: diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index b67822b98..7745db01f 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -360,7 +360,9 @@ suspend fun discoverFromResource( "Expected issuer \"$expectedIssuer\" is not advertised by the resource", ) } else { - val nonLaunchpad = advertised.filterNot { isLaunchpadIssuer(it) } + // Dedupe by code-point: the same non-Launchpad issuer advertised more + // than once is ONE candidate, not an ambiguity. + val nonLaunchpad = advertised.filterNot { isLaunchpadIssuer(it) }.distinct() when { nonLaunchpad.size >= 2 -> throw BasecampException.DiscoverySelection( "ambiguous_issuers", diff --git a/python/src/basecamp/_security.py b/python/src/basecamp/_security.py index c8960ca61..aba5eb2b8 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -71,7 +71,15 @@ def require_origin_root(raw: str, label: str = "origin") -> str: try: url = httpx.URL(raw) scheme = url.scheme - host = url.host + # Force IDNA validation: `.host` decodes the A-label and raises IDNAError + # (a UnicodeError) on an invalid one (e.g. a trailing-hyphen label httpx + # cannot represent), matching what the transport would reject at dial time. + _ = url.host + # Bind against the ASCII host the transport actually dials (raw_host is + # the IDNA A-label form), NOT the decoded Unicode `.host`: an AS that + # correctly echoes its ASCII issuer/resource URI must still match by + # code-point. `.host` would rewrite "xn--..." to Unicode and break that. + host = url.raw_host.decode("ascii") except (httpx.InvalidURL, ValueError) as exc: raise UsageError(f"Invalid {label}: not a valid absolute URL: {raw}") from exc @@ -88,7 +96,11 @@ def require_origin_root(raw: str, label: str = "origin") -> str: authority = raw.split("://", 1)[-1].split("/", 1)[0].split("?", 1)[0].split("#", 1)[0] if url.userinfo or "@" in authority: raise UsageError(f"{label} must not contain userinfo: {raw}") - if url.query or url.fragment: + # httpx collapses a bare trailing "?" to an empty query (b"") and a bare "#" + # to an empty fragment (""), both falsy, so the parsed fields can't tell a + # bare delimiter from none. Scan the raw input too: any "?"/"#" past the + # scheme is a delimiter here (host/port carry neither; path is ""/"/" below). + if url.query or url.fragment or "?" in raw or "#" in raw: raise UsageError(f"{label} must not contain a query or fragment: {raw}") if url.path not in ("", "/"): raise UsageError(f"{label} must be an origin root (no path): {raw}") diff --git a/python/src/basecamp/oauth/config.py b/python/src/basecamp/oauth/config.py index f546638a3..36373b1ba 100644 --- a/python/src/basecamp/oauth/config.py +++ b/python/src/basecamp/oauth/config.py @@ -86,8 +86,11 @@ def __post_init__(self) -> None: if self.config is None or self.issuer is None or self.reason is not None: raise ValueError("selected DiscoveryResult requires config and issuer, and no reason") elif self.kind == "fallback": - if self.reason is None or self.config is not None or self.issuer is not None: - raise ValueError("fallback DiscoveryResult requires a reason, and no config or issuer") + # The reason must be a real FallbackReason member, not merely non-None: + # a public fallback result carrying an arbitrary string would let a + # caller branching on the two documented soft outcomes see an invalid one. + if not isinstance(self.reason, FallbackReason) or self.config is not None or self.issuer is not None: + raise ValueError("fallback DiscoveryResult requires a valid FallbackReason, and no config or issuer") else: # pragma: no cover - Literal type already constrains this raise ValueError(f"DiscoveryResult.kind must be 'selected' or 'fallback', got {self.kind!r}") diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index 9e3631a49..97c491f63 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -294,7 +294,9 @@ def discover_from_resource( f'Expected issuer "{expected_issuer}" is not advertised by the resource', ) else: - non_launchpad = [s for s in advertised if not _is_launchpad_issuer(s)] + # Dedupe by code-point (order-preserving): the same non-Launchpad issuer + # advertised more than once is ONE candidate, not an ambiguity. + non_launchpad = list(dict.fromkeys(s for s in advertised if not _is_launchpad_issuer(s))) if len(non_launchpad) >= 2: raise DiscoverySelectionError( "ambiguous_issuers", diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 967c18542..95818a68e 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -168,6 +168,21 @@ def test_origin_root_drops_default_port() -> None: assert require_origin_root("https://api.example.com:443") == "https://api.example.com" +def test_origin_root_preserves_idna_a_label_ascii() -> None: + # An ASCII A-label origin must round-trip as ASCII (raw_host), not be + # rewritten to httpx's IDNA-decoded Unicode form: an AS echoing its ASCII + # issuer/resource URI must still bind by code-point. + assert require_origin_root("https://xn--e1afmkfd.example") == "https://xn--e1afmkfd.example" + + +@pytest.mark.parametrize("raw", ["https://api.example.com?", "https://api.example.com#"]) +def test_origin_root_rejects_bare_query_or_fragment(raw: str) -> None: + # httpx collapses a bare "?"/"#" to an empty (falsy) query/fragment, so the + # parsed fields miss it; the raw scan must still reject the delimiter. + with pytest.raises(UsageError, match="query or fragment"): + require_origin_root(raw) + + @pytest.mark.parametrize( "raw", [ @@ -251,6 +266,27 @@ def test_device_only_as_omits_authorization_endpoint() -> None: assert "urn:ietf:params:oauth:grant-type:device_code" in (config.grant_types_supported or []) +@respx.mock +def test_duplicate_advertised_issuer_is_one_candidate_not_ambiguous() -> None: + # The same non-Launchpad issuer advertised twice is ONE candidate: the + # exclusion heuristic dedupes by code-point rather than raising ambiguous. + resource_origin = ORIGINS["{{RESOURCE_ORIGIN}}"] + bc5 = ORIGINS["{{BC5_ISSUER}}"] + respx.get(f"{resource_origin}{WELL_KNOWN_RESOURCE}").mock( + return_value=httpx.Response( + 200, json={"resource": resource_origin, "authorization_servers": [bc5, bc5]} + ) + ) + respx.get(f"{bc5}{WELL_KNOWN_AS}").mock( + return_value=httpx.Response(200, json={"issuer": bc5, "token_endpoint": f"{bc5}/token"}) + ) + + result = discover_from_resource(resource_origin) + + assert result.kind == "selected" + assert result.issuer == bc5 + + @respx.mock def test_protected_resource_preserves_absent_vs_empty() -> None: origin = "https://api.basecamp-test.example" @@ -402,3 +438,12 @@ def test_selected_config_raises_on_fallback_result() -> None: with pytest.raises(ValueError): result.selected_config() + + +def test_fallback_result_rejects_non_fallbackreason() -> None: + from basecamp.oauth import DiscoveryResult + + # A fallback must carry a real FallbackReason member; an arbitrary string is + # an invalid public result and must be refused at construction. + with pytest.raises(ValueError, match="FallbackReason"): + DiscoveryResult(kind="fallback", reason="made_up_reason") # type: ignore[arg-type] diff --git a/ruby/lib/basecamp/oauth.rb b/ruby/lib/basecamp/oauth.rb index 29eeca908..fff1e4eb2 100644 --- a/ruby/lib/basecamp/oauth.rb +++ b/ruby/lib/basecamp/oauth.rb @@ -57,6 +57,11 @@ module Oauth # # @param base_url [String] the OAuth server's issuer origin # @param timeout [Integer] request timeout in seconds + # @param expected_issuer [String, nil] optional exact identifier the AS + # metadata's +issuer+ must equal by code-point (RFC 8414 §3.3/§4). When + # +nil+, +issuer+ binds to +base_url+'s normalized origin. Pass the raw + # advertised issuer to bind against a form that normalizes differently + # (e.g. a trailing slash) while still fetching from the normalized origin. # @return [Config] def self.discover(base_url, timeout: 10, expected_issuer: nil) Discovery.new(timeout: timeout).discover(base_url, expected_issuer: expected_issuer) @@ -186,7 +191,9 @@ def self.select_expected(advertised, expected_issuer) # non-Launchpad issuer selects it; two or more is a hard +ambiguous_issuers+ # (never guess); zero returns +nil+ (caller yields the soft fallback). def self.select_by_exclusion(advertised) - non_launchpad = advertised.reject { |server| launchpad_issuer?(server) } + # Dedupe by code-point: the same non-Launchpad issuer advertised twice + # (e.g. [BC5, BC5, Launchpad]) is ONE candidate, not an ambiguity. + non_launchpad = advertised.reject { |server| launchpad_issuer?(server) }.uniq if non_launchpad.length >= 2 raise DiscoverySelectionError.new( "ambiguous_issuers", diff --git a/ruby/lib/basecamp/oauth/discovery.rb b/ruby/lib/basecamp/oauth/discovery.rb index 88e5a97e2..97a8b1553 100644 --- a/ruby/lib/basecamp/oauth/discovery.rb +++ b/ruby/lib/basecamp/oauth/discovery.rb @@ -25,7 +25,16 @@ def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_M Fetcher.ensure_redirects_suppressed!(http_client) if http_client @http_client = http_client || Fetcher.build_client(timeout) @timeout = timeout - @max_body_bytes = max_body_bytes + # Normalize the public cap to a finite non-negative Integer: a nil, float, + # or Float::INFINITY would otherwise disable the streaming memory bound + # (an infinite/undefined cap never trips +total > max_body_bytes+), + # reintroducing an SSRF/OOM risk. Mirrors Resource#initialize. + @max_body_bytes = + if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0 + max_body_bytes + else + Fetcher::DEFAULT_MAX_BODY_BYTES + end end # Discovers OAuth configuration from diff --git a/ruby/lib/basecamp/oauth/fetcher.rb b/ruby/lib/basecamp/oauth/fetcher.rb index 76288bf30..94a1fdeae 100644 --- a/ruby/lib/basecamp/oauth/fetcher.rb +++ b/ruby/lib/basecamp/oauth/fetcher.rb @@ -80,8 +80,18 @@ def self.build_client(timeout) # @param client [Faraday::Connection] # @raise [OauthError] +validation+ when non-adapter middleware is present def self.ensure_redirects_suppressed!(client) - handlers = client.respond_to?(:builder) ? Array(client.builder.handlers) : [] - offending = handlers.find do |h| + return unless client.respond_to?(:builder) + + builder = client.builder + handlers = Array(builder.handlers) + # Faraday keeps the TERMINAL adapter handler OUTSIDE builder.handlers, so + # a redirect-follower smuggled into the adapter slot (+conn.adapter Follower+, + # not validated to be a Faraday::Adapter subclass) would evade a + # handlers-only scan and run as the terminal app. Fold the adapter into + # the same policy check: a genuine adapter (<= Faraday::Adapter) passes; + # any non-adapter class in that slot is refused. + handlers += [ builder.adapter ] if builder.respond_to?(:adapter) + offending = handlers.compact.find do |h| h.respond_to?(:klass) && h.klass.is_a?(Class) && !(h.klass <= Faraday::Adapter) end return unless offending diff --git a/ruby/test/basecamp/oauth_ssrf_test.rb b/ruby/test/basecamp/oauth_ssrf_test.rb index b5866361e..98aae346f 100644 --- a/ruby/test/basecamp/oauth_ssrf_test.rb +++ b/ruby/test/basecamp/oauth_ssrf_test.rb @@ -119,6 +119,33 @@ def test_injected_client_with_non_redirect_named_middleware_is_rejected assert_equal "validation", error.type end + def test_injected_client_with_follower_in_adapter_slot_is_rejected + # Faraday keeps the terminal adapter handler OUTSIDE +builder.handlers+, so a + # non-adapter follower smuggled into the adapter slot (+conn.adapter Follower+) + # would evade a handlers-only scan yet run as the terminal app. The policy + # check must fold the adapter in and refuse it. + connection = Faraday.new do |conn| + conn.adapter SneakyLocationFollower + end + + error = assert_raises(Basecamp::Oauth::OauthError) do + Basecamp::Oauth::Discovery.new(http_client: connection) + end + assert_equal "validation", error.type + end + + def test_discovery_normalizes_non_integer_body_cap_to_default + # A nil/float/Float::INFINITY cap would disable the streaming memory bound + # (an infinite/undefined cap never trips), reintroducing an SSRF/OOM risk. + # Discovery must normalize it to the finite default, as Resource does. + default = Basecamp::Oauth::Fetcher::DEFAULT_MAX_BODY_BYTES + [ Float::INFINITY, nil, 1.5, -1, "big" ].each do |bad| + discovery = Basecamp::Oauth::Discovery.new(max_body_bytes: bad) + assert_equal default, discovery.instance_variable_get(:@max_body_bytes), + "expected #{bad.inspect} to normalize to the default cap" + end + end + def test_redirect_is_not_followed issuer = "https://issuer.redirect-test.example" attacker = "https://attacker.example.com" diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index f92f60024..89b922195 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -74,6 +74,16 @@ export interface DiscoverOptions { * the default cap applies (the bound cannot be disabled). */ maxBodyBytes?: number; + /** + * Internal: the exact issuer string the AS metadata's `issuer` must equal by + * code-point (RFC 8414 §3.3/§4). `discoverFromResource` passes the advertised + * issuer here so binding stays code-point-exact even when it differs from the + * normalized fetch origin (e.g. a trailing slash or explicit `:443`). Defaults + * to the normalized origin when omitted. + * + * @internal + */ + bindIssuer?: string; } /** @@ -153,7 +163,12 @@ export function requireOriginRoot(raw: string, label = "origin"): string { if (url.username || url.password || rawAuthority.includes("@")) { throw new BasecampError("usage", `${label} must not contain userinfo: ${raw}`); } - if (url.search || url.hash) { + // WHATWG `URL` exposes a bare trailing "?" or "#" (e.g. "https://host?") as an + // EMPTY search/hash and normalizes it away from `origin`, so the parsed fields + // alone miss the delimiter. Also scan the raw input: any "?"/"#" past the + // scheme is a query/fragment delimiter here (host/port carry neither, and the + // path is constrained to ""/"/" below). + if (url.search || url.hash || raw.includes("?") || raw.includes("#")) { throw new BasecampError("usage", `${label} must not contain a query or fragment: ${raw}`); } if (url.pathname !== "" && url.pathname !== "/") { @@ -336,7 +351,9 @@ export async function discover( throw new BasecampError("api_error", "OAuth discovery response is not a JSON object"); } - return parseAndBindAsMetadata(data, issuerOrigin); + // Fetch from the normalized origin, but bind `issuer` against the caller's + // exact advertised string when supplied (routing vs binding are distinct). + return parseAndBindAsMetadata(data, options.bindIssuer ?? issuerOrigin); } /** @@ -407,6 +424,15 @@ function parseAndBindAsMetadata( "Invalid OAuth discovery response: code_challenge_methods_supported must be an array of strings" ); } + // scopes_supported, when present, must be an array of strings — a bare string + // ("read write") or null would otherwise reach callers typed as an array and + // yield substring/null behavior instead of an api_error. + if (data.scopes_supported !== undefined && !isStringArray(data.scopes_supported)) { + throw new BasecampError( + "api_error", + "Invalid OAuth discovery response: scopes_supported must be an array of strings" + ); + } return { issuer: data.issuer, @@ -545,7 +571,7 @@ export async function discoverFromResource( let config: OAuthConfig; try { - config = await discover(issuerOrigin, discoverOptions); + config = await discover(issuerOrigin, { ...discoverOptions, bindIssuer: selectedIssuer }); } catch (err) { // Distinguish issuer-binding mismatch from a generic fetch failure via the // structural marker (never the message text). diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index eb9a91f2c..2e4efa386 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -345,6 +345,16 @@ describe("requireOriginRoot userinfo rejection", () => { expect(() => requireOriginRoot(raw)).toThrow(/userinfo/); } ); + + // A bare trailing "?"/"#" is normalized to empty search/hash by WHATWG URL, so + // the parsed fields miss it — the raw scan must still reject it, or a malformed + // caller origin (or a Launchpad look-alike) slips through as a clean origin. + it.each(["https://launchpad.37signals.com?", "https://launchpad.37signals.com#"])( + "rejects bare query/fragment delimiter %s", + (raw) => { + expect(() => requireOriginRoot(raw)).toThrow(/query or fragment/); + } + ); }); describe("resource metadata strictness (#369 review)", () => { @@ -374,4 +384,31 @@ describe("resource metadata strictness (#369 review)", () => { expect(result.kind).toBe("selected"); expect(result.issuer).toBe(BC5); }); + + it("binds AS metadata against the advertised issuer string, not the normalized origin", async () => { + // The advertised issuer carries a trailing slash, which normalizes away for + // routing. Binding must still be code-point-exact against the ADVERTISED + // string: AS metadata echoing "https://bc5…/" must bind, not mismatch. + const advertised = `${BC5}/`; + server.use( + mswHttp.get(`${RESOURCE}/.well-known/oauth-protected-resource`, () => + HttpResponse.json({ resource: RESOURCE, authorization_servers: [advertised] }) + ), + mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => + HttpResponse.json({ issuer: advertised, token_endpoint: `${BC5}/token` }) + ) + ); + const result = (await discoverFromResource(RESOURCE)) as { kind: string; issuer?: string }; + expect(result.kind).toBe("selected"); + expect(result.issuer).toBe(advertised); + }); + + it("rejects scopes_supported that is not an array of strings", async () => { + server.use( + mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => + HttpResponse.json({ issuer: BC5, token_endpoint: `${BC5}/token`, scopes_supported: "read write" }) + ) + ); + await expect(discover(BC5)).rejects.toThrow(/scopes_supported must be an array of strings/); + }); }); From 19f5e47d4d60941f727242f803e749501c7653b5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 18:23:49 -0700 Subject: [PATCH 15/38] oauth: internal issuer binding, typed capability_unavailable, bare-# rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second #369 review round on the ready-for-review PR. Issuer binding — bind against the advertised string in ALL five SDKs, with NO public override: - Go/Python/Kotlin: thread the advertised issuer through an internal binding path (fetchASMetadata bindIssuer / _discover_and_bind / fetchAndBindAsMetadata bindIssuer) so discoverFromResource fetches from the normalized origin but binds metadata.issuer against the exact advertised selectedIssuer. Previously they bound against the normalized origin, turning a trailing-slash / :443 issuer into a false issuer_mismatch. - Remove the public overrides added earlier: TS drops bindIssuer from the exported DiscoverOptions (now an unexported discoverAndBind); Ruby drops expected_issuer from Oauth.discover and Discovery#discover (now the internal Discovery#discover_and_bind). New shared fixture 26 enforces trailing-slash binding across all five SDKs. Typed capability failure (TS): the consumer per-grant check in interactive-login now raises DiscoverySelectionError("capability_unavailable") instead of a generic BasecampError("validation"), so callers distinguish a missing-capability issuer consistently with the discovery contract. Bare fragment rejection: reject a bare trailing "#" (empty fragment that some parsers expose as absent) in Go (net/url has no ForceFragment) and Kotlin (no trailingQuery equivalent for "#"). New shared fixture 25 enforces it across all five (Python/TS added it last round; Ruby rejects it natively). Also: ruff-format the Python files (the pinned formatter check, not just ruff check, gates CI). --- .../25-origin-root-bare-fragment.json | 12 +++++++ ...dvertised-issuer-trailing-slash-binds.json | 27 +++++++++++++++ go/pkg/basecamp/oauth/discovery.go | 20 ++++++++--- .../kotlin/com/basecamp/sdk/Urls.kt | 7 ++-- .../com/basecamp/sdk/oauth/Discovery.kt | 14 ++++++-- python/src/basecamp/oauth/discovery.py | 22 ++++++++++-- python/tests/oauth/test_resource_discovery.py | 4 +-- ruby/lib/basecamp/oauth.rb | 14 +++----- ruby/lib/basecamp/oauth/discovery.rb | 26 ++++++++------ ruby/test/basecamp/oauth_test.rb | 27 +++++++++------ typescript/src/oauth/discovery.ts | 34 +++++++++++-------- typescript/src/oauth/interactive-login.ts | 15 ++++++-- .../tests/oauth/interactive-login.test.ts | 32 +++++++++++++++++ 13 files changed, 193 insertions(+), 61 deletions(-) create mode 100644 conformance/oauth/fixtures/25-origin-root-bare-fragment.json create mode 100644 conformance/oauth/fixtures/26-advertised-issuer-trailing-slash-binds.json diff --git a/conformance/oauth/fixtures/25-origin-root-bare-fragment.json b/conformance/oauth/fixtures/25-origin-root-bare-fragment.json new file mode 100644 index 000000000..02af5aacf --- /dev/null +++ b/conformance/oauth/fixtures/25-origin-root-bare-fragment.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-bare-fragment", + "description": "Resource origin with a bare trailing '#' (empty fragment) must be rejected as a query/fragment-bearing origin, not normalized to a clean origin. Some parsers expose an empty fragment as absent, so each SDK scans the raw input.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https://api.example.com#", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/conformance/oauth/fixtures/26-advertised-issuer-trailing-slash-binds.json b/conformance/oauth/fixtures/26-advertised-issuer-trailing-slash-binds.json new file mode 100644 index 000000000..7de6d91be --- /dev/null +++ b/conformance/oauth/fixtures/26-advertised-issuer-trailing-slash-binds.json @@ -0,0 +1,27 @@ +{ + "name": "advertised-issuer-trailing-slash-binds", + "description": "The advertised issuer carries a trailing slash that normalizes away for routing. Binding is code-point-exact against the ADVERTISED string, so an AS echoing the trailing-slash issuer binds (not a false issuer_mismatch), and the selected issuer is the advertised spelling.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": true, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": ["{{BC5_ISSUER}}/"] + } + }, + "hop2": { + "origin": "{{BC5_ISSUER}}", + "status": 200, + "body": { + "issuer": "{{BC5_ISSUER}}/", + "token_endpoint": "{{BC5_ISSUER}}/oauth/token" + } + }, + "expect": { + "outcome": "selected", + "selectedIssuer": "{{BC5_ISSUER}}/", + "launchpadContacted": false + } +} diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index 5cd4edd7c..f3a18165a 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -130,7 +130,10 @@ func requireOriginRoot(raw, label string) (string, error) { if u.User != nil { return "", usage(fmt.Sprintf("%s must not contain userinfo: %s", label, raw)) } - if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + // net/url sets ForceQuery for a bare trailing "?" but has no equivalent for a + // bare "#" (Fragment is "" for both absent and empty), so also scan the raw + // input: a "#" only ever delimits a fragment here. + if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(raw, "#") { return "", usage(fmt.Sprintf("%s must not contain a query or fragment: %s", label, raw)) } if u.Path != "" && u.Path != "/" { @@ -305,7 +308,7 @@ func (d *Discoverer) Discover(ctx context.Context, baseURL string, opts ...Disco if err != nil { return nil, err } - return d.fetchASMetadata(ctx, origin, newDiscoverConfig(opts)) + return d.fetchASMetadata(ctx, origin, origin, newDiscoverConfig(opts)) } // rawDiscoveryResponse mirrors an RFC 8414 metadata document. Endpoint fields @@ -321,12 +324,17 @@ type rawDiscoveryResponse struct { CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` } -func (d *Discoverer) fetchASMetadata(ctx context.Context, issuerOrigin string, cfg discoverConfig) (*Config, error) { +// fetchASMetadata fetches the AS metadata from issuerOrigin's well-known URL but +// binds the returned issuer against bindIssuer by code-point. Routing and binding +// are distinct: the resource-first flow fetches from the normalized origin yet +// binds against the exact advertised issuer string (which may spell a trailing +// slash or explicit default port). Public Discover passes the same value for both. +func (d *Discoverer) fetchASMetadata(ctx context.Context, issuerOrigin, bindIssuer string, cfg discoverConfig) (*Config, error) { body, err := d.fetchDiscoveryDocument(ctx, issuerOrigin+wellKnownAS, cfg) if err != nil { return nil, err } - return parseAndBindASMetadata(body, issuerOrigin) + return parseAndBindASMetadata(body, bindIssuer) } // parseAndBindASMetadata validates AS metadata and binds issuer to @@ -544,7 +552,9 @@ func (d *Discoverer) DiscoverFromResource(ctx context.Context, resourceOrigin st fmt.Sprintf("advertised issuer %q is not a valid origin root", selectedIssuer), err) } - config, err := d.fetchASMetadata(ctx, issuerOrigin, cfg) + // Fetch from the normalized origin, but bind the metadata issuer against the + // exact advertised string (selectedIssuer), not the normalized origin. + config, err := d.fetchASMetadata(ctx, issuerOrigin, selectedIssuer, cfg) if err != nil { if errors.Is(err, errIssuerBindingMismatch) { return nil, newSelectionError(ErrIssuerMismatch, err.Error(), err) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt index f3f2e2ddf..b18baca8b 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt @@ -104,8 +104,11 @@ internal fun requireOriginRoot(raw: String, label: String = "origin"): String { throw BasecampException.Usage("$label must not contain userinfo: ${BasecampException.truncateMessage(raw)}") } // trailingQuery catches a bare '?' with an empty query (e.g. `https://host?`), - // whose encodedQuery is empty but which is still a query-bearing origin. - if (url.encodedQuery.isNotEmpty() || url.trailingQuery || url.encodedFragment.isNotEmpty()) { + // whose encodedQuery is empty but which is still a query-bearing origin. Ktor + // has no trailingQuery equivalent for a bare '#' (encodedFragment is empty for + // both absent and empty), so scan the raw input too: a '#' only ever delimits + // a fragment here. + if (url.encodedQuery.isNotEmpty() || url.trailingQuery || url.encodedFragment.isNotEmpty() || raw.contains('#')) { throw BasecampException.Usage("$label must not contain a query or fragment: ${BasecampException.truncateMessage(raw)}") } val path = url.encodedPath diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index 7745db01f..cdc495e8b 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -161,7 +161,11 @@ suspend fun discover(baseUrl: String, client: HttpClient? = null): OAuthConfig = * [discoverFromResource] calls it directly so it can branch on the marker type * to classify `issuer_mismatch` vs `as_fetch_failed`. */ -private suspend fun fetchAndBindAsMetadata(baseUrl: String, client: HttpClient?): OAuthConfig { +private suspend fun fetchAndBindAsMetadata( + baseUrl: String, + client: HttpClient?, + bindIssuer: String? = null, +): OAuthConfig { val issuerOrigin = requireOriginRoot(baseUrl, "OAuth discovery base URL") val url = "$issuerOrigin/.well-known/oauth-authorization-server" @@ -180,7 +184,10 @@ private suspend fun fetchAndBindAsMetadata(baseUrl: String, client: HttpClient?) } catch (e: SerializationException) { throw BasecampException.Api("Failed to parse OAuth discovery response", httpStatus = 200, cause = e) } - return bindAsMetadata(raw, issuerOrigin) + // Fetch from the normalized origin, but bind the metadata issuer against the + // exact advertised string when supplied (routing vs binding are distinct); + // the public discover passes none, so it binds to its own normalized origin. + return bindAsMetadata(raw, bindIssuer ?: issuerOrigin) } /** @@ -389,7 +396,8 @@ suspend fun discoverFromResource( val config = try { // Call the binding path directly (not the public discover, which converts // the marker to api_error) so the structural marker reaches the branch. - fetchAndBindAsMetadata(issuerOrigin, client) + // Bind against the exact advertised issuer, not the normalized origin. + fetchAndBindAsMetadata(issuerOrigin, client, bindIssuer = selectedIssuer) } catch (e: IssuerBindingException) { // Structured marker — never the message text — decides issuer_mismatch. throw BasecampException.DiscoverySelection("issuer_mismatch", e.message!!, cause = e) diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index 97c491f63..c35323487 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -128,13 +128,29 @@ def discover( :class:`OAuthError` (``api_error``) on invalid metadata. """ issuer_origin = require_origin_root(base_url, "OAuth discovery base URL") + return _discover_and_bind(issuer_origin, issuer_origin, timeout=timeout, max_body_bytes=max_body_bytes) + + +def _discover_and_bind( + issuer_origin: str, + bind_issuer: str, + *, + timeout: float, + max_body_bytes: int, +) -> OAuthConfig: + """Fetch AS metadata from ``issuer_origin`` but bind ``issuer`` against + ``bind_issuer`` by code-point. Routing and binding are distinct: the + resource-first flow fetches from the normalized origin yet binds against the + exact advertised issuer string (which may spell a trailing slash or explicit + default port). Internal only — not a public override. + """ url = f"{issuer_origin}/.well-known/oauth-authorization-server" data = _fetch_discovery_document(url, timeout, max_body_bytes) if not isinstance(data, dict): raise OAuthError("api_error", "OAuth discovery response is not a JSON object") - return _parse_and_bind_as_metadata(data, issuer_origin) + return _parse_and_bind_as_metadata(data, bind_issuer) def _parse_and_bind_as_metadata(data: dict[str, Any], expected_issuer_origin: str) -> OAuthConfig: @@ -320,7 +336,9 @@ def discover_from_resource( ) from exc try: - config = discover(issuer_origin, timeout=timeout, max_body_bytes=max_body_bytes) + # Fetch from the normalized origin, but bind against the exact advertised + # issuer string (selected), not the normalized origin. + config = _discover_and_bind(issuer_origin, selected, timeout=timeout, max_body_bytes=max_body_bytes) except _IssuerBindingError as exc: # A structured marker — not a message substring — distinguishes an # issuer-binding mismatch from a generic fetch failure. diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 95818a68e..593730456 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -273,9 +273,7 @@ def test_duplicate_advertised_issuer_is_one_candidate_not_ambiguous() -> None: resource_origin = ORIGINS["{{RESOURCE_ORIGIN}}"] bc5 = ORIGINS["{{BC5_ISSUER}}"] respx.get(f"{resource_origin}{WELL_KNOWN_RESOURCE}").mock( - return_value=httpx.Response( - 200, json={"resource": resource_origin, "authorization_servers": [bc5, bc5]} - ) + return_value=httpx.Response(200, json={"resource": resource_origin, "authorization_servers": [bc5, bc5]}) ) respx.get(f"{bc5}{WELL_KNOWN_AS}").mock( return_value=httpx.Response(200, json={"issuer": bc5, "token_endpoint": f"{bc5}/token"}) diff --git a/ruby/lib/basecamp/oauth.rb b/ruby/lib/basecamp/oauth.rb index fff1e4eb2..ac1e2fc47 100644 --- a/ruby/lib/basecamp/oauth.rb +++ b/ruby/lib/basecamp/oauth.rb @@ -57,14 +57,9 @@ module Oauth # # @param base_url [String] the OAuth server's issuer origin # @param timeout [Integer] request timeout in seconds - # @param expected_issuer [String, nil] optional exact identifier the AS - # metadata's +issuer+ must equal by code-point (RFC 8414 §3.3/§4). When - # +nil+, +issuer+ binds to +base_url+'s normalized origin. Pass the raw - # advertised issuer to bind against a form that normalizes differently - # (e.g. a trailing slash) while still fetching from the normalized origin. # @return [Config] - def self.discover(base_url, timeout: 10, expected_issuer: nil) - Discovery.new(timeout: timeout).discover(base_url, expected_issuer: expected_issuer) + def self.discover(base_url, timeout: 10) + Discovery.new(timeout: timeout).discover(base_url) end def self.discover_launchpad(timeout: 10) @@ -220,8 +215,9 @@ def self.bind_issuer(selected_issuer, timeout) # Fetch from the normalized origin, but bind the AS metadata issuer to the # RAW advertised issuer by code-point: an AS whose issuer equals what the # resource advertised must not be rejected merely because normalization - # dropped a trailing slash / default port before the bind. - discover(issuer_origin, timeout: timeout, expected_issuer: selected_issuer) + # dropped a trailing slash / default port before the bind. Uses the + # internal binding path so the public Oauth.discover exposes no override. + Discovery.new(timeout: timeout).discover_and_bind(issuer_origin, selected_issuer) rescue OauthError => e raise as_failure_error(issuer_origin, e) end diff --git a/ruby/lib/basecamp/oauth/discovery.rb b/ruby/lib/basecamp/oauth/discovery.rb index 97a8b1553..69e18723e 100644 --- a/ruby/lib/basecamp/oauth/discovery.rb +++ b/ruby/lib/basecamp/oauth/discovery.rb @@ -51,19 +51,25 @@ def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_M # @example # config = Basecamp::Oauth::Discovery.new.discover("https://launchpad.37signals.com") # config.token_endpoint # => "https://launchpad.37signals.com/authorization/token" - # +expected_issuer+ is the identifier the AS metadata's +issuer+ must equal - # by code-point (RFC 8414 §3.3). It defaults to the normalized origin for a - # direct discover, but resource-first discovery passes the RAW advertised - # issuer so an AS whose issuer matches what the resource advertised (e.g. a - # trailing slash or explicit default port) binds instead of being normalized - # away into a false +issuer_mismatch+. The fetch URL is built from the - # normalized origin either way (that only cleans the origin string; the - # well-known path is identical). - def discover(base_url, expected_issuer: nil) + def discover(base_url) issuer_origin = Basecamp::Security.require_origin_root!(base_url, "OAuth discovery base URL") + discover_and_bind(issuer_origin, issuer_origin) + end + + # Internal: fetch AS metadata from +issuer_origin+ (already an origin root) + # but bind the returned +issuer+ against +bind_issuer+ by code-point. Routing + # and binding are distinct — resource-first discovery fetches from the + # normalized origin yet binds against the exact advertised issuer string + # (which may spell a trailing slash or explicit default port), so an AS whose + # issuer matches what the resource advertised binds instead of being + # normalized away into a false +issuer_mismatch+. Not part of the public + # discovery API — {Oauth.discover} exposes no binding override. + # + # @api private + def discover_and_bind(issuer_origin, bind_issuer) discovery_url = "#{issuer_origin}/.well-known/oauth-authorization-server" data = Fetcher.fetch_json(@http_client, discovery_url, timeout: @timeout, max_body_bytes: @max_body_bytes) - parse_and_bind(data, expected_issuer || issuer_origin) + parse_and_bind(data, bind_issuer) end private diff --git a/ruby/test/basecamp/oauth_test.rb b/ruby/test/basecamp/oauth_test.rb index 55fe0ce49..b35a38ecf 100644 --- a/ruby/test/basecamp/oauth_test.rb +++ b/ruby/test/basecamp/oauth_test.rb @@ -127,19 +127,26 @@ def test_discover_rejects_present_null_grant_types assert_includes error.message, "grant_types_supported" end - def test_discover_binds_metadata_issuer_to_expected_issuer_code_point - # The RFC 8414 code-point bind is against the advertised issuer, so an AS + def test_resource_first_binds_metadata_issuer_to_advertised_code_point + # The RFC 8414 code-point bind is against the ADVERTISED issuer, so an AS # whose issuer matches the advertised trailing-slash form must bind rather - # than be normalized away into a false issuer_mismatch. - discovery_response = { - "issuer" => "https://bc5.example/", - "token_endpoint" => "https://bc5.example/token" - } + # than be normalized away into a false issuer_mismatch. Binding is internal + # (Oauth.discover exposes no override), so drive it through the resource-first + # orchestrator: the resource advertises the trailing-slash issuer and the AS + # echoes it. + advertised = "https://bc5.example/" + stub_request(:get, "https://api.example.com/.well-known/oauth-protected-resource") + .to_return(status: 200, + body: { resource: "https://api.example.com", authorization_servers: [ advertised ] }.to_json, + headers: { "Content-Type" => "application/json" }) stub_request(:get, "https://bc5.example/.well-known/oauth-authorization-server") - .to_return(status: 200, body: discovery_response.to_json, headers: { "Content-Type" => "application/json" }) + .to_return(status: 200, + body: { issuer: advertised, token_endpoint: "https://bc5.example/token" }.to_json, + headers: { "Content-Type" => "application/json" }) - config = Basecamp::Oauth.discover("https://bc5.example", expected_issuer: "https://bc5.example/") - assert_equal "https://bc5.example/", config.issuer + result = Basecamp::Oauth.discover_from_resource("https://api.example.com") + assert result.selected? + assert_equal advertised, result.config.issuer end def test_discover_rejects_scopes_supported_not_array_of_strings diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 89b922195..71782c218 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -74,16 +74,6 @@ export interface DiscoverOptions { * the default cap applies (the bound cannot be disabled). */ maxBodyBytes?: number; - /** - * Internal: the exact issuer string the AS metadata's `issuer` must equal by - * code-point (RFC 8414 §3.3/§4). `discoverFromResource` passes the advertised - * issuer here so binding stays code-point-exact even when it differs from the - * normalized fetch origin (e.g. a trailing slash or explicit `:443`). Defaults - * to the normalized origin when omitted. - * - * @internal - */ - bindIssuer?: string; } /** @@ -344,6 +334,22 @@ export async function discover( options: DiscoverOptions = {} ): Promise { const issuerOrigin = requireOriginRoot(baseUrl, "OAuth discovery base URL"); + return discoverAndBind(issuerOrigin, issuerOrigin, options); +} + +/** + * Fetch AS metadata from `issuerOrigin`'s well-known URL but bind the returned + * `issuer` against `bindIssuer` by code-point. Routing and binding are distinct: + * the resource-first flow fetches from the normalized origin yet binds against + * the exact advertised issuer string (which may spell a trailing slash or + * explicit default port). Not exported — public `discover` passes the same value + * for both, so it never exposes a binding override. + */ +async function discoverAndBind( + issuerOrigin: string, + bindIssuer: string, + options: DiscoverOptions +): Promise { const discoveryUrl = `${issuerOrigin}/.well-known/oauth-authorization-server`; const data = (await fetchDiscoveryDocument(discoveryUrl, options)) as RawDiscoveryResponse; @@ -351,9 +357,7 @@ export async function discover( throw new BasecampError("api_error", "OAuth discovery response is not a JSON object"); } - // Fetch from the normalized origin, but bind `issuer` against the caller's - // exact advertised string when supplied (routing vs binding are distinct). - return parseAndBindAsMetadata(data, options.bindIssuer ?? issuerOrigin); + return parseAndBindAsMetadata(data, bindIssuer); } /** @@ -571,7 +575,9 @@ export async function discoverFromResource( let config: OAuthConfig; try { - config = await discover(issuerOrigin, { ...discoverOptions, bindIssuer: selectedIssuer }); + // Fetch from the normalized origin, but bind against the exact advertised + // issuer string (selectedIssuer), not the normalized origin. + config = await discoverAndBind(issuerOrigin, selectedIssuer, discoverOptions); } catch (err) { // Distinguish issuer-binding mismatch from a generic fetch failure via the // structural marker (never the message text). diff --git a/typescript/src/oauth/interactive-login.ts b/typescript/src/oauth/interactive-login.ts index c18fc7344..d62687626 100644 --- a/typescript/src/oauth/interactive-login.ts +++ b/typescript/src/oauth/interactive-login.ts @@ -5,7 +5,13 @@ * discovery, PKCE, local callback server, browser launch, code exchange. */ -import { discover, discoverLaunchpad, discoverFromResource, isLaunchpadIssuer } from "./discovery.js"; +import { + discover, + discoverLaunchpad, + discoverFromResource, + isLaunchpadIssuer, + DiscoverySelectionError, +} from "./discovery.js"; import { generateState, generatePKCE } from "./pkce.js"; import { buildAuthorizationUrl } from "./authorize.js"; import { startCallbackServer } from "./callback-server.js"; @@ -121,9 +127,12 @@ export async function performInteractiveLogin( // Authorization-code flow requires an authorization endpoint. It is optional // in discovery now (device-only servers omit it), so assert presence here. + // Raise the typed hard-failure reason (capability_unavailable) the resource- + // first contract exposes, not a generic validation error, so consumers can + // distinguish a missing-capability issuer consistently across SDKs. if (!config.authorizationEndpoint) { - throw new BasecampError( - "validation", + throw new DiscoverySelectionError( + "capability_unavailable", "Selected authorization server does not advertise an authorization_endpoint; " + "authorization-code login is unavailable for this issuer" ); diff --git a/typescript/tests/oauth/interactive-login.test.ts b/typescript/tests/oauth/interactive-login.test.ts index a8519c6c2..5c55b5626 100644 --- a/typescript/tests/oauth/interactive-login.test.ts +++ b/typescript/tests/oauth/interactive-login.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { http as mswHttp, HttpResponse, passthrough } from "msw"; import { server } from "../setup.js"; import { performInteractiveLogin } from "../../src/oauth/interactive-login.js"; +import { DiscoverySelectionError } from "../../src/oauth/discovery.js"; import type { TokenStore } from "../../src/oauth/token-store.js"; import type { OAuthToken } from "../../src/oauth/types.js"; @@ -137,6 +138,37 @@ describe("performInteractiveLogin", () => { ).rejects.toThrow(/Failed to open browser/); }); + it("throws a typed capability_unavailable when the AS omits authorization_endpoint", async () => { + // A device-only AS advertises no authorization_endpoint. The consumer-side + // per-grant check must raise the typed DiscoverySelectionError reason, not a + // generic validation error, so callers can distinguish it across SDKs. + server.use( + mswHttp.get( + "https://device-only.example.com/.well-known/oauth-authorization-server", + () => HttpResponse.json({ + issuer: "https://device-only.example.com", + token_endpoint: "https://device-only.example.com/token", + device_authorization_endpoint: "https://device-only.example.com/device", + }) + ), + ); + + const store = createMockStore(); + let caught: unknown; + try { + await performInteractiveLogin({ + clientId: "test_client_id", + store, + baseUrl: "https://device-only.example.com", + openBrowser: vi.fn(), + }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(DiscoverySelectionError); + expect((caught as DiscoverySelectionError).reason).toBe("capability_unavailable"); + }); + it("uses custom baseUrl for discovery", async () => { server.use( mswHttp.get( From 12d33a844587bc021abc16023e44091db2808a5b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 18:45:32 -0700 Subject: [PATCH 16/38] oauth: reject present-null list metadata, close Ruby SSRF entry, cover Kotlin binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third #369 review round. Present-null vs absent (Python + Ruby): a present JSON null is malformed metadata, distinct from an omitted key. - Python discover: list fields (grant_types/scopes/code_challenge_methods) now reject a present null instead of collapsing it into "absent" via .get(). - Python + Ruby resource: authorization_servers present-null is now malformed (→ soft resource_discovery_failed in the orchestrator), no longer normalized to [] and misread as no_as_advertised. Ruby SSRF (cubic P1): Discovery#discover_and_bind was public and did no origin validation — an unvalidated-fetch entry point that defeats the SSRF origin policy. Made it private; added the validated public Discovery#discover_advertised (validates the advertised issuer as an origin root, then fetches + binds), and routed Oauth.bind_issuer through it. Kotlin test coverage: its discovery test EMBEDS scenarios (commonTest can't read fixture files), so shared fixtures 24/25/26 had no Kotlin coverage. Added the mirrored scenarios — duplicate-issuer-deduped, origin-root-bare-fragment, and advertised-issuer-trailing-slash-binds. Python docstring: require_origin_root now correctly documents httpx.URL (the transport parser), not the stale urllib.parse. --- .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 44 +++++++++++++++++++ python/src/basecamp/_security.py | 7 +-- python/src/basecamp/oauth/discovery.py | 28 +++++++----- python/tests/oauth/test_resource_discovery.py | 28 ++++++++++++ ruby/lib/basecamp/oauth.rb | 21 ++++----- ruby/lib/basecamp/oauth/discovery.rb | 35 +++++++++------ ruby/lib/basecamp/oauth/resource.rb | 6 ++- ruby/test/basecamp/oauth_test.rb | 14 ++++++ 8 files changed, 141 insertions(+), 42 deletions(-) diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index 4030aa345..0c84e4a91 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -557,6 +557,50 @@ class OAuthDiscoveryTest { ) ) + @Test fun `24 duplicate issuer deduped`() = runScenario( + Scenario( + name = "duplicate-issuer-deduped", + op = Op.FROM_RESOURCE, + // The same non-Launchpad issuer advertised twice is ONE candidate: the + // exclusion heuristic dedupes by code-point rather than raising ambiguous. + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf(BC5, BC5, LAUNCHPAD))), + hop2 = Hop(body = "{\"issuer\":\"$BC5\",\"token_endpoint\":\"$BC5/oauth/token\"}"), + selectedIssuer = BC5, + launchpadMustBeSilent = true, + ) + ) + + @Test fun `25 origin-root bare fragment rejected`() = runScenario( + Scenario( + name = "origin-root-bare-fragment", + op = Op.PROTECTED_RESOURCE, + // A bare '#' is a fragment-bearing origin (empty fragment) and must be + // rejected — Ktor has no trailingQuery equivalent for '#', so the raw + // input is scanned. + resourceOrigin = "https://api.example.com#", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `26 advertised issuer trailing slash binds`() = runScenario( + Scenario( + name = "advertised-issuer-trailing-slash-binds", + op = Op.FROM_RESOURCE, + // The advertised issuer carries a trailing slash that normalizes away for + // routing. Binding is code-point-exact against the ADVERTISED string, so + // an AS echoing the trailing-slash issuer binds (no false issuer_mismatch) + // and the selected issuer is the advertised spelling. + resourceOrigin = RESOURCE, + hop1 = Hop(body = resourceBody(RESOURCE, listOf("$BC5/"))), + hop2 = Hop(body = "{\"issuer\":\"$BC5/\",\"token_endpoint\":\"$BC5/oauth/token\"}"), + selectedIssuer = "$BC5/", + launchpadMustBeSilent = true, + ) + ) + @Test fun `discover surfaces issuer mismatch as api_error to external callers`() = runTest { // The module-private binding marker must NOT leak: an external discover() // caller sees an ordinary api_error, identical to any other invalid AS diff --git a/python/src/basecamp/_security.py b/python/src/basecamp/_security.py index aba5eb2b8..153d988f3 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -50,9 +50,10 @@ def require_origin_root(raw: str, label: str = "origin") -> str: Accepts iff scheme is https (or http on localhost), a host is present, any port is valid, the path is empty or exactly ``/``, and there is no query, - fragment, or userinfo. Parsing uses ``urllib.parse`` — the transport parser, - never a regex — so bracketed IPv6 (``http://[::1]:3000``) and ports agree - with the host the client actually dials. + fragment, or userinfo. Parsing uses ``httpx.URL`` — the SAME transport parser + the client dials with, never a regex or a divergent parser like ``urllib`` — + so bracketed IPv6 (``http://[::1]:3000``), ports, and IDNA/IPvFuture handling + agree with the host the client actually dials (no parser-differential bypass). Raises :class:`~basecamp.errors.UsageError` on any violation: a bad caller-supplied origin is a usage error. Callers validating an *advertised* diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index c35323487..2241b571d 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -182,8 +182,12 @@ def _parse_and_bind_as_metadata(data: dict[str, Any], expected_issuer_origin: st "scopes_supported", "code_challenge_methods_supported", ): - value = data.get(list_field) - if value is not None and not _is_str_list(value): + # Distinguish absent (valid) from present (must be an array of strings): + # a present JSON null is malformed metadata, not the same as omitted. Using + # ``.get()`` alone would collapse present-null into absent and accept it. + if list_field not in data: + continue + if not _is_str_list(data[list_field]): raise OAuthError( "api_error", f"Invalid OAuth discovery response: {list_field} must be an array of strings", @@ -241,17 +245,17 @@ def discover_protected_resource( authorization_servers: list[str] | None if "authorization_servers" not in data: authorization_servers = None + elif _is_str_list(data["authorization_servers"]): + # Present-and-empty ([]) is preserved distinctly from absent (None). + authorization_servers = data["authorization_servers"] else: - servers = data.get("authorization_servers") - if servers is None: - authorization_servers = [] - elif _is_str_list(servers): - authorization_servers = servers - else: - raise OAuthError( - "api_error", - "Invalid resource metadata: authorization_servers must be an array of strings", - ) + # A present JSON null (or any non-array-of-strings) is MALFORMED metadata, + # not "present but empty": it must fail hop-1 (→ soft resource_discovery_failed + # in the orchestrator), never be normalized to [] and read as no_as_advertised. + raise OAuthError( + "api_error", + "Invalid resource metadata: authorization_servers must be an array of strings", + ) return ProtectedResourceMetadata(resource=resource, authorization_servers=authorization_servers) diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 593730456..6ae36906b 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -303,6 +303,34 @@ def test_protected_resource_present_empty_array() -> None: assert empty.authorization_servers == [] +@respx.mock +def test_present_null_authorization_servers_is_malformed_not_empty() -> None: + # A present JSON null authorization_servers is MALFORMED metadata, not + # "present but empty": it must fail hop-1 (soft resource_discovery_failed), + # never be normalized to [] and read as no_as_advertised. + origin = ORIGINS["{{RESOURCE_ORIGIN}}"] + respx.get(f"{origin}{WELL_KNOWN_RESOURCE}").mock( + return_value=httpx.Response(200, json={"resource": origin, "authorization_servers": None}) + ) + result = discover_from_resource(origin) + assert result.kind == "fallback" + assert result.reason == "resource_discovery_failed" + + +@respx.mock +def test_present_null_grant_types_is_rejected_not_absent() -> None: + # A present JSON null list field is malformed, distinct from an absent key. + issuer = ORIGINS["{{ISSUER_ORIGIN}}"] + respx.get(f"{issuer}{WELL_KNOWN_AS}").mock( + return_value=httpx.Response( + 200, + json={"issuer": issuer, "token_endpoint": f"{issuer}/token", "grant_types_supported": None}, + ) + ) + with pytest.raises(OAuthError, match="grant_types_supported must be an array of strings"): + discover(issuer) + + @respx.mock def test_issuer_mismatch_classified_via_marker() -> None: # Committed to a BC5 issuer, the AS document returns a non-matching issuer. diff --git a/ruby/lib/basecamp/oauth.rb b/ruby/lib/basecamp/oauth.rb index ac1e2fc47..dbd027c27 100644 --- a/ruby/lib/basecamp/oauth.rb +++ b/ruby/lib/basecamp/oauth.rb @@ -202,24 +202,21 @@ def self.select_by_exclusion(advertised) # BC5 is committed: validate the advertised issuer origin then fetch + bind # its AS metadata. Every failure here is a hard {DiscoverySelectionError}. def self.bind_issuer(selected_issuer, timeout) - issuer_origin = begin - Basecamp::Security.require_origin_root!(selected_issuer, "advertised issuer") + config = begin + # discover_advertised validates the advertised issuer as an origin root, + # then fetches from the normalized origin and binds the AS metadata issuer + # against the RAW advertised string by code-point — so an AS whose issuer + # equals what the resource advertised is not rejected merely because + # normalization dropped a trailing slash / default port before the bind. + # It exposes no unvalidated-fetch entry point (the SSRF origin policy holds). + Discovery.new(timeout: timeout).discover_advertised(selected_issuer) rescue Basecamp::UsageError => e raise DiscoverySelectionError.new( "invalid_issuer_origin", "Advertised issuer #{selected_issuer.inspect} is not a valid origin root: #{e.message}" ) - end - - config = begin - # Fetch from the normalized origin, but bind the AS metadata issuer to the - # RAW advertised issuer by code-point: an AS whose issuer equals what the - # resource advertised must not be rejected merely because normalization - # dropped a trailing slash / default port before the bind. Uses the - # internal binding path so the public Oauth.discover exposes no override. - Discovery.new(timeout: timeout).discover_and_bind(issuer_origin, selected_issuer) rescue OauthError => e - raise as_failure_error(issuer_origin, e) + raise as_failure_error(selected_issuer, e) end DiscoveryResult.selected(config) diff --git a/ruby/lib/basecamp/oauth/discovery.rb b/ruby/lib/basecamp/oauth/discovery.rb index 69e18723e..4a080123b 100644 --- a/ruby/lib/basecamp/oauth/discovery.rb +++ b/ruby/lib/basecamp/oauth/discovery.rb @@ -56,24 +56,33 @@ def discover(base_url) discover_and_bind(issuer_origin, issuer_origin) end - # Internal: fetch AS metadata from +issuer_origin+ (already an origin root) - # but bind the returned +issuer+ against +bind_issuer+ by code-point. Routing - # and binding are distinct — resource-first discovery fetches from the - # normalized origin yet binds against the exact advertised issuer string - # (which may spell a trailing slash or explicit default port), so an AS whose - # issuer matches what the resource advertised binds instead of being - # normalized away into a false +issuer_mismatch+. Not part of the public - # discovery API — {Oauth.discover} exposes no binding override. + # Resource-first entry: validate +advertised_issuer+ as an origin root, then + # fetch from the normalized origin and bind the AS metadata +issuer+ against + # the RAW advertised string by code-point. Routing and binding are distinct — + # an AS whose issuer matches what the resource advertised (e.g. a trailing + # slash or explicit default port) binds instead of being normalized away into + # a false +issuer_mismatch+. Validation happens HERE so there is NO public + # unvalidated-fetch entry point: the SSRF origin policy is always enforced. # - # @api private - def discover_and_bind(issuer_origin, bind_issuer) - discovery_url = "#{issuer_origin}/.well-known/oauth-authorization-server" - data = Fetcher.fetch_json(@http_client, discovery_url, timeout: @timeout, max_body_bytes: @max_body_bytes) - parse_and_bind(data, bind_issuer) + # @param advertised_issuer [String] the raw issuer string a resource advertised + # @raise [Basecamp::UsageError] on a malformed advertised origin + def discover_advertised(advertised_issuer) + issuer_origin = Basecamp::Security.require_origin_root!(advertised_issuer, "advertised issuer") + discover_and_bind(issuer_origin, advertised_issuer) end private + # Fetch AS metadata from +issuer_origin+ (an ALREADY-VALIDATED origin root) + # but bind the returned +issuer+ against +bind_issuer+ by code-point. Kept + # private: it does no origin validation of its own, so exposing it would be + # an unvalidated-fetch entry point that defeats the SSRF origin policy. + def discover_and_bind(issuer_origin, bind_issuer) + discovery_url = "#{issuer_origin}/.well-known/oauth-authorization-server" + data = Fetcher.fetch_json(@http_client, discovery_url, timeout: @timeout, max_body_bytes: @max_body_bytes) + parse_and_bind(data, bind_issuer) + end + # Universal validation only: +issuer+ + +token_endpoint+ present and # non-empty, issuer identical by code-point, and any present +*_endpoint+ # field non-empty. Per-grant endpoint checks are the consumer's job. diff --git a/ruby/lib/basecamp/oauth/resource.rb b/ruby/lib/basecamp/oauth/resource.rb index 6375cfe38..f3c90e684 100644 --- a/ruby/lib/basecamp/oauth/resource.rb +++ b/ruby/lib/basecamp/oauth/resource.rb @@ -71,8 +71,10 @@ def extract_authorization_servers(data) return nil unless data.key?("authorization_servers") servers = data["authorization_servers"] - return [] if servers.nil? - + # A present JSON null (or any non-array-of-strings) is MALFORMED metadata, + # not "present but empty": it must fail hop-1 (→ soft resource_discovery_failed + # in the orchestrator), never be normalized to [] and read as no_as_advertised. + # An empty array is a valid "present but empty" value and is preserved. unless servers.is_a?(Array) && servers.all?(String) raise OauthError.new( "api_error", diff --git a/ruby/test/basecamp/oauth_test.rb b/ruby/test/basecamp/oauth_test.rb index b35a38ecf..49129264c 100644 --- a/ruby/test/basecamp/oauth_test.rb +++ b/ruby/test/basecamp/oauth_test.rb @@ -178,6 +178,20 @@ def test_discover_protected_resource_rejects_wrong_typed_resource assert_equal "api_error", error.type end + def test_present_null_authorization_servers_is_malformed_not_empty + # A present JSON null authorization_servers is MALFORMED metadata, not + # "present but empty": it must fail hop-1 (soft resource_discovery_failed), + # never be normalized to [] and read as no_as_advertised. + stub_request(:get, "https://api.example.com/.well-known/oauth-protected-resource") + .to_return(status: 200, + body: { resource: "https://api.example.com", authorization_servers: nil }.to_json, + headers: { "Content-Type" => "application/json" }) + + result = Basecamp::Oauth.discover_from_resource("https://api.example.com") + assert result.fallback? + assert_equal "resource_discovery_failed", result.reason + end + def test_exchange_code token_response = { "access_token" => "access_token_123", From d7978866ed57c693ff9760f265986e5b39807955 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 19:06:53 -0700 Subject: [PATCH 17/38] oauth: reject null authorization_servers in Kotlin, port 0 in TS, fix stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth #369 review round. Kotlin present-null (parity with Python/Ruby/Go/TS): discoverProtectedResource now rejects a present authorization_servers: null at the JSON layer — the nullable field otherwise collapses present-null and absent to the same null, silently reading malformed metadata as no_as_advertised. Now it fails hop-1 → soft resource_discovery_failed. New shared fixture 27 enforces this across all five (with the Kotlin mirror scenario, since its test embeds scenarios). TS port 0: requireOriginRoot now rejects any port outside 1–65535. WHATWG accepts https://host:0 and keeps it in the origin; the other SDKs reject it up front, so a :0 caller/advertised issuer now fails as usage / invalid_issuer_origin rather than proceeding to a fetch (as_fetch_failed). Stale comments: the Ruby and Python authorization_servers doc comments still said "present null normalizes to []"; updated to match the malformed-metadata behavior shipped last round. --- ...27-authorization-servers-present-null.json | 19 +++++++++++++++++++ .../com/basecamp/sdk/oauth/Discovery.kt | 15 +++++++++++++++ .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 13 +++++++++++++ python/src/basecamp/oauth/discovery.py | 2 +- ruby/lib/basecamp/oauth/resource.rb | 10 +++++----- typescript/src/oauth/discovery.ts | 12 +++++++++--- .../tests/oauth/resource-discovery.test.ts | 6 ++++++ 7 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 conformance/oauth/fixtures/27-authorization-servers-present-null.json diff --git a/conformance/oauth/fixtures/27-authorization-servers-present-null.json b/conformance/oauth/fixtures/27-authorization-servers-present-null.json new file mode 100644 index 000000000..8b2511937 --- /dev/null +++ b/conformance/oauth/fixtures/27-authorization-servers-present-null.json @@ -0,0 +1,19 @@ +{ + "name": "authorization-servers-present-null", + "description": "Hop-1 metadata with a present authorization_servers: null is MALFORMED (not absent, not empty): it fails hop-1 → soft resource_discovery_failed → Launchpad, never a silent no_as_advertised.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}", + "bc5Committed": false, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}", + "authorization_servers": null + } + }, + "expect": { + "outcome": "fallback", + "fallbackReason": "resource_discovery_failed", + "launchpadContacted": true + } +} diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index cdc495e8b..6aa9287d0 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -295,6 +295,21 @@ suspend fun discoverProtectedResource( val url = "$origin/.well-known/oauth-protected-resource" val body = fetchDiscoveryDocument(url, client) + // A present `authorization_servers: null` is MALFORMED metadata, not absent: a + // nullable Kotlin field collapses present-null and omitted to the same null, so + // reject present-null at the JSON layer (→ soft resource_discovery_failed in the + // orchestrator, never a silent no_as_advertised). Present-[] stays valid/empty. + val obj = try { + discoveryJson.parseToJsonElement(body) as? JsonObject + } catch (e: SerializationException) { + throw BasecampException.Api("Failed to parse resource metadata response", httpStatus = 200, cause = e) + } ?: throw BasecampException.Api("Failed to parse resource metadata response", httpStatus = 200) + if (obj["authorization_servers"] is JsonNull) { + throw BasecampException.Api( + "Invalid resource metadata: authorization_servers must be an array of strings", + httpStatus = 200, + ) + } val raw = try { discoveryJson.decodeFromString(body) } catch (e: SerializationException) { diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index 0c84e4a91..60cc10f19 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -601,6 +601,19 @@ class OAuthDiscoveryTest { ) ) + @Test fun `27 authorization_servers present null`() = runScenario( + Scenario( + name = "authorization-servers-present-null", + op = Op.FROM_RESOURCE, + // A present authorization_servers: null is MALFORMED (not absent, not + // empty): the nullable field must not collapse it to absent. It fails + // hop-1 → soft resource_discovery_failed, never a silent no_as_advertised. + resourceOrigin = RESOURCE, + hop1 = Hop(body = "{\"resource\":\"$RESOURCE\",\"authorization_servers\":null}"), + fallbackReason = "resource_discovery_failed", + ) + ) + @Test fun `discover surfaces issuer mismatch as api_error to external callers`() = runTest { // The module-private binding marker must NOT leak: an external discover() // caller sees an ordinary api_error, identical to any other invalid AS diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index 2241b571d..a7c97a7d4 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -241,7 +241,7 @@ def discover_protected_resource( # Preserve absent (None) vs present-empty ([]). When present it must be a list # of strings — a bare string previously slipped through and was iterated # char-by-char during selection; reject it as malformed so the orchestrator - # soft-falls-back. A present-but-null value normalizes to []. + # soft-falls-back. A present JSON null is malformed too (NOT normalized to []). authorization_servers: list[str] | None if "authorization_servers" not in data: authorization_servers = None diff --git a/ruby/lib/basecamp/oauth/resource.rb b/ruby/lib/basecamp/oauth/resource.rb index f3c90e684..6d8091c99 100644 --- a/ruby/lib/basecamp/oauth/resource.rb +++ b/ruby/lib/basecamp/oauth/resource.rb @@ -62,11 +62,11 @@ def discover(resource_origin) private - # Preserve absent (+nil+) vs present-empty (+[]+); a present +null+ is - # normalized to +[]+ to match the "present but empty" posture. A present - # value that is not an array of strings is malformed metadata and must be - # rejected — never iterated (a bare string would otherwise be treated as a - # sequence of single-character issuers during selection). + # Preserve absent (+nil+) vs present-empty (+[]+). A present value that is + # not an array of strings — including a JSON +null+ — is malformed metadata + # and is rejected (→ soft resource_discovery_failed in the orchestrator), + # never iterated (a bare string would otherwise be treated as a sequence of + # single-character issuers during selection) and never normalized to +[]+. def extract_authorization_servers(data) return nil unless data.key?("authorization_servers") diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 71782c218..4de33ec0f 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -164,9 +164,15 @@ export function requireOriginRoot(raw: string, label = "origin"): string { if (url.pathname !== "" && url.pathname !== "/") { throw new BasecampError("usage", `${label} must be an origin root (no path): ${raw}`); } - // Note: `new URL("https://h:notaport")` throws above, so a surviving url has a - // structurally valid (possibly default) port. url.origin drops a default port - // and any trailing slash — exactly the normalized origin we want. + // `new URL("https://h:notaport")` throws above and WHATWG rejects ports > 65535, + // but it ACCEPTS port 0 and keeps it in the origin. The origin-root profile (like + // the other SDKs) rejects any port outside 1–65535, so a caller/advertised issuer + // using `:0` fails as usage / invalid_issuer_origin rather than proceeding to a fetch. + if (url.port !== "" && (Number(url.port) < 1 || Number(url.port) > 65535)) { + throw new BasecampError("usage", `${label} has an invalid port: ${raw}`); + } + // Note: a surviving url has a structurally valid (possibly default) port. url.origin + // drops a default port and any trailing slash — exactly the normalized origin we want. return url.origin; } diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index 2e4efa386..3ec5fc54e 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -355,6 +355,12 @@ describe("requireOriginRoot userinfo rejection", () => { expect(() => requireOriginRoot(raw)).toThrow(/query or fragment/); } ); + + // WHATWG accepts port 0 and keeps it in the origin; the origin-root profile + // rejects any port outside 1–65535, matching the other SDKs. + it("rejects port 0", () => { + expect(() => requireOriginRoot("https://host:0")).toThrow(/invalid port/); + }); }); describe("resource metadata strictness (#369 review)", () => { From 49e566fdf6558b4917fb265feb30340160fd81b3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 19:16:23 -0700 Subject: [PATCH 18/38] oauth: presence-check baseUrl, root-export requireOriginRoot, reject dangling port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth #369 review round (TS + Python). TS interactive-login presence vs truthiness: an explicitly-supplied empty baseUrl "" was treated as absent — the mutual-exclusion guard and the legacy single-hop path both used truthiness. Now they use `!== undefined`, so baseUrl:"" trips the mutual-exclusion guard (when paired) and otherwise reaches origin validation and raises usage, instead of silently logging in against Launchpad. TS public surface: re-export requireOriginRoot from the package root (typescript/src/index.ts), matching the oauth subpath export so the origin validator is reachable by both import paths. Python dangling port: require_origin_root now rejects "https://host:" — httpx normalizes the empty port to None (looks like no port), so a raw-authority trailing-":" check rejects it, mirroring Ruby. IPv6 authorities end with "]", so only a trailing ":" is a dangling port. --- python/src/basecamp/_security.py | 9 ++++- python/tests/oauth/test_resource_discovery.py | 7 ++++ typescript/src/index.ts | 1 + typescript/src/oauth/interactive-login.ts | 9 +++-- .../tests/oauth/interactive-login.test.ts | 34 +++++++++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/python/src/basecamp/_security.py b/python/src/basecamp/_security.py index 153d988f3..cfbe207fc 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -106,8 +106,15 @@ def require_origin_root(raw: str, label: str = "origin") -> str: if url.path not in ("", "/"): raise UsageError(f"{label} must be an origin root (no path): {raw}") + # A dangling port delimiter ("https://host:") normalizes to port None under + # httpx, silently accepting a malformed authority. Reject a trailing ":" on the + # raw authority explicitly. IPv6 authorities legitimately end with "]" (e.g. + # "[::1]"), so only a trailing ":" is a dangling port. + if authority.endswith(":"): + raise UsageError(f"{label} has an invalid port: {raw}") + # httpx does not range-check the port (it accepts :99999), so enforce 1–65535 - # explicitly. httpx already drops an absent/default/dangling port to None. + # explicitly. httpx already drops an absent/default port to None. port = url.port if port is not None and not (1 <= port <= 65535): raise UsageError(f"{label} has an invalid port: {raw}") diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 6ae36906b..3fd5d7616 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -183,6 +183,13 @@ def test_origin_root_rejects_bare_query_or_fragment(raw: str) -> None: require_origin_root(raw) +def test_origin_root_rejects_dangling_port() -> None: + # A dangling ":" normalizes to port None under httpx (looks like no port); the + # raw-authority check must still reject the malformed authority. + with pytest.raises(UsageError, match="invalid port"): + require_origin_root("https://bc5.example:") + + @pytest.mark.parametrize( "raw", [ diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 1da811311..372c15718 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -447,6 +447,7 @@ export { discoverProtectedResource, discoverFromResource, discoverLaunchpad, + requireOriginRoot, DiscoverySelectionError, LAUNCHPAD_BASE_URL, type DiscoverOptions, diff --git a/typescript/src/oauth/interactive-login.ts b/typescript/src/oauth/interactive-login.ts index d62687626..5a19c4d3f 100644 --- a/typescript/src/oauth/interactive-login.ts +++ b/typescript/src/oauth/interactive-login.ts @@ -108,7 +108,10 @@ export async function performInteractiveLogin( onStatus, } = options; - if (baseUrl && resourceBaseUrl) { + // Presence, not truthiness: an explicitly-supplied empty string is "provided" + // (and invalid) — it must trip this guard, then reach origin validation below, + // never be silently treated as absent. + if (baseUrl !== undefined && resourceBaseUrl !== undefined) { throw new BasecampError( "usage", "baseUrl and resourceBaseUrl are mutually exclusive discovery modes; supply only one" @@ -239,7 +242,9 @@ async function discoverEndpoints(opts: { return { config, legacy: useLegacyFormat ?? true }; } - const config = baseUrl ? await discover(baseUrl) : await discoverLaunchpad(); + // Presence, not truthiness: an explicitly-supplied empty baseUrl must reach + // origin validation and raise a usage error, not silently fall back to Launchpad. + const config = baseUrl !== undefined ? await discover(baseUrl) : await discoverLaunchpad(); // Legacy single-hop path keeps its historical default of legacy=true. return { config, legacy: useLegacyFormat ?? true }; } diff --git a/typescript/tests/oauth/interactive-login.test.ts b/typescript/tests/oauth/interactive-login.test.ts index 5c55b5626..301a3d0fe 100644 --- a/typescript/tests/oauth/interactive-login.test.ts +++ b/typescript/tests/oauth/interactive-login.test.ts @@ -9,6 +9,7 @@ import { http as mswHttp, HttpResponse, passthrough } from "msw"; import { server } from "../setup.js"; import { performInteractiveLogin } from "../../src/oauth/interactive-login.js"; import { DiscoverySelectionError } from "../../src/oauth/discovery.js"; +import { BasecampError } from "../../src/errors.js"; import type { TokenStore } from "../../src/oauth/token-store.js"; import type { OAuthToken } from "../../src/oauth/types.js"; @@ -169,6 +170,39 @@ describe("performInteractiveLogin", () => { expect((caught as DiscoverySelectionError).reason).toBe("capability_unavailable"); }); + it("rejects an explicitly-empty baseUrl as usage, not a silent Launchpad fallback", async () => { + // Presence, not truthiness: baseUrl "" is provided-but-invalid; it must reach + // origin validation and raise usage, never fall back to Launchpad. + const store = createMockStore(); + let caught: unknown; + try { + await performInteractiveLogin({ clientId: "c", store, baseUrl: "", openBrowser: vi.fn() }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(BasecampError); + expect((caught as BasecampError).code).toBe("usage"); + }); + + it("rejects baseUrl and resourceBaseUrl both supplied (even empty) as mutually exclusive", async () => { + const store = createMockStore(); + let caught: unknown; + try { + await performInteractiveLogin({ + clientId: "c", + store, + baseUrl: "", + resourceBaseUrl: "", + openBrowser: vi.fn(), + }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(BasecampError); + expect((caught as BasecampError).code).toBe("usage"); + expect((caught as BasecampError).message).toMatch(/mutually exclusive/); + }); + it("uses custom baseUrl for discovery", async () => { server.use( mswHttp.get( From 48467d783896804233ce179876837b31122d6353 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 19:24:44 -0700 Subject: [PATCH 19/38] oauth(python): normalize discovery body cap to a finite non-negative int max_body_bytes is typed int but callers can pass None, a float, or inf at runtime; any of those disables the streaming memory bound (total > inf never trips), defeating the SSRF guarantee. Add _normalize_body_cap at the streaming chokepoint (falling back to the default) so the bound cannot be turned off, mirroring the Ruby Discovery/Resource normalization. --- python/src/basecamp/oauth/discovery.py | 15 +++++++++++++++ python/tests/oauth/test_resource_discovery.py | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index a7c97a7d4..3a795375e 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -56,6 +56,20 @@ def __init__(self, message: str, **kwargs: Any) -> None: super().__init__("api_error", message, **kwargs) +def _normalize_body_cap(max_body_bytes: object) -> int: + """Coerce the public cap to a finite, non-negative int. + + ``max_body_bytes`` is *typed* ``int``, but callers can pass ``None``, a float, + or ``float("inf")`` at runtime; any of those would disable the streaming memory + bound (``total > inf`` never trips), defeating the SSRF guarantee. Fall back to + the default so the bound can never be turned off. ``bool`` is excluded (a + subclass of ``int``, but a nonsensical cap). + """ + if isinstance(max_body_bytes, bool) or not isinstance(max_body_bytes, int) or max_body_bytes < 0: + return MAX_DISCOVERY_BODY_BYTES + return max_body_bytes + + def _fetch_discovery_document(url: str, timeout: float, max_body_bytes: int) -> Any: """SSRF-hardened GET of a discovery document. @@ -64,6 +78,7 @@ def _fetch_discovery_document(url: str, timeout: float, max_body_bytes: int) -> streaming cap that aborts once ``max_body_bytes`` is exceeded, and maps any non-2xx status to ``api_error`` (not ``network``). """ + max_body_bytes = _normalize_body_cap(max_body_bytes) try: with httpx.stream( "GET", diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 3fd5d7616..ff6bdc7ef 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -183,6 +183,17 @@ def test_origin_root_rejects_bare_query_or_fragment(raw: str) -> None: require_origin_root(raw) +def test_body_cap_normalizes_non_finite_to_default() -> None: + from basecamp.oauth.discovery import MAX_DISCOVERY_BODY_BYTES, _normalize_body_cap + + # None/float/inf/negative would disable the streaming memory bound; each must + # fall back to the finite default so the SSRF cap can never be turned off. + for bad in (None, float("inf"), 1.5, -1, True, "big"): + assert _normalize_body_cap(bad) == MAX_DISCOVERY_BODY_BYTES + # A valid non-negative int is preserved. + assert _normalize_body_cap(4096) == 4096 + + def test_origin_root_rejects_dangling_port() -> None: # A dangling ":" normalizes to port None under httpx (looks like no port); the # raw-authority check must still reject the malformed authority. From 28f3393859010dd4e69965326beda00908c81471 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 19:38:34 -0700 Subject: [PATCH 20/38] oauth: uniform dangling-port + empty-userinfo origin-root rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth #369 review round — close the remaining origin-root profile drift so all five SDKs enforce the same rules. Dangling port ("https://host:"): Go (net/url reports an empty Port()), TS (WHATWG normalizes to url.port === ""), and Kotlin (Ktor drops the colon) all accepted it and returned the clean origin. Each now scans the raw authority for a trailing ":" and rejects it (Python/Ruby already did last round). An IPv6 authority ends with "]", so only a trailing ":" is a dangling port. Empty userinfo ("https://@host"): Ruby's uri.userinfo is an empty (falsy) string for delimiter-only userinfo, so the parsed-field check missed it. Reject on the raw authority's "@" presence, matching Go/TS/Python/Kotlin. New shared fixtures 28 (dangling port) and 29 (empty userinfo) + Kotlin mirror scenarios enforce both across all five SDKs. --- .../28-origin-root-dangling-port.json | 12 +++++++++ .../29-origin-root-empty-userinfo.json | 12 +++++++++ go/pkg/basecamp/oauth/discovery.go | 14 ++++++++++ .../kotlin/com/basecamp/sdk/Urls.kt | 6 +++++ .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 26 +++++++++++++++++++ ruby/lib/basecamp/security.rb | 21 ++++++++++----- ruby/test/basecamp/security_test.rb | 9 +++++++ typescript/src/oauth/discovery.ts | 6 +++++ .../tests/oauth/resource-discovery.test.ts | 6 +++++ 9 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 conformance/oauth/fixtures/28-origin-root-dangling-port.json create mode 100644 conformance/oauth/fixtures/29-origin-root-empty-userinfo.json diff --git a/conformance/oauth/fixtures/28-origin-root-dangling-port.json b/conformance/oauth/fixtures/28-origin-root-dangling-port.json new file mode 100644 index 000000000..b5c4e3bc9 --- /dev/null +++ b/conformance/oauth/fixtures/28-origin-root-dangling-port.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-dangling-port", + "description": "Resource origin with a dangling port delimiter ('https://host:') is malformed: parsers normalize the empty port away, so each SDK scans the raw authority for a trailing ':' and rejects it.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https://api.example.com:", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/conformance/oauth/fixtures/29-origin-root-empty-userinfo.json b/conformance/oauth/fixtures/29-origin-root-empty-userinfo.json new file mode 100644 index 000000000..28691bfa4 --- /dev/null +++ b/conformance/oauth/fixtures/29-origin-root-empty-userinfo.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-empty-userinfo", + "description": "Resource origin with delimiter-only userinfo ('https://@host') is malformed: parsers report an empty (falsy) userinfo, so each SDK scans the raw authority for an '@' delimiter and rejects it on presence.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https://@api.example.com", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index f3a18165a..b77a8ab8c 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -152,6 +152,20 @@ func requireOriginRoot(raw, label string) (string, error) { } } + // net/url reports an empty Port() for a dangling ":" ("https://host:"), so + // scan the raw authority for a trailing ":" (an IPv6 authority ends with "]", + // so only a trailing ":" is a dangling port). + authority := raw + if i := strings.Index(authority, "://"); i >= 0 { + authority = authority[i+3:] + } + if j := strings.IndexAny(authority, "/?#"); j >= 0 { + authority = authority[:j] + } + if strings.HasSuffix(authority, ":") { + return "", usage(fmt.Sprintf("%s has an invalid port: %s", label, raw)) + } + // Lowercase the host: DNS names and schemes are case-insensitive (RFC 3986 // §3.1/§6.2.2.1), so a mixed-case advertised issuer like // https://Launchpad.37signals.com must normalize to the same origin as its diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt index b18baca8b..37ffdbfdc 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt @@ -103,6 +103,12 @@ internal fun requireOriginRoot(raw: String, label: String = "origin"): String { if (authority.contains('@') || !url.user.isNullOrEmpty() || !url.password.isNullOrEmpty()) { throw BasecampException.Usage("$label must not contain userinfo: ${BasecampException.truncateMessage(raw)}") } + // A dangling ":" ("https://host:") parses to the default-port origin under + // Ktor, silently accepting a malformed authority. IPv6 authorities end with + // "]" (e.g. "[::1]"), so only a trailing ":" is a dangling port. + if (authority.endsWith(':')) { + throw BasecampException.Usage("$label has an invalid port: ${BasecampException.truncateMessage(raw)}") + } // trailingQuery catches a bare '?' with an empty query (e.g. `https://host?`), // whose encodedQuery is empty but which is still a query-bearing origin. Ktor // has no trailingQuery equivalent for a bare '#' (encodedFragment is empty for diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index 60cc10f19..1c60a7d64 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -614,6 +614,32 @@ class OAuthDiscoveryTest { ) ) + @Test fun `28 origin-root dangling port rejected`() = runScenario( + Scenario( + name = "origin-root-dangling-port", + op = Op.PROTECTED_RESOURCE, + // A dangling ":" ("https://host:") normalizes the port away; the raw + // authority's trailing ":" must still be rejected. + resourceOrigin = "https://api.example.com:", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `29 origin-root empty userinfo rejected`() = runScenario( + Scenario( + name = "origin-root-empty-userinfo", + op = Op.PROTECTED_RESOURCE, + // Delimiter-only userinfo ("https://@host") reports an empty (falsy) + // userinfo; the raw authority's "@" must be rejected on presence. + resourceOrigin = "https://@api.example.com", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + @Test fun `discover surfaces issuer mismatch as api_error to external callers`() = runTest { // The module-private binding marker must NOT leak: an external discover() // caller sees an ordinary api_error, identical to any other invalid AS diff --git a/ruby/lib/basecamp/security.rb b/ruby/lib/basecamp/security.rb index b39a6a93a..d6c110a83 100644 --- a/ruby/lib/basecamp/security.rb +++ b/ruby/lib/basecamp/security.rb @@ -113,17 +113,26 @@ def self.require_origin_root!(raw, label = "origin") raise UsageError.new("#{label} must use HTTPS (or http on localhost): #{raw}") end raise UsageError.new("#{label} has no host: #{raw}") if uri.host.nil? || uri.host.empty? - raise UsageError.new("#{label} must not contain userinfo: #{raw}") if uri.userinfo + + # The raw authority (between "://" and the first "/?#") backs the presence + # checks the parsed fields miss: URI reports delimiter-only userinfo + # ("https://@example.com") as an empty (falsy) string, and normalizes a + # dangling port ("https://example.com:") to the default-port origin. + authority = raw.to_s.split("://", 2)[1].to_s.split(%r{[/?#]}, 2)[0].to_s + + # Reject on the PRESENCE of userinfo, not truthiness: an "@" in the authority + # is always a userinfo delimiter (a host cannot contain one). + if uri.userinfo || authority.include?("@") + raise UsageError.new("#{label} must not contain userinfo: #{raw}") + end raise UsageError.new("#{label} must not contain a query or fragment: #{raw}") if uri.query || uri.fragment unless uri.path.nil? || uri.path.empty? || uri.path == "/" raise UsageError.new("#{label} must be an origin root (no path): #{raw}") end - # A dangling port delimiter ("https://example.com:") parses to the - # default-port origin under URI, silently accepting a malformed authority. - # Reject the empty port explicitly. IPv6 authorities legitimately end with - # "]" (e.g. "[::1]"), so only a trailing ":" is a dangling port. - authority = raw.to_s.split("://", 2)[1].to_s.split(%r{[/?#]}, 2)[0].to_s + # A dangling port delimiter ("https://example.com:") silently accepts a + # malformed authority. IPv6 authorities legitimately end with "]" (e.g. + # "[::1]"), so only a trailing ":" is a dangling port. if authority.end_with?(":") raise UsageError.new("#{label} has an invalid port: #{raw}") end diff --git a/ruby/test/basecamp/security_test.rb b/ruby/test/basecamp/security_test.rb index 4febaf114..ec1240d94 100644 --- a/ruby/test/basecamp/security_test.rb +++ b/ruby/test/basecamp/security_test.rb @@ -230,6 +230,15 @@ def test_rejects_dangling_port_delimiter assert_match(/port/, error.message) end + def test_rejects_empty_userinfo + # URI reports delimiter-only userinfo ("https://@example.com") as an empty + # (falsy) string, so rejection must key on the raw authority's "@" presence. + error = assert_raises(Basecamp::UsageError) do + Basecamp::Security.require_origin_root!("https://@example.com") + end + assert_match(/userinfo/, error.message) + end + def test_rejects_path_beyond_root assert_raises(Basecamp::UsageError) do Basecamp::Security.require_origin_root!("https://example.com/foo") diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 4de33ec0f..7f53078c2 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -171,6 +171,12 @@ export function requireOriginRoot(raw: string, label = "origin"): string { if (url.port !== "" && (Number(url.port) < 1 || Number(url.port) > 65535)) { throw new BasecampError("usage", `${label} has an invalid port: ${raw}`); } + // WHATWG normalizes a dangling port ("https://host:") to url.port === "", so the + // check above misses it; scan the raw authority for a trailing ":" (an IPv6 + // authority ends with "]", so only a trailing ":" is a dangling port). + if (rawAuthority.endsWith(":")) { + throw new BasecampError("usage", `${label} has an invalid port: ${raw}`); + } // Note: a surviving url has a structurally valid (possibly default) port. url.origin // drops a default port and any trailing slash — exactly the normalized origin we want. return url.origin; diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index 3ec5fc54e..0361cb770 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -361,6 +361,12 @@ describe("requireOriginRoot userinfo rejection", () => { it("rejects port 0", () => { expect(() => requireOriginRoot("https://host:0")).toThrow(/invalid port/); }); + + // WHATWG normalizes a dangling ":" away (url.port === ""); the raw-authority + // scan must still reject it. + it("rejects a dangling port delimiter", () => { + expect(() => requireOriginRoot("https://host:")).toThrow(/invalid port/); + }); }); describe("resource metadata strictness (#369 review)", () => { From ed83fe5053e109a61fb6fd306f3cd2a9125ab037 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 20:01:51 -0700 Subject: [PATCH 21/38] oauth: bind resource identifier to the raw caller origin; Kotlin port-0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh #369 review round — two contract fixes. Resource-identifier binding (all five SDKs): discoverProtectedResource fetched from the normalized origin AND bound doc.resource against that normalized value, so a caller supplying a valid non-normalized spelling ("https://api.example/" or ":443") whose server echoes it was rejected and discoverFromResource soft-fell back to Launchpad. Per RFC 9728 §3.1 (the trailing slash is removed only while constructing the well-known URL) and §3.3 (the returned resource must be identical to the identifier used), and SPEC.md §16 ("NO normalization"), bind doc.resource against the RAW caller resourceOrigin, using the normalized origin only for the fetch URL. This matches the issuer-binding split already shipped. The fixture harnesses (Python/Ruby/TS, which match by exact URL) now register hop-1 at the normalized origin so a non-normalized resourceOrigin still hits it. Kotlin port zero: Ktor's url.port is the EFFECTIVE port, so an explicit ":0" normalized to the default and passed the origin-root check. Inspect the raw authority's port token and reject anything outside 1-65535 (this also subsumes the dangling-":" case). New shared fixtures 30 (port zero) and 31 (resource trailing-slash binds) with Kotlin mirrors; default-port binding covered by per-SDK unit tests (Go's httptest harness binds a dynamic non-default port, so an explicit :443 placeholder can't be expressed there — its raw-binding is covered by fixture 31). --- .../fixtures/30-origin-root-port-zero.json | 12 ++++++ .../31-resource-trailing-slash-binds.json | 17 ++++++++ go/pkg/basecamp/oauth/discovery.go | 20 +++++++--- .../kotlin/com/basecamp/sdk/Urls.kt | 21 +++++++--- .../com/basecamp/sdk/oauth/Discovery.kt | 9 +++-- .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 40 +++++++++++++++++++ python/src/basecamp/oauth/discovery.py | 9 +++-- python/tests/oauth/test_resource_discovery.py | 20 +++++++++- ruby/lib/basecamp/oauth/resource.rb | 9 +++-- .../basecamp/oauth_resource_discovery_test.rb | 6 ++- ruby/test/basecamp/oauth_test.rb | 12 ++++++ typescript/src/oauth/discovery.ts | 9 +++-- .../tests/oauth/resource-discovery.test.ts | 17 +++++++- 13 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 conformance/oauth/fixtures/30-origin-root-port-zero.json create mode 100644 conformance/oauth/fixtures/31-resource-trailing-slash-binds.json diff --git a/conformance/oauth/fixtures/30-origin-root-port-zero.json b/conformance/oauth/fixtures/30-origin-root-port-zero.json new file mode 100644 index 000000000..84fb2eb9e --- /dev/null +++ b/conformance/oauth/fixtures/30-origin-root-port-zero.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-port-zero", + "description": "Resource origin with an explicit port 0 ('https://host:0') is malformed. Some parsers report an effective default port for :0, so each SDK inspects the raw authority's port token and rejects anything outside 1-65535.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https://api.example.com:0", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/conformance/oauth/fixtures/31-resource-trailing-slash-binds.json b/conformance/oauth/fixtures/31-resource-trailing-slash-binds.json new file mode 100644 index 000000000..2d00e7684 --- /dev/null +++ b/conformance/oauth/fixtures/31-resource-trailing-slash-binds.json @@ -0,0 +1,17 @@ +{ + "name": "resource-trailing-slash-binds", + "description": "The caller's resource identifier carries a trailing slash that normalizes away for the fetch URL. Binding is code-point-exact against the ORIGINAL caller identifier (RFC 9728 §3.1/§3.3, no normalization), so a resource document that echoes the trailing-slash identifier binds instead of a false mismatch.", + "operation": "discoverProtectedResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}/", + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}/" + } + }, + "expect": { + "outcome": "selected", + "selectedIssuer": "{{RESOURCE_ORIGIN}}/", + "launchpadContacted": false + } +} diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index b77a8ab8c..fa4721b7d 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -453,10 +453,16 @@ func (d *Discoverer) DiscoverProtectedResource(ctx context.Context, resourceOrig if err != nil { return nil, err } - return d.fetchProtectedResource(ctx, origin, newDiscoverConfig(opts)) + return d.fetchProtectedResource(ctx, origin, resourceOrigin, newDiscoverConfig(opts)) } -func (d *Discoverer) fetchProtectedResource(ctx context.Context, origin string, cfg discoverConfig) (*ProtectedResourceMetadata, error) { +// fetchProtectedResource fetches from origin's well-known URL but binds the +// metadata resource against bindResource by code-point. Routing and binding are +// distinct (RFC 9728 §3.1/§3.3): the well-known URL is built from the normalized +// origin, but doc.resource must be identical to the resource identifier the +// caller supplied — with NO normalization (SPEC.md §16). DiscoverProtectedResource +// and DiscoverFromResource pass the raw resourceOrigin. +func (d *Discoverer) fetchProtectedResource(ctx context.Context, origin, bindResource string, cfg discoverConfig) (*ProtectedResourceMetadata, error) { body, err := d.fetchDiscoveryDocument(ctx, origin+wellKnownResource, cfg) if err != nil { return nil, err @@ -475,11 +481,12 @@ func (d *Discoverer) fetchProtectedResource(ctx context.Context, origin string, if err := rejectNullListFields(body, "authorization_servers"); err != nil { return nil, err } - // Bind resource identifier to the requested origin, code-point exact. - if *raw.Resource != origin { + // Bind the resource identifier to the requested identifier (the raw caller + // origin), code-point exact, NO normalization (RFC 9728 §3.3, SPEC.md §16). + if *raw.Resource != bindResource { return nil, &basecamp.Error{ Code: basecamp.CodeAPI, - Message: fmt.Sprintf("resource identifier mismatch: metadata resource %q does not equal %q", *raw.Resource, origin), + Message: fmt.Sprintf("resource identifier mismatch: metadata resource %q does not equal %q", *raw.Resource, bindResource), } } @@ -508,7 +515,8 @@ func (d *Discoverer) DiscoverFromResource(ctx context.Context, resourceOrigin st } // --- Hop 1: resource metadata. Failure here is soft (before selection). --- - resource, err := d.fetchProtectedResource(ctx, origin, cfg) + // Fetch from the normalized origin, bind against the raw caller identifier. + resource, err := d.fetchProtectedResource(ctx, origin, resourceOrigin, cfg) if err != nil { var be *basecamp.Error if errors.As(err, &be) && be.Code == basecamp.CodeUsage { diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt index 37ffdbfdc..b384f6bff 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt @@ -103,11 +103,22 @@ internal fun requireOriginRoot(raw: String, label: String = "origin"): String { if (authority.contains('@') || !url.user.isNullOrEmpty() || !url.password.isNullOrEmpty()) { throw BasecampException.Usage("$label must not contain userinfo: ${BasecampException.truncateMessage(raw)}") } - // A dangling ":" ("https://host:") parses to the default-port origin under - // Ktor, silently accepting a malformed authority. IPv6 authorities end with - // "]" (e.g. "[::1]"), so only a trailing ":" is a dangling port. - if (authority.endsWith(':')) { - throw BasecampException.Usage("$label has an invalid port: ${BasecampException.truncateMessage(raw)}") + // Ktor's url.port is the EFFECTIVE port: an explicit ":0" and an absent port + // both normalize to the default, and a dangling ":" is dropped — so url.port + // can't distinguish them. Inspect the RAW authority instead. IPv6 authorities + // are bracketed ("[::1]"), so a port (if any) follows "]:"; otherwise it + // follows the sole ":". A present-but-invalid port token (empty for a dangling + // ":", "0", or out of 1–65535) is rejected. + val portToken = when { + authority.contains("]:") -> authority.substringAfterLast("]:") + !authority.contains(']') && authority.contains(':') -> authority.substringAfterLast(':') + else -> null + } + if (portToken != null) { + val port = portToken.toIntOrNull() + if (port == null || port < 1 || port > 65535) { + throw BasecampException.Usage("$label has an invalid port: ${BasecampException.truncateMessage(raw)}") + } } // trailingQuery catches a bare '?' with an empty query (e.g. `https://host?`), // whose encodedQuery is empty but which is still a query-bearing origin. Ktor diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index 6aa9287d0..9afdaed8b 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -323,10 +323,13 @@ suspend fun discoverProtectedResource( httpStatus = 200, ) } - // Bind resource identifier to the requested origin, code-point exact. - if (resource != origin) { + // Bind the resource identifier to the requested identifier (the raw caller + // origin), code-point exact, NO normalization (RFC 9728 §3.3, SPEC.md §16): the + // well-known URL is built from the normalized origin, but the metadata resource + // must be identical to what the caller supplied. + if (resource != resourceOrigin) { throw BasecampException.Api( - "Resource identifier mismatch: metadata resource \"$resource\" does not equal \"$origin\"", + "Resource identifier mismatch: metadata resource \"$resource\" does not equal \"$resourceOrigin\"", httpStatus = 200, ) } diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index 1c60a7d64..a153fdc0a 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -640,6 +640,46 @@ class OAuthDiscoveryTest { ) ) + @Test fun `30 origin-root port zero rejected`() = runScenario( + Scenario( + name = "origin-root-port-zero", + op = Op.PROTECTED_RESOURCE, + // Ktor's url.port is the EFFECTIVE port, so ":0" looks like the default; + // the raw authority's port token (0) must be rejected as out of range. + resourceOrigin = "https://api.example.com:0", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `31 resource trailing slash binds`() = runScenario( + Scenario( + name = "resource-trailing-slash-binds", + op = Op.PROTECTED_RESOURCE, + // The caller's identifier carries a trailing slash that normalizes away + // for the fetch URL; binding is code-point-exact against the ORIGINAL + // caller identifier, so a resource echoing the trailing slash binds. + resourceOrigin = "$RESOURCE/", + hop1 = Hop(body = "{\"resource\":\"$RESOURCE/\"}"), + selectedIssuer = "$RESOURCE/", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `resource default port binds against the raw caller identifier`() = runScenario( + Scenario( + name = "resource-default-port-binds", + op = Op.PROTECTED_RESOURCE, + // Default-port variant of the raw-identifier bind: ":443" normalizes + // away for the fetch URL but the resource must echo it by code-point. + resourceOrigin = "$RESOURCE:443", + hop1 = Hop(body = "{\"resource\":\"$RESOURCE:443\"}"), + selectedIssuer = "$RESOURCE:443", + launchpadMustBeSilent = true, + ) + ) + @Test fun `discover surfaces issuer mismatch as api_error to external callers`() = runTest { // The module-private binding marker must NOT leak: an external discover() // caller sees an ordinary api_error, identical to any other invalid AS diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index 3a795375e..edffb9244 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -246,11 +246,14 @@ def discover_protected_resource( resource = data.get("resource") if not isinstance(resource, str) or not resource: raise OAuthError("api_error", "Invalid resource metadata: missing required field: resource") - # Bind the resource identifier to the requested origin, code-point exact. - if resource != origin: + # Bind the resource identifier to the requested identifier (the raw caller + # origin), code-point exact, NO normalization (RFC 9728 §3.3, SPEC.md §16): the + # well-known URL is built from the normalized origin, but the metadata resource + # must be identical to what the caller supplied. + if resource != resource_origin: raise OAuthError( "api_error", - f'Resource identifier mismatch: metadata resource "{resource}" does not equal "{origin}"', + f'Resource identifier mismatch: metadata resource "{resource}" does not equal "{resource_origin}"', ) # Preserve absent (None) vs present-empty ([]). When present it must be a list diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index ff6bdc7ef..3a26abede 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -108,7 +108,15 @@ def _track(request: httpx.Request) -> httpx.Response: router.get(f"{ORIGINS['{{LAUNCHPAD_ORIGIN}}']}{WELL_KNOWN_AS}").mock(side_effect=_track) if fx.get("hop1"): - _add_route(router, f"{fx['resourceOrigin']}{WELL_KNOWN_RESOURCE}", fx["hop1"]) + # Register the mock at the NORMALIZED origin: the SDK builds the + # well-known URL from the normalized origin even when the caller's + # spelling differs (trailing slash, explicit :443), so the raw string + # would not match the actual request. + _add_route( + router, + f"{require_origin_root(fx['resourceOrigin'])}{WELL_KNOWN_RESOURCE}", + fx["hop1"], + ) if fx.get("hop2"): issuer_origin = fx["hop2"].get("origin") or fx.get("issuerOrigin") _add_route(router, f"{issuer_origin}{WELL_KNOWN_AS}", fx["hop2"]) @@ -303,6 +311,16 @@ def test_duplicate_advertised_issuer_is_one_candidate_not_ambiguous() -> None: assert result.issuer == bc5 +@respx.mock +def test_resource_binds_against_raw_caller_default_port() -> None: + # ":443" normalizes away for the fetch URL, but the metadata resource is bound + # code-point-exact against the ORIGINAL caller identifier (RFC 9728 §3.3). + res = "https://api.basecamp-test.example" + respx.get(f"{res}{WELL_KNOWN_RESOURCE}").mock(return_value=httpx.Response(200, json={"resource": f"{res}:443"})) + meta = discover_protected_resource(f"{res}:443") + assert meta.resource == f"{res}:443" + + @respx.mock def test_protected_resource_preserves_absent_vs_empty() -> None: origin = "https://api.basecamp-test.example" diff --git a/ruby/lib/basecamp/oauth/resource.rb b/ruby/lib/basecamp/oauth/resource.rb index 6d8091c99..22fbb64fa 100644 --- a/ruby/lib/basecamp/oauth/resource.rb +++ b/ruby/lib/basecamp/oauth/resource.rb @@ -46,11 +46,14 @@ def discover(resource_origin) raise OauthError.new("api_error", "Invalid resource metadata: missing required field: resource") end - # Bind the resource identifier to the requested origin, code-point exact. - unless resource == origin + # Bind the resource identifier to the requested identifier (the raw caller + # origin), code-point exact, NO normalization (RFC 9728 §3.3, SPEC.md §16): + # the well-known URL is built from the normalized +origin+, but the metadata + # +resource+ must be identical to what the caller supplied. + unless resource == resource_origin raise OauthError.new( "api_error", - "Resource identifier mismatch: metadata resource #{resource.inspect} does not equal #{origin.inspect}" + "Resource identifier mismatch: metadata resource #{resource.inspect} does not equal #{resource_origin.inspect}" ) end diff --git a/ruby/test/basecamp/oauth_resource_discovery_test.rb b/ruby/test/basecamp/oauth_resource_discovery_test.rb index b169146c3..681b8f52a 100644 --- a/ruby/test/basecamp/oauth_resource_discovery_test.rb +++ b/ruby/test/basecamp/oauth_resource_discovery_test.rb @@ -77,7 +77,11 @@ def stub_launchpad_well_known def stub_hops(fixture) if (hop1 = fixture["hop1"]) - stub_exchange("#{fixture["resourceOrigin"]}#{WELL_KNOWN_RESOURCE}", hop1) + # Register the mock at the NORMALIZED origin: the SDK builds the well-known + # URL from the normalized origin even when the caller's spelling differs + # (trailing slash, explicit :443), so the raw string would not match. + origin = Basecamp::Security.require_origin_root!(fixture["resourceOrigin"]) + stub_exchange("#{origin}#{WELL_KNOWN_RESOURCE}", hop1) end if (hop2 = fixture["hop2"]) diff --git a/ruby/test/basecamp/oauth_test.rb b/ruby/test/basecamp/oauth_test.rb index 49129264c..d2c4513f3 100644 --- a/ruby/test/basecamp/oauth_test.rb +++ b/ruby/test/basecamp/oauth_test.rb @@ -178,6 +178,18 @@ def test_discover_protected_resource_rejects_wrong_typed_resource assert_equal "api_error", error.type end + def test_resource_binds_against_raw_caller_default_port + # ":443" normalizes away for the fetch URL, but the metadata resource is bound + # code-point-exact against the ORIGINAL caller identifier (RFC 9728 §3.3). + res = "https://api.example.com" + stub_request(:get, "#{res}/.well-known/oauth-protected-resource") + .to_return(status: 200, body: { resource: "#{res}:443" }.to_json, + headers: { "Content-Type" => "application/json" }) + + meta = Basecamp::Oauth.discover_protected_resource("#{res}:443") + assert_equal "#{res}:443", meta.resource + end + def test_present_null_authorization_servers_is_malformed_not_empty # A present JSON null authorization_servers is MALFORMED metadata, not # "present but empty": it must fail hop-1 (soft resource_discovery_failed), diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 7f53078c2..03cb670a8 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -485,11 +485,14 @@ export async function discoverProtectedResource( if (typeof data.resource !== "string" || !data.resource) { throw new BasecampError("api_error", "Invalid resource metadata: missing required field (resource)"); } - // Bind resource identifier to the requested origin, code-point exact. - if (data.resource !== origin) { + // Bind the resource identifier to the requested identifier (the raw caller + // origin), code-point exact, NO normalization (RFC 9728 §3.3, SPEC.md §16): the + // well-known URL is built from the normalized origin, but doc.resource must be + // identical to what the caller supplied. + if (data.resource !== resourceOrigin) { throw new BasecampError( "api_error", - `Resource identifier mismatch: metadata resource "${data.resource}" does not equal "${origin}"` + `Resource identifier mismatch: metadata resource "${data.resource}" does not equal "${resourceOrigin}"` ); } diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index 0361cb770..1f72ce659 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -130,7 +130,10 @@ describe("resource-first discovery fixtures", () => { } if (fx.hop1) { - const resourceOrigin = fx.resourceOrigin!; + // Register the mock at the NORMALIZED origin: the SDK builds the well-known + // URL from the normalized origin even when the caller's spelling differs + // (e.g. a trailing slash or explicit :443), so the raw string wouldn't match. + const resourceOrigin = requireOriginRoot(fx.resourceOrigin!); server.use(handlerFor(`${resourceOrigin}${WELL_KNOWN.resource}`, fx.hop1)); } if (fx.hop2) { @@ -415,6 +418,18 @@ describe("resource metadata strictness (#369 review)", () => { expect(result.issuer).toBe(advertised); }); + it("binds the resource identifier against the raw caller string (default port)", async () => { + // ":443" normalizes away for the fetch URL, but the metadata resource is bound + // code-point-exact against the ORIGINAL caller identifier (RFC 9728 §3.3). + server.use( + mswHttp.get(`${RESOURCE}/.well-known/oauth-protected-resource`, () => + HttpResponse.json({ resource: `${RESOURCE}:443` }) + ) + ); + const meta = await discoverProtectedResource(`${RESOURCE}:443`); + expect(meta.resource).toBe(`${RESOURCE}:443`); + }); + it("rejects scopes_supported that is not an array of strings", async () => { server.use( mswHttp.get(`${BC5}/.well-known/oauth-authorization-server`, () => From 773f1ea71d471dfc504e6e459edfd08f3d395288 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 20:24:15 -0700 Subject: [PATCH 22/38] oauth: raw-identifier binding through the orchestrator + public discover; signed port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth #369 review round — complete the raw-identifier binding contract and close two more origin-root parity gaps. Binding reached only the direct discoverProtectedResource path last round. Now: - Orchestrator (discoverFromResource) passes the RAW resourceOrigin to hop-1 binding instead of the normalized origin (TS/Python/Ruby/Kotlin; Go already threaded it). A caller spelling like "https://api.example/" whose server echoes it no longer soft-falls back to Launchpad. - Public discover() binds AS metadata against the caller's RAW baseUrl, not the normalized issuerOrigin (all five), per RFC 8414 §3.3 + SPEC.md §16. Existing trailing-slash discover tests (Go/TS/Ruby) updated: the slash normalizes only for the fetch URL, so the AS must echo the trailing-slash issuer to bind. Signed port ("https://host:+1"): Kotlin (Ktor's url.port is effective) and Python (httpx coerces "+1" to 1) accepted it; Go/Ruby/TS reject at parse. Both now restrict the raw authority's port token to ASCII digits (subsuming the dangling-":" case). New shared fixtures 32 (signed port), 33 (public-discover raw issuer bind), 34 (orchestrator raw resource bind) + Kotlin mirrors; default-port binding is per-SDK unit tests. Ruby IPv6 finding re-confirmed a false positive (URI#host preserves "[::1]") and pinned with an explicit round-trip test. --- .../fixtures/32-origin-root-signed-port.json | 12 ++++++ ...-discover-issuer-trailing-slash-binds.json | 18 ++++++++ ...4-resource-first-trailing-slash-binds.json | 27 ++++++++++++ go/pkg/basecamp/oauth/discovery.go | 4 +- go/pkg/basecamp/oauth/oauth_test.go | 15 ++++--- .../kotlin/com/basecamp/sdk/Urls.kt | 4 +- .../com/basecamp/sdk/oauth/Discovery.kt | 13 +++--- .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 41 +++++++++++++++++++ python/src/basecamp/_security.py | 16 ++++++-- python/src/basecamp/oauth/discovery.py | 13 +++--- ruby/lib/basecamp/oauth.rb | 7 ++-- ruby/lib/basecamp/oauth/discovery.rb | 4 +- ruby/test/basecamp/oauth_test.rb | 7 +++- ruby/test/basecamp/security_test.rb | 7 ++++ typescript/src/oauth/discovery.ts | 13 +++--- typescript/tests/oauth/oauth.test.ts | 9 ++-- 16 files changed, 171 insertions(+), 39 deletions(-) create mode 100644 conformance/oauth/fixtures/32-origin-root-signed-port.json create mode 100644 conformance/oauth/fixtures/33-discover-issuer-trailing-slash-binds.json create mode 100644 conformance/oauth/fixtures/34-resource-first-trailing-slash-binds.json diff --git a/conformance/oauth/fixtures/32-origin-root-signed-port.json b/conformance/oauth/fixtures/32-origin-root-signed-port.json new file mode 100644 index 000000000..860fadfc0 --- /dev/null +++ b/conformance/oauth/fixtures/32-origin-root-signed-port.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-signed-port", + "description": "Resource origin with a signed port token ('https://host:+1') is malformed. Some parsers coerce '+1' to 1, so each SDK restricts the raw authority's port token to ASCII digits and rejects a sign.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https://api.example.com:+1", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/conformance/oauth/fixtures/33-discover-issuer-trailing-slash-binds.json b/conformance/oauth/fixtures/33-discover-issuer-trailing-slash-binds.json new file mode 100644 index 000000000..9546fa002 --- /dev/null +++ b/conformance/oauth/fixtures/33-discover-issuer-trailing-slash-binds.json @@ -0,0 +1,18 @@ +{ + "name": "discover-issuer-trailing-slash-binds", + "description": "The public discover() caller supplies an issuer spelling with a trailing slash that normalizes away for the fetch URL. Issuer binding is code-point-exact against the ORIGINAL caller string (RFC 8414 §3.3, SPEC.md §16, no normalization), so an AS echoing the trailing-slash issuer binds instead of a false mismatch.", + "operation": "discover", + "issuerOrigin": "{{ISSUER_ORIGIN}}/", + "hop2": { + "origin": "{{ISSUER_ORIGIN}}", + "status": 200, + "body": { + "issuer": "{{ISSUER_ORIGIN}}/", + "token_endpoint": "{{ISSUER_ORIGIN}}/oauth/token" + } + }, + "expect": { + "outcome": "selected", + "selectedIssuer": "{{ISSUER_ORIGIN}}/" + } +} diff --git a/conformance/oauth/fixtures/34-resource-first-trailing-slash-binds.json b/conformance/oauth/fixtures/34-resource-first-trailing-slash-binds.json new file mode 100644 index 000000000..56f94c98a --- /dev/null +++ b/conformance/oauth/fixtures/34-resource-first-trailing-slash-binds.json @@ -0,0 +1,27 @@ +{ + "name": "resource-first-trailing-slash-binds", + "description": "The orchestrator (discoverFromResource) is called with a resource identifier carrying a trailing slash. It must pass the RAW identifier through to hop-1 binding (not the normalized origin), so a resource document echoing the trailing-slash identifier binds and selection proceeds instead of a false resource_discovery_failed.", + "operation": "discoverFromResource", + "resourceOrigin": "{{RESOURCE_ORIGIN}}/", + "bc5Committed": true, + "hop1": { + "status": 200, + "body": { + "resource": "{{RESOURCE_ORIGIN}}/", + "authorization_servers": ["{{BC5_ISSUER}}", "{{LAUNCHPAD_ORIGIN}}"] + } + }, + "hop2": { + "origin": "{{BC5_ISSUER}}", + "status": 200, + "body": { + "issuer": "{{BC5_ISSUER}}", + "token_endpoint": "{{BC5_ISSUER}}/oauth/token" + } + }, + "expect": { + "outcome": "selected", + "selectedIssuer": "{{BC5_ISSUER}}", + "launchpadContacted": false + } +} diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index fa4721b7d..5fe093116 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -322,7 +322,9 @@ func (d *Discoverer) Discover(ctx context.Context, baseURL string, opts ...Disco if err != nil { return nil, err } - return d.fetchASMetadata(ctx, origin, origin, newDiscoverConfig(opts)) + // Bind against the caller's raw baseURL (RFC 8414 §3.3, SPEC.md §16 "NO + // normalization"); the normalized origin is only for the fetch URL. + return d.fetchASMetadata(ctx, origin, baseURL, newDiscoverConfig(opts)) } // rawDiscoveryResponse mirrors an RFC 8414 metadata document. Endpoint fields diff --git a/go/pkg/basecamp/oauth/oauth_test.go b/go/pkg/basecamp/oauth/oauth_test.go index a87b08fd2..eb08f6ff2 100644 --- a/go/pkg/basecamp/oauth/oauth_test.go +++ b/go/pkg/basecamp/oauth/oauth_test.go @@ -119,24 +119,27 @@ func TestDiscoverer_Discover_MidStreamReadFailureIsNetwork(t *testing.T) { } func TestDiscoverer_Discover_TrailingSlash(t *testing.T) { - var origin string + // A trailing slash is normalized away for the fetch URL (routing), but issuer + // binding is code-point-exact against the caller's RAW baseURL (RFC 8414 §3.3, + // SPEC.md §16), so the AS must echo the trailing-slash issuer to bind. + var caller string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/.well-known/oauth-authorization-server" { t.Errorf("unexpected path: %s", r.URL.Path) } - _, _ = fmt.Fprintf(w, `{"issuer":%q,"token_endpoint":%q}`, origin, origin+"/token") + _, _ = fmt.Fprintf(w, `{"issuer":%q,"token_endpoint":%q}`, caller, caller+"token") })) defer server.Close() - origin = server.URL + caller = server.URL + "/" d := NewDiscoverer(server.Client()) - cfg, err := d.Discover(context.Background(), server.URL+"/") + cfg, err := d.Discover(context.Background(), caller) if err != nil { t.Fatalf("Discover() with trailing slash failed: %v", err) } - if cfg.Issuer != origin { - t.Errorf("Discover() issuer = %q, want %q", cfg.Issuer, origin) + if cfg.Issuer != caller { + t.Errorf("Discover() issuer = %q, want %q", cfg.Issuer, caller) } } diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt index b384f6bff..0f08c0e5d 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt @@ -115,7 +115,9 @@ internal fun requireOriginRoot(raw: String, label: String = "origin"): String { else -> null } if (portToken != null) { - val port = portToken.toIntOrNull() + // Restrict to ASCII digits before parsing: toIntOrNull accepts a signed + // token ("+1", "-1"), which is not a valid port authority. + val port = portToken.takeIf { token -> token.isNotEmpty() && token.all { it in '0'..'9' } }?.toIntOrNull() if (port == null || port < 1 || port > 65535) { throw BasecampException.Usage("$label has an invalid port: ${BasecampException.truncateMessage(raw)}") } diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index 9afdaed8b..497606d38 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -141,7 +141,9 @@ private data class RawResourceResponse( */ suspend fun discover(baseUrl: String, client: HttpClient? = null): OAuthConfig = try { - fetchAndBindAsMetadata(baseUrl, client) + // Bind against the caller's raw baseUrl (RFC 8414 §3.3, SPEC.md §16 "NO + // normalization"); the normalized origin is only for the fetch URL. + fetchAndBindAsMetadata(baseUrl, client, bindIssuer = baseUrl) } catch (marker: IssuerBindingException) { // The binding failure is signalled internally by a module-private marker // so discoverFromResource can classify it by type. To external callers it @@ -361,13 +363,12 @@ suspend fun discoverFromResource( expectedIssuer: String? = null, client: HttpClient? = null, ): DiscoveryResult { - // Origin-root validation of the *caller's* input is a usage error — let it - // propagate as-is (not a soft fallback). - val origin = requireOriginRoot(resourceOrigin, "resource origin") - // --- Hop 1: resource metadata. Failure here is soft (before selection). --- + // Pass the RAW resourceOrigin so binding is code-point-exact against the caller's + // identifier (SPEC.md §16); discoverProtectedResource normalizes only its fetch + // URL. A malformed caller origin surfaces as usage (re-thrown below), not a fallback. val resource: ProtectedResourceMetadata = try { - discoverProtectedResource(origin, client) + discoverProtectedResource(resourceOrigin, client) } catch (e: BasecampException.Usage) { throw e } catch (e: BasecampException) { diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index a153fdc0a..1a6b426b5 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -680,6 +680,47 @@ class OAuthDiscoveryTest { ) ) + @Test fun `32 origin-root signed port rejected`() = runScenario( + Scenario( + name = "origin-root-signed-port", + op = Op.PROTECTED_RESOURCE, + // "+1" would coerce to port 1 via toIntOrNull; the port token must be + // restricted to ASCII digits so a sign is rejected. + resourceOrigin = "https://api.example.com:+1", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + + @Test fun `33 discover issuer trailing slash binds`() = runScenario( + Scenario( + name = "discover-issuer-trailing-slash-binds", + op = Op.DISCOVER, + // Public discover() binds against the caller's RAW issuer spelling: the + // trailing slash normalizes away for the fetch URL but the AS issuer + // must echo it by code-point (RFC 8414 §3.3). + issuerOrigin = "$ISSUER/", + hop2 = Hop(body = "{\"issuer\":\"$ISSUER/\",\"token_endpoint\":\"$ISSUER/oauth/token\"}"), + selectedIssuer = "$ISSUER/", + ) + ) + + @Test fun `34 resource-first trailing slash binds`() = runScenario( + Scenario( + name = "resource-first-trailing-slash-binds", + op = Op.FROM_RESOURCE, + // The orchestrator passes the RAW resource identifier through to hop-1 + // binding, so a resource echoing the trailing-slash identifier binds and + // selection proceeds (not a false resource_discovery_failed). + resourceOrigin = "$RESOURCE/", + hop1 = Hop(body = resourceBody("$RESOURCE/", listOf(BC5, LAUNCHPAD))), + hop2 = Hop(body = "{\"issuer\":\"$BC5\",\"token_endpoint\":\"$BC5/oauth/token\"}"), + selectedIssuer = BC5, + launchpadMustBeSilent = true, + ) + ) + @Test fun `discover surfaces issuer mismatch as api_error to external callers`() = runTest { // The module-private binding marker must NOT leak: an external discover() // caller sees an ordinary api_error, identical to any other invalid AS diff --git a/python/src/basecamp/_security.py b/python/src/basecamp/_security.py index cfbe207fc..716c92a79 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -107,10 +107,18 @@ def require_origin_root(raw: str, label: str = "origin") -> str: raise UsageError(f"{label} must be an origin root (no path): {raw}") # A dangling port delimiter ("https://host:") normalizes to port None under - # httpx, silently accepting a malformed authority. Reject a trailing ":" on the - # raw authority explicitly. IPv6 authorities legitimately end with "]" (e.g. - # "[::1]"), so only a trailing ":" is a dangling port. - if authority.endswith(":"): + # httpx, silently accepting a malformed authority. Also reject a signed port + # token ("+1"/"-1"): httpx parses "+1" to 1, which would pass the range check. + # Inspect the raw authority's port segment — IPv6 is bracketed ("[::1]"), so a + # port follows "]:"; otherwise it follows the sole ":". + if "]:" in authority: + port_token: str | None = authority.rsplit("]:", 1)[1] + elif "]" not in authority and ":" in authority: + port_token = authority.rsplit(":", 1)[1] + else: + port_token = None + if port_token is not None and not (port_token.isascii() and port_token.isdigit()): + # Empty (dangling ":") or non-digit ("+1") port token — malformed authority. raise UsageError(f"{label} has an invalid port: {raw}") # httpx does not range-check the port (it accepts :99999), so enforce 1–65535 diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index edffb9244..3da3e7456 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -143,7 +143,9 @@ def discover( :class:`OAuthError` (``api_error``) on invalid metadata. """ issuer_origin = require_origin_root(base_url, "OAuth discovery base URL") - return _discover_and_bind(issuer_origin, issuer_origin, timeout=timeout, max_body_bytes=max_body_bytes) + # Bind against the caller's raw base_url (RFC 8414 §3.3, SPEC.md §16 "NO + # normalization"); the normalized origin is only for the fetch URL. + return _discover_and_bind(issuer_origin, base_url, timeout=timeout, max_body_bytes=max_body_bytes) def _discover_and_bind( @@ -311,13 +313,12 @@ def discover_from_resource( :class:`DiscoverySelectionError`; callers MUST NOT convert a raise into a Launchpad request. """ - # Origin-root validation of the *caller's* input is a usage error — let it - # propagate as-is (never a soft fallback). - origin = require_origin_root(resource_origin, "resource origin") - # --- Hop 1: resource metadata. Failure here is soft (before selection). --- + # Pass the RAW resource_origin so binding is code-point-exact against the caller's + # identifier (SPEC.md §16); discover_protected_resource normalizes only its fetch + # URL. A malformed caller origin raises UsageError (not caught here → propagates). try: - resource = discover_protected_resource(origin, timeout=timeout, max_body_bytes=max_body_bytes) + resource = discover_protected_resource(resource_origin, timeout=timeout, max_body_bytes=max_body_bytes) except OAuthError: return DiscoveryResult(kind="fallback", reason=FallbackReason.RESOURCE_DISCOVERY_FAILED) diff --git a/ruby/lib/basecamp/oauth.rb b/ruby/lib/basecamp/oauth.rb index dbd027c27..90a6cf4de 100644 --- a/ruby/lib/basecamp/oauth.rb +++ b/ruby/lib/basecamp/oauth.rb @@ -97,11 +97,12 @@ def self.discover_protected_resource(resource_origin, timeout: 10) def self.discover_from_resource(resource_origin, expected_issuer: nil, timeout: 10) # Origin-root validation of the *caller's* input is a usage error — let it # propagate as-is (not a soft fallback). - origin = Basecamp::Security.require_origin_root!(resource_origin, "resource origin") - # Hop 1: resource metadata. Any failure here is soft (before selection). + # Pass the RAW resource_origin so binding is code-point-exact against the + # caller's identifier (SPEC.md §16); discover_protected_resource normalizes + # only its fetch URL. A malformed caller origin raises UsageError → re-raised. resource = begin - discover_protected_resource(origin, timeout: timeout) + discover_protected_resource(resource_origin, timeout: timeout) rescue Basecamp::UsageError raise rescue OauthError diff --git a/ruby/lib/basecamp/oauth/discovery.rb b/ruby/lib/basecamp/oauth/discovery.rb index 4a080123b..4c437bc7f 100644 --- a/ruby/lib/basecamp/oauth/discovery.rb +++ b/ruby/lib/basecamp/oauth/discovery.rb @@ -53,7 +53,9 @@ def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_M # config.token_endpoint # => "https://launchpad.37signals.com/authorization/token" def discover(base_url) issuer_origin = Basecamp::Security.require_origin_root!(base_url, "OAuth discovery base URL") - discover_and_bind(issuer_origin, issuer_origin) + # Bind against the caller's raw +base_url+ (RFC 8414 §3.3, SPEC.md §16 "NO + # normalization"); the normalized origin is only for the fetch URL. + discover_and_bind(issuer_origin, base_url) end # Resource-first entry: validate +advertised_issuer+ as an origin root, then diff --git a/ruby/test/basecamp/oauth_test.rb b/ruby/test/basecamp/oauth_test.rb index d2c4513f3..9d5b50874 100644 --- a/ruby/test/basecamp/oauth_test.rb +++ b/ruby/test/basecamp/oauth_test.rb @@ -36,8 +36,11 @@ def test_discover_custom_url end def test_discover_handles_trailing_slash + # A trailing slash is normalized away for the well-known fetch (routing), but + # issuer binding is code-point-exact against the caller's RAW base_url (RFC + # 8414 §3.3, SPEC.md §16), so the AS must echo the trailing-slash issuer. discovery_response = { - "issuer" => "https://launchpad.37signals.com", + "issuer" => "https://launchpad.37signals.com/", "authorization_endpoint" => "https://launchpad.37signals.com/authorization/new", "token_endpoint" => "https://launchpad.37signals.com/authorization/token" } @@ -46,7 +49,7 @@ def test_discover_handles_trailing_slash .to_return(status: 200, body: discovery_response.to_json, headers: { "Content-Type" => "application/json" }) config = Basecamp::Oauth.discover("https://launchpad.37signals.com/") - assert_equal "https://launchpad.37signals.com", config.issuer + assert_equal "https://launchpad.37signals.com/", config.issuer end def test_discover_raises_on_missing_fields diff --git a/ruby/test/basecamp/security_test.rb b/ruby/test/basecamp/security_test.rb index ec1240d94..dc48571ba 100644 --- a/ruby/test/basecamp/security_test.rb +++ b/ruby/test/basecamp/security_test.rb @@ -230,6 +230,13 @@ def test_rejects_dangling_port_delimiter assert_match(/port/, error.message) end + def test_preserves_bracketed_ipv6_literal + # URI#host preserves the brackets for an IPv6 literal (returns "[::1]", not + # "::1"), so the rebuilt origin round-trips without double- or un-bracketing. + assert_equal "http://[::1]:3000", Basecamp::Security.require_origin_root!("http://[::1]:3000") + assert_equal "https://[2001:db8::1]", Basecamp::Security.require_origin_root!("https://[2001:db8::1]") + end + def test_rejects_empty_userinfo # URI reports delimiter-only userinfo ("https://@example.com") as an empty # (falsy) string, so rejection must key on the raw authority's "@" presence. diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 03cb670a8..29cca328e 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -346,7 +346,9 @@ export async function discover( options: DiscoverOptions = {} ): Promise { const issuerOrigin = requireOriginRoot(baseUrl, "OAuth discovery base URL"); - return discoverAndBind(issuerOrigin, issuerOrigin, options); + // Bind against the caller's raw baseUrl (RFC 8414 §3.3, SPEC.md §16 "NO + // normalization"); the normalized origin is only for the fetch URL. + return discoverAndBind(issuerOrigin, baseUrl, options); } /** @@ -533,14 +535,13 @@ export async function discoverFromResource( ): Promise { const { expectedIssuer, ...discoverOptions } = options; - // Origin-root validation of the *caller's* input is a usage error — let it - // propagate as-is (not a soft fallback). - const origin = requireOriginRoot(resourceOrigin, "resource origin"); - // --- Hop 1: resource metadata. Failure here is soft (before selection). --- + // Pass the RAW resourceOrigin so binding is code-point-exact against the caller's + // identifier (SPEC.md §16); discoverProtectedResource normalizes only its fetch + // URL. A malformed caller origin surfaces as usage (re-raised below), not a fallback. let resource: ProtectedResourceMetadata; try { - resource = await discoverProtectedResource(origin, discoverOptions); + resource = await discoverProtectedResource(resourceOrigin, discoverOptions); } catch (err) { if (err instanceof BasecampError && err.code === "usage") throw err; return { kind: "fallback", reason: "resource_discovery_failed" }; diff --git a/typescript/tests/oauth/oauth.test.ts b/typescript/tests/oauth/oauth.test.ts index f2f4c1edb..9be8ed68f 100644 --- a/typescript/tests/oauth/oauth.test.ts +++ b/typescript/tests/oauth/oauth.test.ts @@ -114,17 +114,20 @@ describe("OAuth Discovery", () => { expect(config.codeChallengeMethodsSupported).toBeUndefined(); }); - it("normalizes trailing slash in base URL", async () => { + it("normalizes a trailing slash for the fetch URL but binds against the raw issuer", async () => { + // The trailing slash is dropped only for the well-known fetch (routing); + // issuer binding is code-point-exact against the caller's raw string (RFC + // 8414 §3.3, SPEC.md §16), so the AS must echo the trailing-slash issuer. server.use( http.get( "https://launchpad.37signals.com/.well-known/oauth-authorization-server", - () => HttpResponse.json(mockDiscoveryResponse) + () => HttpResponse.json({ ...mockDiscoveryResponse, issuer: "https://launchpad.37signals.com/" }) ) ); const config = await discover("https://launchpad.37signals.com/"); - expect(config.issuer).toBe("https://launchpad.37signals.com"); + expect(config.issuer).toBe("https://launchpad.37signals.com/"); }); it("throws BasecampError on HTTP error", async () => { From da97a49698b82110d45f37fcc9a0cc62aaae0ccd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 20:35:36 -0700 Subject: [PATCH 23/38] oauth: correct stale binding comments on the internal bind helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discoverAndBind (TS) and fetchAndBindAsMetadata (Kotlin) comments still said public discover() passes the same/normalized value for binding. It now passes the caller's raw baseUrl (RFC 8414 §3.3), so correct the comments to prevent a later change from restoring normalized binding based on them. --- .../commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt | 5 +++-- typescript/src/oauth/discovery.ts | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index 497606d38..0be160883 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -187,8 +187,9 @@ private suspend fun fetchAndBindAsMetadata( throw BasecampException.Api("Failed to parse OAuth discovery response", httpStatus = 200, cause = e) } // Fetch from the normalized origin, but bind the metadata issuer against the - // exact advertised string when supplied (routing vs binding are distinct); - // the public discover passes none, so it binds to its own normalized origin. + // caller's raw identifier (routing vs binding are distinct): public discover + // passes its raw baseUrl, discoverFromResource passes the advertised issuer. + // The `?: issuerOrigin` fallback only applies if a caller omits bindIssuer. return bindAsMetadata(raw, bindIssuer ?: issuerOrigin) } diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 29cca328e..9c4be9851 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -354,10 +354,10 @@ export async function discover( /** * Fetch AS metadata from `issuerOrigin`'s well-known URL but bind the returned * `issuer` against `bindIssuer` by code-point. Routing and binding are distinct: - * the resource-first flow fetches from the normalized origin yet binds against - * the exact advertised issuer string (which may spell a trailing slash or - * explicit default port). Not exported — public `discover` passes the same value - * for both, so it never exposes a binding override. + * fetch from the normalized origin, bind against the caller's raw identifier + * (which may spell a trailing slash or explicit default port). Public `discover` + * passes its raw `baseUrl` as `bindIssuer`; `discoverFromResource` passes the + * advertised issuer. Not exported — no public binding override. */ async function discoverAndBind( issuerOrigin: string, From 2748394f9b4a418b409efda23b84aeaf61c232d8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 20:57:25 -0700 Subject: [PATCH 24/38] oauth: reject C0/space/backslash origin spellings that parsers normalize away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth #369 review round. WHATWG URL (TypeScript) silently strips C0 controls, tabs/newlines, and surrounding spaces, and converts backslashes to forward slashes for special schemes — so "https:\\host", "https://host\n", or a trailing space were cleaned and accepted, letting a malformed caller origin (or a Launchpad look-alike) pass as a valid origin. httpx (Python) similarly percent-encodes a trailing space into the host ("https://host" + " " -> "https://host%20"). Go/Ruby already reject these at parse. Add a uniform up-front raw scan in all five requireOriginRoot implementations that rejects any C0 control, space, or backslash before parsing — none is legitimate in an origin root. New shared fixture 35 + Kotlin mirror; TS/Python unit tests cover the newline/space variants a JSON fixture can't express. --- .../fixtures/35-origin-root-invalid-characters.json | 12 ++++++++++++ go/pkg/basecamp/oauth/discovery.go | 8 ++++++++ .../src/commonMain/kotlin/com/basecamp/sdk/Urls.kt | 6 ++++++ .../kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt | 13 +++++++++++++ python/src/basecamp/_security.py | 7 +++++++ python/tests/oauth/test_resource_discovery.py | 8 ++++++++ ruby/lib/basecamp/security.rb | 7 +++++++ typescript/src/oauth/discovery.ts | 7 +++++++ typescript/tests/oauth/resource-discovery.test.ts | 9 +++++++++ 9 files changed, 77 insertions(+) create mode 100644 conformance/oauth/fixtures/35-origin-root-invalid-characters.json diff --git a/conformance/oauth/fixtures/35-origin-root-invalid-characters.json b/conformance/oauth/fixtures/35-origin-root-invalid-characters.json new file mode 100644 index 000000000..e68065096 --- /dev/null +++ b/conformance/oauth/fixtures/35-origin-root-invalid-characters.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-invalid-characters", + "description": "A caller origin containing a backslash ('https:\\\\api.example.com') is malformed. WHATWG-style parsers convert backslashes to forward slashes for special schemes, normalizing it to a clean origin; each SDK rejects C0 controls, space, and backslash in the raw input up front.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https:\\\\api.example.com", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index 5fe093116..ab0788879 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -113,6 +113,14 @@ func requireOriginRoot(raw, label string) (string, error) { return &basecamp.Error{Code: basecamp.CodeUsage, Message: msg} } + // Reject C0 controls, space, and backslash up front: URL parsers variously + // strip tabs/newlines/surrounding spaces or convert backslashes to slashes, + // so a malformed spelling ("https:\\host", "https://host\n") could be cleaned + // and accepted. None of these code points is legitimate in an origin root. + if strings.IndexFunc(raw, func(r rune) bool { return r <= 0x20 || r == '\\' }) >= 0 { + return "", usage(fmt.Sprintf("%s contains invalid characters: %s", label, raw)) + } + u, err := url.Parse(raw) if err != nil { return "", usage(fmt.Sprintf("invalid %s: not a valid absolute URL: %s", label, raw)) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt index 0f08c0e5d..bc7b3d99b 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt @@ -82,6 +82,12 @@ internal fun requireSecureEndpoint(url: String, label: String) { * @return the normalized origin (scheme://host[:port], no trailing slash). */ internal fun requireOriginRoot(raw: String, label: String = "origin"): String { + // Reject C0 controls, space, and backslash up front: URL parsers variously + // strip tabs/newlines/surrounding spaces or convert backslashes, so a malformed + // spelling could be cleaned and accepted. None is legitimate in an origin root. + if (raw.any { it <= ' ' || it == '\\' }) { + throw BasecampException.Usage("$label contains invalid characters: ${BasecampException.truncateMessage(raw)}") + } val url = parseAbsoluteUrl(raw) ?: throw BasecampException.Usage("Invalid $label: not a valid absolute URL: ${BasecampException.truncateMessage(raw)}") diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index 1a6b426b5..fdecc8be9 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -706,6 +706,19 @@ class OAuthDiscoveryTest { ) ) + @Test fun `35 origin-root invalid characters rejected`() = runScenario( + Scenario( + name = "origin-root-invalid-characters", + op = Op.PROTECTED_RESOURCE, + // A backslash (or C0 control / space) is stripped or converted by WHATWG- + // style parsers; the raw input must be rejected before parsing. + resourceOrigin = "https:\\\\api.example.com", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + @Test fun `34 resource-first trailing slash binds`() = runScenario( Scenario( name = "resource-first-trailing-slash-binds", diff --git a/python/src/basecamp/_security.py b/python/src/basecamp/_security.py index 716c92a79..67482c4e8 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -62,6 +62,13 @@ def require_origin_root(raw: str, label: str = "origin") -> str: Returns the normalized origin (``scheme://host[:port]``, no trailing slash, default port dropped). """ + # Reject C0 controls, space, and backslash up front: URL parsers variously + # strip tabs/newlines/surrounding spaces or percent-encode a space into the + # host (httpx turns "https://host " into "https://host%20"), so a malformed + # spelling could be cleaned and accepted. None is legitimate in an origin root. + if any(c <= " " or c == "\\" for c in raw): + raise UsageError(f"{label} contains invalid characters: {raw}") + # Parse with httpx.URL — the SAME transport parser the client dials with (see # is_localhost below and _http.py) — so validation can never disagree with the # request about scheme/host/port. urllib and httpx diverge on IDNA labels and diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 3a26abede..439cd103e 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -202,6 +202,14 @@ def test_body_cap_normalizes_non_finite_to_default() -> None: assert _normalize_body_cap(4096) == 4096 +@pytest.mark.parametrize("raw", ["https:\\\\host", "https://host\n", "https://host ", "https://ho st"]) +def test_origin_root_rejects_normalized_spellings(raw: str) -> None: + # Parsers strip C0 controls / whitespace or percent-encode a space into the + # host; the up-front raw scan must reject these before they are cleaned. + with pytest.raises(UsageError, match="invalid characters"): + require_origin_root(raw) + + def test_origin_root_rejects_dangling_port() -> None: # A dangling ":" normalizes to port None under httpx (looks like no port); the # raw-authority check must still reject the malformed authority. diff --git a/ruby/lib/basecamp/security.rb b/ruby/lib/basecamp/security.rb index d6c110a83..d253b4307 100644 --- a/ruby/lib/basecamp/security.rb +++ b/ruby/lib/basecamp/security.rb @@ -106,6 +106,13 @@ def self.require_https_unless_localhost!(url, label = "URL") # @return [String] the normalized origin (+scheme://host[:port]+, no trailing slash) # @raise [UsageError] on any profile violation or parse failure def self.require_origin_root!(raw, label = "origin") + # Reject C0 controls, space, and backslash up front: URL parsers variously + # strip tabs/newlines/surrounding spaces or convert backslashes, so a + # malformed spelling could be cleaned and accepted. None is legitimate here. + if raw.to_s.match?(/[\x00-\x20\\]/) + raise UsageError.new("#{label} contains invalid characters: #{raw}") + end + uri = URI.parse(raw.to_s) scheme = uri.scheme&.downcase diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 9c4be9851..7ef6ccdb8 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -131,6 +131,13 @@ export const LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com"; * @returns the normalized origin (scheme://host[:port], no trailing slash) */ export function requireOriginRoot(raw: string, label = "origin"): string { + // Reject C0 controls, space, and backslash up front: WHATWG URL silently strips + // tabs/newlines/surrounding spaces and converts backslashes to forward slashes + // for special schemes, so a malformed spelling ("https:\\host", "https://host\n") + // would be cleaned and accepted. None is legitimate in an origin root. + if (/[\u0000-\u0020\\]/.test(raw)) { + throw new BasecampError("usage", `${label} contains invalid characters: ${raw}`); + } let url: URL; try { url = new URL(raw); diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index 1f72ce659..b6257cfbb 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -370,6 +370,15 @@ describe("requireOriginRoot userinfo rejection", () => { it("rejects a dangling port delimiter", () => { expect(() => requireOriginRoot("https://host:")).toThrow(/invalid port/); }); + + // WHATWG strips C0 controls / surrounding whitespace and converts backslashes; + // the up-front raw scan must reject these spellings before they are cleaned. + it.each(["https:\\\\host", "https://host\n", "https://host ", "https://ho st"])( + "rejects a WHATWG-normalized spelling %j", + (raw) => { + expect(() => requireOriginRoot(raw)).toThrow(/invalid characters/); + } + ); }); describe("resource metadata strictness (#369 review)", () => { From eb7a0bd680842e1d63510dddc5c06add8844c857 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 21:14:49 -0700 Subject: [PATCH 25/38] oauth: append device endpoint for positional compat; reject missing-authority origins Positional-API compatibility (Ruby Config, Kotlin OAuthConfig): device_ authorization_endpoint was inserted BEFORE registration_endpoint, shifting positional callers so OAuthConfig("iss","auth","token","reg") filled the device field and dropped registration. Append it after the pre-existing fields (as Python already does). Both SDKs construct via named args internally, and Kotlin JSON is by @SerialName, so declaration order only affects the positional API. TS requireOriginRoot hardening: - Missing/extra-slash authority: WHATWG recovers "https:host" and "https:///host" into a clean origin. Require an explicit "://" + non-empty authority (Go rejects via Opaque, Python/Ruby via empty host, Kotlin via parseAbsoluteUrl). New shared fixture 36 + Kotlin mirror. - Non-string input (e.g. a Symbol) escaped RegExp.test as a native TypeError now that the raw-scan runs before the guarded URL parse; guard typeof up front. Also broaden the fixture harness's Launchpad tracker to the protected-resource well-known, not just the AS well-known. --- .../36-origin-root-missing-authority.json | 12 ++++++++++++ .../kotlin/com/basecamp/sdk/oauth/Discovery.kt | 7 ++++++- .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 13 +++++++++++++ ruby/lib/basecamp/oauth/config.rb | 12 ++++++++---- typescript/src/oauth/discovery.ts | 12 ++++++++++++ .../tests/oauth/resource-discovery.test.ts | 18 ++++++++++++++++-- 6 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 conformance/oauth/fixtures/36-origin-root-missing-authority.json diff --git a/conformance/oauth/fixtures/36-origin-root-missing-authority.json b/conformance/oauth/fixtures/36-origin-root-missing-authority.json new file mode 100644 index 000000000..0cec77b19 --- /dev/null +++ b/conformance/oauth/fixtures/36-origin-root-missing-authority.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-missing-authority", + "description": "A caller origin without an explicit '//' authority ('https:api.example.com') is malformed. WHATWG-style parsers recover it into a clean origin; each SDK requires an explicit non-empty authority delimiter and rejects it.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https:api.example.com", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index 0be160883..bad989b29 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -42,15 +42,20 @@ import kotlinx.serialization.json.JsonObject * device-only authorization servers omit it, so authorization-code consumers * MUST assert its presence before use. [tokenEndpoint] remains required. */ +// NOTE: deviceAuthorizationEndpoint is APPENDED after the pre-existing fields. +// A data class constructor is a positional public API, so inserting a new field +// mid-list would shift positional callers (e.g. OAuthConfig("iss","auth","token", +// "reg") would fill the wrong field). Keep new fields last. JSON is by @SerialName, +// so declaration order does not affect (de)serialization. @Serializable data class OAuthConfig( val issuer: String, @SerialName("authorization_endpoint") val authorizationEndpoint: String? = null, @SerialName("token_endpoint") val tokenEndpoint: String, - @SerialName("device_authorization_endpoint") val deviceAuthorizationEndpoint: String? = null, @SerialName("registration_endpoint") val registrationEndpoint: String? = null, @SerialName("scopes_supported") val scopesSupported: List = emptyList(), @SerialName("grant_types_supported") val grantTypesSupported: List = emptyList(), + @SerialName("device_authorization_endpoint") val deviceAuthorizationEndpoint: String? = null, ) /** diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index fdecc8be9..7f9504dd2 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -719,6 +719,19 @@ class OAuthDiscoveryTest { ) ) + @Test fun `36 origin-root missing authority rejected`() = runScenario( + Scenario( + name = "origin-root-missing-authority", + op = Op.PROTECTED_RESOURCE, + // No explicit "//" authority; WHATWG-style parsers recover it into a + // clean origin, so it must be rejected as malformed. + resourceOrigin = "https:api.example.com", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + @Test fun `34 resource-first trailing slash binds`() = runScenario( Scenario( name = "resource-first-trailing-slash-binds", diff --git a/ruby/lib/basecamp/oauth/config.rb b/ruby/lib/basecamp/oauth/config.rb index d5bed9288..f26209474 100644 --- a/ruby/lib/basecamp/oauth/config.rb +++ b/ruby/lib/basecamp/oauth/config.rb @@ -16,23 +16,27 @@ module Oauth # @attr registration_endpoint [String, nil] URL of the dynamic client registration endpoint # @attr scopes_supported [Array, nil] List of OAuth 2 scopes supported # @attr grant_types_supported [Array, nil] OAuth 2 grant types the server supports + # NOTE: +device_authorization_endpoint+ is APPENDED after the pre-existing + # members. Data.define's member order is the positional/deconstruct order, so + # inserting a new field mid-list would shift positional callers (and pattern + # matches). Keep new fields last for positional compatibility. Config = Data.define( :issuer, :authorization_endpoint, :token_endpoint, - :device_authorization_endpoint, :registration_endpoint, :scopes_supported, - :grant_types_supported + :grant_types_supported, + :device_authorization_endpoint ) do def initialize( issuer:, token_endpoint:, authorization_endpoint: nil, - device_authorization_endpoint: nil, registration_endpoint: nil, scopes_supported: nil, - grant_types_supported: nil + grant_types_supported: nil, + device_authorization_endpoint: nil ) super end diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 7ef6ccdb8..5e70b3f2c 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -131,6 +131,11 @@ export const LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com"; * @returns the normalized origin (scheme://host[:port], no trailing slash) */ export function requireOriginRoot(raw: string, label = "origin"): string { + // A non-string runtime input (e.g. a Symbol) would throw a native TypeError in + // RegExp.test/new URL below; surface the documented usage error instead. + if (typeof raw !== "string") { + throw new BasecampError("usage", `Invalid ${label}: not a valid absolute URL: ${String(raw)}`); + } // Reject C0 controls, space, and backslash up front: WHATWG URL silently strips // tabs/newlines/surrounding spaces and converts backslashes to forward slashes // for special schemes, so a malformed spelling ("https:\\host", "https://host\n") @@ -138,6 +143,13 @@ export function requireOriginRoot(raw: string, label = "origin"): string { if (/[\u0000-\u0020\\]/.test(raw)) { throw new BasecampError("usage", `${label} contains invalid characters: ${raw}`); } + // WHATWG recovers a missing or extra-slash authority ("https:host", + // "https:///host") into a clean origin. Require an explicit "://" followed by a + // non-empty authority (a non-slash character) so those spellings are rejected. + const authoritySep = raw.indexOf("://"); + if (authoritySep < 0 || raw[authoritySep + 3] === undefined || raw[authoritySep + 3] === "/") { + throw new BasecampError("usage", `${label} must be an origin root: ${raw}`); + } let url: URL; try { url = new URL(raw); diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index b6257cfbb..7e677a672 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -99,8 +99,9 @@ describe("resource-first discovery fixtures", () => { beforeEach(() => { launchpadContacted = false; - // Track any request to the Launchpad well-known endpoints. The orchestrator - // itself never contacts Launchpad; hard cases must never reach here. + // Track ANY request to a Launchpad well-known endpoint — both the AS metadata + // and the protected-resource metadata — so a hard case that wrongly hit either + // Launchpad endpoint is caught. The orchestrator itself never contacts Launchpad. server.use( mswHttp.get(`${ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}${WELL_KNOWN.as}`, () => { launchpadContacted = true; @@ -109,6 +110,10 @@ describe("resource-first discovery fixtures", () => { authorization_endpoint: `${ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}/authorization/new`, token_endpoint: `${ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}/authorization/token`, }); + }), + mswHttp.get(`${ORIGINS["{{LAUNCHPAD_ORIGIN}}"]}${WELL_KNOWN.resource}`, () => { + launchpadContacted = true; + return HttpResponse.json({ resource: ORIGINS["{{LAUNCHPAD_ORIGIN}}"] }); }) ); }); @@ -379,6 +384,15 @@ describe("requireOriginRoot userinfo rejection", () => { expect(() => requireOriginRoot(raw)).toThrow(/invalid characters/); } ); + + // WHATWG recovers a missing/extra-slash authority into a clean origin; require + // an explicit "://" + non-empty authority. + it.each(["https:host", "https:///host"])( + "rejects a missing-authority spelling %j", + (raw) => { + expect(() => requireOriginRoot(raw)).toThrow(/origin root/); + } + ); }); describe("resource metadata strictness (#369 review)", () => { From ccdae8b82a862d39c7d2ca08aa1f1dc59719d9f6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 21:36:59 -0700 Subject: [PATCH 26/38] oauth: reject dot-segment paths that URL parsers resolve away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHATWG URL (TS) and httpx (Python) resolve dot-segments in the path — "/a/.." and "/%2e%2e" collapse to "/", so the normalized-path check passes and a caller origin carrying a path is accepted as a clean origin. Add a parser-independent raw-path scan (reject any path beyond "/" in the raw input). Go/Ruby keep the un-normalized path and already reject; Kotlin's parser likewise. New shared fixture 37 + Kotlin mirror; TS/Python unit tests cover the "/a/.." and "/%2e%2e" variants. --- .../fixtures/37-origin-root-dot-segment-path.json | 12 ++++++++++++ .../kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt | 13 +++++++++++++ python/src/basecamp/_security.py | 7 +++++++ python/tests/oauth/test_resource_discovery.py | 8 ++++++++ typescript/src/oauth/discovery.ts | 8 ++++++++ typescript/tests/oauth/resource-discovery.test.ts | 8 ++++++++ 6 files changed, 56 insertions(+) create mode 100644 conformance/oauth/fixtures/37-origin-root-dot-segment-path.json diff --git a/conformance/oauth/fixtures/37-origin-root-dot-segment-path.json b/conformance/oauth/fixtures/37-origin-root-dot-segment-path.json new file mode 100644 index 000000000..245053b8d --- /dev/null +++ b/conformance/oauth/fixtures/37-origin-root-dot-segment-path.json @@ -0,0 +1,12 @@ +{ + "name": "origin-root-dot-segment-path", + "description": "A caller origin with a dot-segment path ('https://api.example.com/a/..') is malformed. WHATWG-style parsers resolve the path away to '/', so the normalized path check passes; each SDK scans the RAW path and rejects any path beyond '/'.", + "operation": "discoverProtectedResource", + "resourceOrigin": "https://api.example.com/a/..", + "expect": { + "outcome": "raise", + "error": "usage", + "launchpadContacted": false, + "errorCategory": "usage" + } +} diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index 7f9504dd2..fb7512fbc 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -719,6 +719,19 @@ class OAuthDiscoveryTest { ) ) + @Test fun `37 origin-root dot-segment path rejected`() = runScenario( + Scenario( + name = "origin-root-dot-segment-path", + op = Op.PROTECTED_RESOURCE, + // WHATWG-style parsers resolve "/a/.." to "/", so the normalized path + // check passes; the raw path must be scanned and rejected. + resourceOrigin = "https://api.example.com/a/..", + raiseUsage = true, + errorCategory = "usage", + launchpadMustBeSilent = true, + ) + ) + @Test fun `36 origin-root missing authority rejected`() = runScenario( Scenario( name = "origin-root-missing-authority", diff --git a/python/src/basecamp/_security.py b/python/src/basecamp/_security.py index 67482c4e8..7f94fbf79 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -112,6 +112,13 @@ def require_origin_root(raw: str, label: str = "origin") -> str: raise UsageError(f"{label} must not contain a query or fragment: {raw}") if url.path not in ("", "/"): raise UsageError(f"{label} must be an origin root (no path): {raw}") + # httpx resolves dot-segments ("/a/.." -> "/"), so url.path above misses a path + # present in the raw input. Scan the raw path (from the first "/" after the + # authority — "?"/"#" are already rejected above). + after_authority = raw.split("://", 1)[1] if "://" in raw else raw + slash = after_authority.find("/") + if slash >= 0 and after_authority[slash:] != "/": + raise UsageError(f"{label} must be an origin root (no path): {raw}") # A dangling port delimiter ("https://host:") normalizes to port None under # httpx, silently accepting a malformed authority. Also reject a signed port diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 439cd103e..1ecfa500d 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -210,6 +210,14 @@ def test_origin_root_rejects_normalized_spellings(raw: str) -> None: require_origin_root(raw) +@pytest.mark.parametrize("raw", ["https://api.example/a/..", "https://api.example/%2e%2e"]) +def test_origin_root_rejects_dot_segment_path(raw: str) -> None: + # httpx resolves "/a/.." to "/", so the normalized-path check misses it; the + # raw-path scan must reject any path beyond "/". + with pytest.raises(UsageError, match="no path"): + require_origin_root(raw) + + def test_origin_root_rejects_dangling_port() -> None: # A dangling ":" normalizes to port None under httpx (looks like no port); the # raw-authority check must still reject the malformed authority. diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 5e70b3f2c..758886d29 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -183,6 +183,14 @@ export function requireOriginRoot(raw: string, label = "origin"): string { if (url.pathname !== "" && url.pathname !== "/") { throw new BasecampError("usage", `${label} must be an origin root (no path): ${raw}`); } + // WHATWG resolves dot-segments ("/a/..", "/%2e%2e") to "/", so url.pathname above + // misses a path that was present in the raw input. Scan the raw path (everything + // from the first "/" after the authority — "?"/"#" are already rejected above). + const afterAuthority = raw.slice(raw.indexOf("://") + 3); + const rawPathStart = afterAuthority.indexOf("/"); + if (rawPathStart >= 0 && afterAuthority.slice(rawPathStart) !== "/") { + throw new BasecampError("usage", `${label} must be an origin root (no path): ${raw}`); + } // `new URL("https://h:notaport")` throws above and WHATWG rejects ports > 65535, // but it ACCEPTS port 0 and keeps it in the origin. The origin-root profile (like // the other SDKs) rejects any port outside 1–65535, so a caller/advertised issuer diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts index 7e677a672..1cec7179d 100644 --- a/typescript/tests/oauth/resource-discovery.test.ts +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -393,6 +393,14 @@ describe("requireOriginRoot userinfo rejection", () => { expect(() => requireOriginRoot(raw)).toThrow(/origin root/); } ); + + // WHATWG resolves dot-segments to "/", so the raw path must be scanned. + it.each(["https://api.example/a/..", "https://api.example/%2e%2e"])( + "rejects a dot-segment path %j", + (raw) => { + expect(() => requireOriginRoot(raw)).toThrow(/origin root \(no path\)/); + } + ); }); describe("resource metadata strictness (#369 review)", () => { From 59ed7e4778274d773b35877dae44b579eba50d9f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 22:02:33 -0700 Subject: [PATCH 27/38] oauth: validate every advertised *_endpoint in Kotlin discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fixtures/38-unmodeled-endpoint-empty.json | 21 ++++++++ .../com/basecamp/sdk/oauth/Discovery.kt | 52 +++++++++---------- .../com/basecamp/sdk/OAuthDiscoveryTest.kt | 31 +++++++++++ 3 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 conformance/oauth/fixtures/38-unmodeled-endpoint-empty.json diff --git a/conformance/oauth/fixtures/38-unmodeled-endpoint-empty.json b/conformance/oauth/fixtures/38-unmodeled-endpoint-empty.json new file mode 100644 index 000000000..d5877b771 --- /dev/null +++ b/conformance/oauth/fixtures/38-unmodeled-endpoint-empty.json @@ -0,0 +1,21 @@ +{ + "name": "unmodeled-endpoint-empty", + "description": "AS metadata carries an endpoint this SDK does not model (revocation_endpoint) present with an empty string. §16 requires ANY present *_endpoint to be a non-empty string, so it must be rejected — not silently dropped as an unknown key. Guards against whitelist-only validators (Kotlin historically checked only its four modeled fields).", + "operation": "discover", + "issuerOrigin": "{{ISSUER_ORIGIN}}", + "hop2": { + "origin": "{{ISSUER_ORIGIN}}", + "status": 200, + "body": { + "issuer": "{{ISSUER_ORIGIN}}", + "authorization_endpoint": "{{ISSUER_ORIGIN}}/oauth/authorize", + "token_endpoint": "{{ISSUER_ORIGIN}}/oauth/token", + "revocation_endpoint": "" + } + }, + "expect": { + "outcome": "raise", + "error": "invalid_metadata", + "errorCategory": "api_error" + } +} diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index bad989b29..fa5d8d006 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt @@ -18,6 +18,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive /** * OAuth 2.0 discovery for the Basecamp SDK. @@ -177,15 +178,17 @@ private suspend fun fetchAndBindAsMetadata( val url = "$issuerOrigin/.well-known/oauth-authorization-server" val body = fetchDiscoveryDocument(url, client) - // Inspect the raw JSON before decoding so an endpoint key present with an - // explicit JSON `null` is rejected as invalid metadata rather than collapsed to - // the same absent-key state a nullable Kotlin field cannot distinguish. + // Inspect the raw JSON before decoding so every `*_endpoint` key — including + // ones this SDK does not model — is validated. `ignoreUnknownKeys` silently + // drops an unmodeled key like `revocation_endpoint`, so a whitelist over the + // four modeled fields would let `revocation_endpoint: null`/`""` through in + // violation of §16 (any present `*_endpoint` must be a non-empty string). val obj = try { discoveryJson.parseToJsonElement(body) as? JsonObject } catch (e: SerializationException) { throw BasecampException.Api("Failed to parse OAuth discovery response", httpStatus = 200, cause = e) } ?: throw BasecampException.Api("Failed to parse OAuth discovery response", httpStatus = 200) - rejectExplicitNullEndpoints(obj) + rejectInvalidEndpoints(obj) val raw = try { discoveryJson.decodeFromString(body) } catch (e: SerializationException) { @@ -199,22 +202,23 @@ private suspend fun fetchAndBindAsMetadata( } /** - * Rejects any `*_endpoint` key present with an explicit JSON `null`. A nullable - * Kotlin field collapses "key omitted" (valid: endpoint absent) and "key present - * but `null`" (invalid metadata) to the same `null`, so the distinction is - * enforced at the JSON layer. Present-but-empty strings are rejected downstream in - * [bindAsMetadata]; non-string values fail the structural decode. + * Enforces §16 over EVERY `*_endpoint` key in the raw document (mirrors Go's + * `rejectEmptyEndpoints`, TS/Python/Ruby's generic `_endpoint` loops): any present + * endpoint must be a non-empty JSON string. This runs on the parsed [JsonObject] + * rather than the decoded `RawDiscoveryResponse` because `ignoreUnknownKeys` drops + * unmodeled keys during decode, and because a nullable Kotlin field collapses "key + * omitted" (valid: absent) with "key present but `null`" (invalid) to the same + * `null`. Rejecting at the JSON layer catches an explicit `null`, an empty string, + * and a non-string value (number/array/object) uniformly, for modeled and + * unmodeled endpoints alike (e.g. `revocation_endpoint`, `introspection_endpoint`). */ -private fun rejectExplicitNullEndpoints(obj: JsonObject) { - for (key in listOf( - "authorization_endpoint", - "token_endpoint", - "device_authorization_endpoint", - "registration_endpoint", - )) { - if (obj[key] is JsonNull) { +private fun rejectInvalidEndpoints(obj: JsonObject) { + for ((key, value) in obj) { + if (!key.endsWith("_endpoint")) continue + val prim = value as? JsonPrimitive + if (prim == null || !prim.isString || prim.content.isEmpty()) { throw BasecampException.Api( - "Invalid OAuth discovery response: $key must not be null", + "Invalid OAuth discovery response: $key must be a non-empty string", httpStatus = 200, ) } @@ -263,16 +267,8 @@ private fun bindAsMetadata(raw: RawDiscoveryResponse, expectedIssuerOrigin: Stri httpStatus = 200, ) } - // Reject present-but-empty endpoint strings. - for ((name, value) in listOf( - "authorization_endpoint" to raw.authorizationEndpoint, - "device_authorization_endpoint" to raw.deviceAuthorizationEndpoint, - "registration_endpoint" to raw.registrationEndpoint, - )) { - if (value != null && value.isEmpty()) { - throw BasecampException.Api("Invalid OAuth discovery response: empty $name", httpStatus = 200) - } - } + // Present-but-empty endpoint strings (modeled and unmodeled alike) are already + // rejected at the JSON layer by rejectInvalidEndpoints before decode. return OAuthConfig( issuer = issuer, diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt index fb7512fbc..4e6b31591 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -760,6 +760,37 @@ class OAuthDiscoveryTest { ) ) + @Test fun `38 unmodeled endpoint present-but-empty rejected`() = runScenario( + Scenario( + name = "unmodeled-endpoint-empty", + op = Op.DISCOVER, + issuerOrigin = ISSUER, + // revocation_endpoint is not modeled by OAuthConfig; ignoreUnknownKeys + // would drop it during decode, so validation must run over the raw JSON + // to enforce §16 (any present *_endpoint is a non-empty string). + hop2 = Hop( + body = "{\"issuer\":\"$ISSUER\",\"authorization_endpoint\":\"$ISSUER/oauth/authorize\"," + + "\"token_endpoint\":\"$ISSUER/oauth/token\",\"revocation_endpoint\":\"\"}" + ), + raiseApiError = true, + errorCategory = "api_error", + ) + ) + + @Test fun `unmodeled endpoint present-but-null rejected`() = runScenario( + Scenario( + name = "unmodeled-endpoint-null", + op = Op.DISCOVER, + issuerOrigin = ISSUER, + hop2 = Hop( + body = "{\"issuer\":\"$ISSUER\",\"token_endpoint\":\"$ISSUER/oauth/token\"," + + "\"revocation_endpoint\":null}" + ), + raiseApiError = true, + errorCategory = "api_error", + ) + ) + @Test fun `discover surfaces issuer mismatch as api_error to external callers`() = runTest { // The module-private binding marker must NOT leak: an external discover() // caller sees an ordinary api_error, identical to any other invalid AS From 16057144076c0add6fae1145c002f5a3d0441d95 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 22:19:38 -0700 Subject: [PATCH 28/38] oauth: propagate caller cancellation before Go soft fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- go/pkg/basecamp/oauth/discovery.go | 8 +++ .../basecamp/oauth/resource_discovery_test.go | 62 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index ab0788879..be5bed631 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -532,6 +532,14 @@ func (d *Discoverer) DiscoverFromResource(ctx context.Context, resourceOrigin st if errors.As(err, &be) && be.Code == basecamp.CodeUsage { return nil, err } + // A caller cancelling the context (or its deadline expiring) must see that + // cancellation, never a soft fallback that silently proceeds to Launchpad. + // fetchDiscoveryDocument derives its own per-fetch timeout as a CHILD + // context, so a non-nil parent ctx.Err() means the CALLER aborted — not the + // SDK's internal timeout, which leaves the parent's Err() nil and stays soft. + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } return &DiscoveryResult{FallbackReason: FallbackResourceDiscoveryFailed}, nil } diff --git a/go/pkg/basecamp/oauth/resource_discovery_test.go b/go/pkg/basecamp/oauth/resource_discovery_test.go index cbc033e19..22e33fb86 100644 --- a/go/pkg/basecamp/oauth/resource_discovery_test.go +++ b/go/pkg/basecamp/oauth/resource_discovery_test.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" ) @@ -556,6 +557,67 @@ func TestDiscoverFromResource_ASFetchFailurePreservesStatus(t *testing.T) { } } +// TestDiscoverFromResource_CallerCancellationPropagates asserts that a caller +// cancelling the context during hop 1 sees the cancellation, NOT a soft +// resource_discovery_failed fallback. A fallback would silently steer the caller +// on to a credentialed Launchpad request after they explicitly aborted. +func TestDiscoverFromResource_CallerCancellationPropagates(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + started := make(chan struct{}) + resource := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() // block until the caller-cancelled context aborts the request + })) + defer resource.Close() + + go func() { + <-started + cancel() // caller aborts mid-flight + }() + + launchpadHits := 0 + client := &http.Client{Transport: &countingTransport{base: resource.Client().Transport, count: &launchpadHits}} + + result, err := NewDiscoverer(client).DiscoverFromResource(ctx, resource.URL) + if err == nil { + t.Fatalf("expected cancellation error, got soft fallback result: %+v", result) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("errors.Is(err, context.Canceled) = false; err = %v", err) + } + if result != nil { + t.Errorf("expected nil result on cancellation, got %+v", result) + } + if launchpadHits != 0 { + t.Errorf("Launchpad was contacted %d time(s); a cancelled discovery must not touch Launchpad", launchpadHits) + } +} + +// TestDiscoverFromResource_OwnTimeoutStillSoftFallsBack is the companion to the +// cancellation test: the SDK's OWN per-fetch timeout (a child context) firing on +// an unresponsive resource host must remain a soft resource_discovery_failed +// fallback — the parent context is untouched, so the ctx.Err() guard must not +// convert a legitimate fallback into a hard error. +func TestDiscoverFromResource_OwnTimeoutStillSoftFallsBack(t *testing.T) { + block := make(chan struct{}) + resource := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + <-block // never respond within the SDK timeout + })) + defer resource.Close() + defer close(block) + + result, err := NewDiscoverer(resource.Client()). + DiscoverFromResource(context.Background(), resource.URL, WithTimeout(50*time.Millisecond)) + if err != nil { + t.Fatalf("SDK-timeout should soft-fall-back, got error: %v", err) + } + if result == nil || result.Config != nil || result.FallbackReason != FallbackResourceDiscoveryFailed { + t.Errorf("want resource_discovery_failed fallback, got %+v", result) + } +} + func TestDiscoverFromResource_MixedCaseLaunchpadExcluded(t *testing.T) { // A mixed-case Launchpad host must be recognized as Launchpad (hosts are // case-insensitive), so it is excluded and discovery soft-falls-back with From b7765336a1b5f617d7f46bba3d240e0c6d2022ee Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 22:34:32 -0700 Subject: [PATCH 29/38] oauth: truncate non-2xx discovery body in TypeScript error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- typescript/src/oauth/discovery.ts | 10 +++++++--- typescript/tests/oauth/oauth.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index 758886d29..d1dae28c6 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -12,7 +12,7 @@ * bounded cap that aborts before the whole oversized body is buffered. */ -import { BasecampError } from "../errors.js"; +import { BasecampError, truncateErrorMessage } from "../errors.js"; import { isLocalhost } from "../security.js"; import type { OAuthConfig, @@ -313,11 +313,15 @@ async function fetchDiscoveryDocument( }); if (!response.ok || (response.status >= 300 && response.status < 400)) { - // Drain-and-cap defensively; body is unused on the error path. + // Drain-and-cap defensively; body is unused on the error path. Truncate + // before embedding: the bounded read still permits up to maxBodyBytes (1 MiB + // default), and interpolating that whole body into the error string risks log + // spam / memory pressure and could echo sensitive server content. Cap it to + // 500 chars to match Go/Python/Ruby's truncated discovery error messages. const body = await readBodyBounded(response, maxBodyBytes).catch(() => ""); throw new BasecampError( "api_error", - `OAuth discovery failed with status ${response.status}: ${body}`, + `OAuth discovery failed with status ${response.status}: ${truncateErrorMessage(body)}`, { httpStatus: response.status } ); } diff --git a/typescript/tests/oauth/oauth.test.ts b/typescript/tests/oauth/oauth.test.ts index 9be8ed68f..e52b7f693 100644 --- a/typescript/tests/oauth/oauth.test.ts +++ b/typescript/tests/oauth/oauth.test.ts @@ -149,6 +149,29 @@ describe("OAuth Discovery", () => { } }); + it("truncates a large non-2xx body in the error message", async () => { + const hugeBody = "x".repeat(200_000); + server.use( + http.get( + "https://launchpad.37signals.com/.well-known/oauth-authorization-server", + () => HttpResponse.text(hugeBody, { status: 502 }) + ) + ); + + try { + await discover("https://launchpad.37signals.com"); + expect.fail("Should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(BasecampError); + // The body is capped at 500 chars (matching Go/Python/Ruby), so the message + // never grows with the response body — no log spam / memory pressure. + const msg = (err as BasecampError).message; + expect(msg.length).toBeLessThan(600); + expect(msg).toContain("..."); + expect(msg).not.toContain(hugeBody); + } + }); + it("throws BasecampError on invalid JSON response", async () => { server.use( http.get( From 21cacc85b80db6d7d85da383313be915af616dbb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 22:51:29 -0700 Subject: [PATCH 30/38] oauth: bound Ruby discovery reads with a wall-clock deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ruby/lib/basecamp/oauth/fetcher.rb | 31 ++++++++++++++-- ruby/test/basecamp/oauth_ssrf_test.rb | 53 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/ruby/lib/basecamp/oauth/fetcher.rb b/ruby/lib/basecamp/oauth/fetcher.rb index 94a1fdeae..e16395fbb 100644 --- a/ruby/lib/basecamp/oauth/fetcher.rb +++ b/ruby/lib/basecamp/oauth/fetcher.rb @@ -13,7 +13,9 @@ module Oauth # # 1. requires HTTPS (localhost exempt) — enforced by the origin-root profile # ({Basecamp::Security.require_origin_root!}) before this is called; - # 2. bounds the timeout (set on the injected connection); + # 2. bounds the timeout — both a per-read socket timeout AND a monotonic + # wall-clock deadline over the whole streaming read (a per-read timeout + # alone resets on every chunk, so a slow-drip peer could hang the fetch); # 3. suppresses redirects — the default Faraday connection carries no redirect # middleware, so an attacker-controlled 3xx +Location+ is surfaced as a # non-2xx +api_error+ rather than chased; @@ -31,6 +33,10 @@ module Fetcher # Never escapes this module — it is mapped to an OauthError. class BodyTooLarge < StandardError; end + # Raised internally when a streaming read exceeds its wall-clock deadline. + # Never escapes this module — it is mapped to a retryable +network+ OauthError. + class ReadDeadlineExceeded < StandardError; end + # Builds a +[chunks, on_data]+ pair for a genuine bounded/streaming read. # Assign +on_data+ to a request's +req.options.on_data+; after the request # returns, +chunks.join+ is the accumulated body. The proc raises @@ -39,12 +45,25 @@ class BodyTooLarge < StandardError; end # {BodyTooLarge} and map it to their own error. Shared by both discovery # hops and the device flow so every OAuth response reads under the same cap. # + # +req.options.timeout+ only bounds each individual socket read, and every + # +on_data+ chunk resets it — so a peer dripping one byte before each read + # timeout can hold the connection open arbitrarily long without ever tripping + # the cap. When a monotonic +deadline+ is supplied, the proc raises + # {ReadDeadlineExceeded} the moment the WHOLE read outlives it, matching the + # wall-clock bound the other SDKs enforce (Python's monotonic deadline, Go's + # context, TS's abort timer, Kotlin's requestTimeoutMillis). + # # @param max_body_bytes [Integer] bounded read cap in bytes + # @param deadline [Float, nil] monotonic clock deadline (CLOCK_MONOTONIC seconds) # @return [Array(Array, Proc)] the chunk buffer and the +on_data+ proc - def self.bounded_reader(max_body_bytes) + def self.bounded_reader(max_body_bytes, deadline: nil) chunks = [] total = 0 reader = proc do |chunk, _received| + if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + raise ReadDeadlineExceeded + end + total += chunk.bytesize raise BodyTooLarge if total > max_body_bytes @@ -119,7 +138,11 @@ def self.ensure_redirects_suppressed!(client) # @raise [OauthError] +api_error+ on non-2xx, oversized body, non-object # JSON, or parse failure; +network+ on transport failure def self.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES) - chunks, on_data = bounded_reader(max_body_bytes) + # Wall-clock deadline over the WHOLE read: req.options.timeout below bounds + # only each socket read and resets on every chunk, so a slow-drip peer could + # otherwise hang the fetch indefinitely while staying under max_body_bytes. + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + chunks, on_data = bounded_reader(max_body_bytes, deadline: deadline) response = http_client.get(url) do |req| req.headers["Accept"] = "application/json" @@ -148,6 +171,8 @@ def self.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY data rescue BodyTooLarge raise OauthError.new("api_error", "OAuth discovery response exceeds size cap") + rescue ReadDeadlineExceeded + raise OauthError.new("network", "OAuth discovery timed out", retryable: true) rescue Faraday::Error => e raise OauthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true) rescue JSON::ParserError => e diff --git a/ruby/test/basecamp/oauth_ssrf_test.rb b/ruby/test/basecamp/oauth_ssrf_test.rb index 98aae346f..c31f55cd2 100644 --- a/ruby/test/basecamp/oauth_ssrf_test.rb +++ b/ruby/test/basecamp/oauth_ssrf_test.rb @@ -70,6 +70,59 @@ def test_over_cap_body_aborts_before_buffering_whole_body assert_operator meter[:delivered], :<, oversized.bytesize end + # Streams tiny chunks with a real pause between each, staying well under the + # byte cap forever — a slow-drip peer. Records how many bytes were delivered so + # the test can prove the read aborted on the wall-clock deadline rather than + # running to completion. + class SlowDripAdapter < Faraday::Adapter + def initialize(app = nil, body:, chunk_size:, pause:, meter:) + super(app) + @body = body + @chunk_size = chunk_size + @pause = pause + @meter = meter + end + + def call(env) + on_data = env.request.on_data + sent = 0 + while sent < @body.bytesize + piece = @body.byteslice(sent, @chunk_size) + sent += piece.bytesize + @meter[:delivered] = sent + sleep @pause # simulate a peer trickling data below the read timeout + on_data.call(piece, sent) + end + save_response(env, 200, "", { "Content-Type" => "application/json" }) + @app.call(env) + end + end + + def test_slow_drip_stream_aborts_on_wall_clock_deadline + issuer = "https://issuer.ssrf-test.example" + # A modest, in-cap body dripped one byte at a time: the per-read timeout never + # trips (each chunk resets it), so only the whole-read deadline can stop it. + body = { "issuer" => issuer, "token_endpoint" => "#{issuer}/t", "pad" => "x" * 200 }.to_json + + meter = { delivered: 0 } + connection = Faraday.new do |conn| + conn.adapter SlowDripAdapter, body: body, chunk_size: 1, pause: 0.02, meter: meter + end + + # 1 MiB cap (never reached) + a 0.1s wall-clock deadline; ~5 chunks (0.1s) in. + discovery = Basecamp::Oauth::Discovery.new(http_client: connection, timeout: 0.1) + + error = assert_raises(Basecamp::Oauth::OauthError) do + discovery.discover(issuer) + end + # A slow-drip timeout is a retryable transport failure, not an api_error. + assert_equal "network", error.type + assert error.retryable, "wall-clock timeout must be retryable" + + # The read aborted mid-stream: fewer bytes were delivered than the full body. + assert_operator meter[:delivered], :<, body.bytesize + end + # A middleware whose name matches the redirect detector. Standing in for # faraday-follow_redirects without taking the dependency. class RedirectFollowingMiddleware < Faraday::Middleware From 22f8046bcb32561173125b861112e8ca2fd427cb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 23:07:33 -0700 Subject: [PATCH 31/38] oauth: propagate caller cancellation during Go hop-2 AS fetch 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. --- go/pkg/basecamp/oauth/discovery.go | 7 ++++ .../basecamp/oauth/resource_discovery_test.go | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index be5bed631..b74ab3138 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -599,6 +599,13 @@ func (d *Discoverer) DiscoverFromResource(ctx context.Context, resourceOrigin st if errors.Is(err, errIssuerBindingMismatch) { return nil, newSelectionError(ErrIssuerMismatch, err.Error(), err) } + // A caller cancelling (or its deadline expiring) during the committed AS + // fetch must surface as cancellation, not a misclassified as_fetch_failed + // api_error. As on the hop-1 path, the parent ctx.Err() is non-nil only for + // a caller abort — the SDK's own per-fetch timeout is a child context. + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } return nil, newSelectionError(ErrASFetchFailed, fmt.Sprintf("authorization server metadata fetch failed for committed issuer %q: %v", issuerOrigin, err), err) } diff --git a/go/pkg/basecamp/oauth/resource_discovery_test.go b/go/pkg/basecamp/oauth/resource_discovery_test.go index 22e33fb86..63771638d 100644 --- a/go/pkg/basecamp/oauth/resource_discovery_test.go +++ b/go/pkg/basecamp/oauth/resource_discovery_test.go @@ -618,6 +618,48 @@ func TestDiscoverFromResource_OwnTimeoutStillSoftFallsBack(t *testing.T) { } } +// TestDiscoverFromResource_ASStageCancellationPropagates is the hop-2 companion: +// a caller cancelling during the committed-issuer AS metadata fetch must see the +// cancellation, not a misclassified ErrASFetchFailed/api_error. +func TestDiscoverFromResource_ASStageCancellationPropagates(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + started := make(chan struct{}) + bc5 := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() // block the AS fetch until the caller cancels + })) + defer bc5.Close() + + var resourceOrigin string + resource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"resource":"` + resourceOrigin + `","authorization_servers":["` + bc5.URL + `"]}`)) + })) + defer resource.Close() + resourceOrigin = resource.URL + + go func() { + <-started + cancel() + }() + + result, err := NewDiscoverer(resource.Client()).DiscoverFromResource(ctx, resourceOrigin) + if err == nil { + t.Fatalf("expected cancellation error, got result: %+v", result) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("errors.Is(err, context.Canceled) = false; err = %v", err) + } + if errors.Is(err, ErrASFetchFailed) { + t.Errorf("caller cancellation must not be misclassified as ErrASFetchFailed; err = %v", err) + } + if result != nil { + t.Errorf("expected nil result on cancellation, got %+v", result) + } +} + func TestDiscoverFromResource_MixedCaseLaunchpadExcluded(t *testing.T) { // A mixed-case Launchpad host must be recognized as Launchpad (hosts are // case-insensitive), so it is excluded and discovery soft-falls-back with From d3dfa5c0959e9973c7828f99f81f06fe3361ce65 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 23:07:33 -0700 Subject: [PATCH 32/38] oauth: correct Ruby selection-error taxonomy comment and test adapter 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. --- .../oauth/discovery_selection_error.rb | 6 ++++-- ruby/test/basecamp/oauth_ssrf_test.rb | 20 +++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/ruby/lib/basecamp/oauth/discovery_selection_error.rb b/ruby/lib/basecamp/oauth/discovery_selection_error.rb index 4ed9c3bcb..20c3474e3 100644 --- a/ruby/lib/basecamp/oauth/discovery_selection_error.rb +++ b/ruby/lib/basecamp/oauth/discovery_selection_error.rb @@ -17,8 +17,10 @@ class DiscoverySelectionError < OauthError # @param message [String] human-readable description # @param http_status [Integer, nil] HTTP status code, if applicable def initialize(reason, message, http_status: nil) - # capability/expected-issuer are consumer/usage-shaped; the rest are AS - # metadata faults surfaced as api_error. + # Only capability_unavailable is consumer/usage-shaped (validation). Every + # other reason — including expected_issuer_unavailable — is an AS metadata + # fault surfaced as api_error, matching the other four SDKs (an issuer the + # resource does not advertise is a metadata fault, not a caller-usage one). type = reason == "capability_unavailable" ? "validation" : "api_error" super(type, message, http_status: http_status) @reason = reason diff --git a/ruby/test/basecamp/oauth_ssrf_test.rb b/ruby/test/basecamp/oauth_ssrf_test.rb index c31f55cd2..e126fcdbd 100644 --- a/ruby/test/basecamp/oauth_ssrf_test.rb +++ b/ruby/test/basecamp/oauth_ssrf_test.rb @@ -85,15 +85,19 @@ def initialize(app = nil, body:, chunk_size:, pause:, meter:) def call(env) on_data = env.request.on_data - sent = 0 - while sent < @body.bytesize - piece = @body.byteslice(sent, @chunk_size) - sent += piece.bytesize - @meter[:delivered] = sent - sleep @pause # simulate a peer trickling data below the read timeout - on_data.call(piece, sent) + if on_data + sent = 0 + while sent < @body.bytesize + piece = @body.byteslice(sent, @chunk_size) + sent += piece.bytesize + @meter[:delivered] = sent + sleep @pause # simulate a peer trickling data below the read timeout + on_data.call(piece, sent) + end + save_response(env, 200, "", { "Content-Type" => "application/json" }) + else + save_response(env, 200, @body, { "Content-Type" => "application/json" }) end - save_response(env, 200, "", { "Content-Type" => "application/json" }) @app.call(env) end end From c83f57ff75976e5995cac6409c7601dc4ee01136 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 23:24:42 -0700 Subject: [PATCH 33/38] oauth: reject non-finite discovery timeouts and fix taxonomy docstring in Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- python/src/basecamp/oauth/discovery.py | 23 +++++++++++++++++++ python/src/basecamp/oauth/errors.py | 7 +++--- python/tests/oauth/test_resource_discovery.py | 13 +++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index 3da3e7456..a457cce62 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import math import time from typing import Any @@ -70,6 +71,25 @@ def _normalize_body_cap(max_body_bytes: object) -> int: return max_body_bytes +def _normalize_timeout(timeout: object) -> float: + """Coerce the public timeout to a finite, positive float. + + ``timeout`` is *typed* ``float``, but a caller can pass ``None``, a non-number, + a non-positive value, or ``float("inf")``/``nan`` at runtime. An infinite or + non-positive timeout would disable BOTH httpx's bound and the wall-clock + deadline below (``time.monotonic() > inf`` never trips), letting a slow-drip + endpoint hold the SSRF-hardened fetch open indefinitely. Fall back to the + default so the bound can never be turned off — the same discipline as + :func:`_normalize_body_cap`. ``bool`` is excluded (a subclass of ``int``, but + a nonsensical timeout). + """ + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): + return _DISCOVERY_TIMEOUT + if not math.isfinite(timeout) or timeout <= 0: + return _DISCOVERY_TIMEOUT + return float(timeout) + + def _fetch_discovery_document(url: str, timeout: float, max_body_bytes: int) -> Any: """SSRF-hardened GET of a discovery document. @@ -79,6 +99,9 @@ def _fetch_discovery_document(url: str, timeout: float, max_body_bytes: int) -> non-2xx status to ``api_error`` (not ``network``). """ max_body_bytes = _normalize_body_cap(max_body_bytes) + # Normalize BEFORE httpx and before the deadline: a non-finite/non-positive + # timeout must not disable either bound (see _normalize_timeout). + timeout = _normalize_timeout(timeout) try: with httpx.stream( "GET", diff --git a/python/src/basecamp/oauth/errors.py b/python/src/basecamp/oauth/errors.py index ea3ad68e9..f8e91ffc9 100644 --- a/python/src/basecamp/oauth/errors.py +++ b/python/src/basecamp/oauth/errors.py @@ -43,9 +43,10 @@ class DiscoverySelectionError(OAuthError): Launchpad request. The ``reason`` attribute distinguishes the specific hard case (see :data:`DiscoverySelectionReason`). - ``capability_unavailable`` and ``expected_issuer_unavailable`` are - consumer/usage-shaped; the remaining reasons are AS-metadata faults surfaced - as ``api_error``. + Only ``capability_unavailable`` is consumer/usage-shaped (``validation``). + Every other reason — including ``expected_issuer_unavailable`` — is an + AS-metadata fault surfaced as ``api_error``, matching the other four SDKs (an + issuer the resource does not advertise is a metadata fault, not caller usage). """ def __init__(self, reason: DiscoverySelectionReason, message: str, **kwargs: Any): diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index 1ecfa500d..d1b701944 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -202,6 +202,19 @@ def test_body_cap_normalizes_non_finite_to_default() -> None: assert _normalize_body_cap(4096) == 4096 +def test_timeout_normalizes_non_finite_to_default() -> None: + from basecamp.oauth.discovery import _DISCOVERY_TIMEOUT, _normalize_timeout + + # None/inf/nan/non-positive/non-numeric would disable BOTH httpx's bound and + # the wall-clock deadline (monotonic > inf never trips), letting a slow-drip + # endpoint hang the fetch; each must fall back to the finite default. + for bad in (None, float("inf"), float("nan"), 0, -1, True, "10"): + assert _normalize_timeout(bad) == _DISCOVERY_TIMEOUT + # A valid positive value is preserved (as a float). + assert _normalize_timeout(2.5) == 2.5 + assert _normalize_timeout(30) == 30.0 + + @pytest.mark.parametrize("raw", ["https:\\\\host", "https://host\n", "https://host ", "https://ho st"]) def test_origin_root_rejects_normalized_spellings(raw: str) -> None: # Parsers strip C0 controls / whitespace or percent-encode a space into the From 056165264e7680dc3c2e9a4affa6438a3bcab0b1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 23:24:43 -0700 Subject: [PATCH 34/38] oauth: normalize non-finite discovery timeouts in Ruby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ruby/lib/basecamp/oauth/discovery.rb | 7 +++++-- ruby/lib/basecamp/oauth/fetcher.rb | 18 ++++++++++++++++++ ruby/lib/basecamp/oauth/resource.rb | 7 +++++-- ruby/test/basecamp/oauth_ssrf_test.rb | 16 ++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/ruby/lib/basecamp/oauth/discovery.rb b/ruby/lib/basecamp/oauth/discovery.rb index 4c437bc7f..86c2bd875 100644 --- a/ruby/lib/basecamp/oauth/discovery.rb +++ b/ruby/lib/basecamp/oauth/discovery.rb @@ -23,8 +23,11 @@ def initialize(message, http_status: nil) # @param max_body_bytes [Integer] bounded read cap in bytes def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) Fetcher.ensure_redirects_suppressed!(http_client) if http_client - @http_client = http_client || Fetcher.build_client(timeout) - @timeout = timeout + # Normalize before building the client and before the fetch computes its + # wall-clock deadline: a non-finite/non-positive timeout must not disable + # either bound (see Fetcher.normalize_timeout). + @timeout = Fetcher.normalize_timeout(timeout) + @http_client = http_client || Fetcher.build_client(@timeout) # Normalize the public cap to a finite non-negative Integer: a nil, float, # or Float::INFINITY would otherwise disable the streaming memory bound # (an infinite/undefined cap never trips +total > max_body_bytes+), diff --git a/ruby/lib/basecamp/oauth/fetcher.rb b/ruby/lib/basecamp/oauth/fetcher.rb index e16395fbb..a34d419e0 100644 --- a/ruby/lib/basecamp/oauth/fetcher.rb +++ b/ruby/lib/basecamp/oauth/fetcher.rb @@ -29,6 +29,24 @@ module Fetcher # Discovery documents are tiny; cap the read at 1 MiB by default. DEFAULT_MAX_BODY_BYTES = 1 * 1024 * 1024 + # Default request timeout in seconds when a caller supplies none or an + # invalid one. + DEFAULT_TIMEOUT = 10 + + # Coerce the public timeout to a finite, positive numeric. A nil, non-numeric, + # non-positive, or +Float::INFINITY+/+NaN+ value would otherwise disable BOTH + # the socket timeout and the wall-clock deadline in {fetch_json} (+now + inf+ + # never trips), letting a slow-drip peer hold the fetch open indefinitely. + # Mirrors the +max_body_bytes+ normalization in the discovery initializers. + # + # @param timeout [Object] caller-supplied timeout + # @return [Numeric] a finite, positive timeout in seconds + def self.normalize_timeout(timeout) + return timeout if timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? + + DEFAULT_TIMEOUT + end + # Raised internally to abort a streaming read once the cap is exceeded. # Never escapes this module — it is mapped to an OauthError. class BodyTooLarge < StandardError; end diff --git a/ruby/lib/basecamp/oauth/resource.rb b/ruby/lib/basecamp/oauth/resource.rb index 22fbb64fa..b6695ad0d 100644 --- a/ruby/lib/basecamp/oauth/resource.rb +++ b/ruby/lib/basecamp/oauth/resource.rb @@ -10,8 +10,11 @@ class Resource # @param max_body_bytes [Integer] bounded read cap in bytes def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) Fetcher.ensure_redirects_suppressed!(http_client) if http_client - @http_client = http_client || Fetcher.build_client(timeout) - @timeout = timeout + # Normalize before building the client and before the fetch computes its + # wall-clock deadline: a non-finite/non-positive timeout must not disable + # either bound (see Fetcher.normalize_timeout). + @timeout = Fetcher.normalize_timeout(timeout) + @http_client = http_client || Fetcher.build_client(@timeout) # Normalize the public cap to a finite non-negative Integer: a nil, float, # or Float::INFINITY would otherwise disable the streaming memory bound # (an infinite/undefined cap never trips), reintroducing an SSRF/OOM risk. diff --git a/ruby/test/basecamp/oauth_ssrf_test.rb b/ruby/test/basecamp/oauth_ssrf_test.rb index e126fcdbd..5f0a72cd6 100644 --- a/ruby/test/basecamp/oauth_ssrf_test.rb +++ b/ruby/test/basecamp/oauth_ssrf_test.rb @@ -203,6 +203,22 @@ def test_discovery_normalizes_non_integer_body_cap_to_default end end + def test_discovery_normalizes_non_finite_timeout_to_default + # A nil/non-numeric/non-positive/Float::INFINITY timeout would disable both the + # socket timeout and the wall-clock deadline (now + inf never trips), letting a + # slow-drip peer hang the fetch. Both Discovery and Resource must normalize it. + default = Basecamp::Oauth::Fetcher::DEFAULT_TIMEOUT + [ Float::INFINITY, Float::NAN, nil, 0, -1, "10" ].each do |bad| + [ Basecamp::Oauth::Discovery, Basecamp::Oauth::Resource ].each do |klass| + instance = klass.new(timeout: bad) + assert_equal default, instance.instance_variable_get(:@timeout), + "expected #{klass}#new(timeout: #{bad.inspect}) to normalize to the default" + end + end + # A valid positive timeout is preserved. + assert_equal 2.5, Basecamp::Oauth::Discovery.new(timeout: 2.5).instance_variable_get(:@timeout) + end + def test_redirect_is_not_followed issuer = "https://issuer.redirect-test.example" attacker = "https://attacker.example.com" From fc20ce578ab94716be2b8aeb73eb8ecdbe2657d3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 23:39:33 -0700 Subject: [PATCH 35/38] oauth: harden Python timeout normalization against oversized ints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- python/src/basecamp/oauth/discovery.py | 10 ++++++++-- python/tests/oauth/test_resource_discovery.py | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/python/src/basecamp/oauth/discovery.py b/python/src/basecamp/oauth/discovery.py index a457cce62..30d20380f 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -85,9 +85,15 @@ def _normalize_timeout(timeout: object) -> float: """ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): return _DISCOVERY_TIMEOUT - if not math.isfinite(timeout) or timeout <= 0: + try: + value = float(timeout) + except OverflowError: + # An int too large to convert to float (e.g. 10**400) — treat as invalid + # rather than letting math.isfinite/float raise out of the normalizer. + return _DISCOVERY_TIMEOUT + if not math.isfinite(value) or value <= 0: return _DISCOVERY_TIMEOUT - return float(timeout) + return value def _fetch_discovery_document(url: str, timeout: float, max_body_bytes: int) -> Any: diff --git a/python/tests/oauth/test_resource_discovery.py b/python/tests/oauth/test_resource_discovery.py index d1b701944..bff458751 100644 --- a/python/tests/oauth/test_resource_discovery.py +++ b/python/tests/oauth/test_resource_discovery.py @@ -208,7 +208,9 @@ def test_timeout_normalizes_non_finite_to_default() -> None: # None/inf/nan/non-positive/non-numeric would disable BOTH httpx's bound and # the wall-clock deadline (monotonic > inf never trips), letting a slow-drip # endpoint hang the fetch; each must fall back to the finite default. - for bad in (None, float("inf"), float("nan"), 0, -1, True, "10"): + # 10**400 is an int too large to convert to float — must fall back, not raise + # OverflowError out of the normalizer. + for bad in (None, float("inf"), float("nan"), 0, -1, True, "10", 10**400): assert _normalize_timeout(bad) == _DISCOVERY_TIMEOUT # A valid positive value is preserved (as a float). assert _normalize_timeout(2.5) == 2.5 From 8f63c4905bca118d849a1ab5a46e5697f3a9717e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 23:39:34 -0700 Subject: [PATCH 36/38] oauth: guard Ruby timeout normalization against non-real numerics 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. --- ruby/lib/basecamp/oauth/fetcher.rb | 5 ++++- ruby/test/basecamp/oauth_ssrf_test.rb | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ruby/lib/basecamp/oauth/fetcher.rb b/ruby/lib/basecamp/oauth/fetcher.rb index a34d419e0..b240d01b2 100644 --- a/ruby/lib/basecamp/oauth/fetcher.rb +++ b/ruby/lib/basecamp/oauth/fetcher.rb @@ -42,7 +42,10 @@ module Fetcher # @param timeout [Object] caller-supplied timeout # @return [Numeric] a finite, positive timeout in seconds def self.normalize_timeout(timeout) - return timeout if timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? + # +real?+ gates out Complex before +finite?+/+positive?+ (which Complex does + # not define — calling them would raise NoMethodError). Integer, Float, and + # Rational are all real and answer both. + return timeout if timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive? DEFAULT_TIMEOUT end diff --git a/ruby/test/basecamp/oauth_ssrf_test.rb b/ruby/test/basecamp/oauth_ssrf_test.rb index 5f0a72cd6..f72e4e8f0 100644 --- a/ruby/test/basecamp/oauth_ssrf_test.rb +++ b/ruby/test/basecamp/oauth_ssrf_test.rb @@ -208,7 +208,9 @@ def test_discovery_normalizes_non_finite_timeout_to_default # socket timeout and the wall-clock deadline (now + inf never trips), letting a # slow-drip peer hang the fetch. Both Discovery and Resource must normalize it. default = Basecamp::Oauth::Fetcher::DEFAULT_TIMEOUT - [ Float::INFINITY, Float::NAN, nil, 0, -1, "10" ].each do |bad| + # Complex is a Numeric but defines neither positive? nor an ordering — it must + # normalize to the default, not raise NoMethodError during construction. + [ Float::INFINITY, Float::NAN, nil, 0, -1, "10", Complex(1, 2) ].each do |bad| [ Basecamp::Oauth::Discovery, Basecamp::Oauth::Resource ].each do |klass| instance = klass.new(timeout: bad) assert_equal default, instance.instance_variable_get(:@timeout), From 9f4f5d025af448d9f48664765975d503817fe287 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 23:59:45 -0700 Subject: [PATCH 37/38] oauth: make Go RegistrationEndpoint optional and normalize ports numerically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- go/pkg/basecamp/oauth/discovery.go | 21 ++++++++++++------- .../basecamp/oauth/resource_discovery_test.go | 6 ++++++ go/pkg/basecamp/oauth/types.go | 4 ++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/go/pkg/basecamp/oauth/discovery.go b/go/pkg/basecamp/oauth/discovery.go index b74ab3138..41be9519c 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -152,12 +152,19 @@ func requireOriginRoot(raw, label string) (string, error) { } // url.Parse rejects a non-numeric port but accepts a numeric-but-out-of-range - // one (e.g. ":99999"), so range-check it explicitly against 1–65535. + // one (e.g. ":99999"), so range-check it explicitly against 1–65535. Keep the + // parsed int and render it canonically below: net/url's Port() is the RAW token, + // so a leading-zero spelling of the default (":000443") would otherwise slip past + // the string default-port drop and leave a non-canonical ":000443" origin — + // breaking isLaunchpadIssuer's origin-equality (a Launchpad advertisement with + // that spelling would be misread as a distinct BC5 issuer). + portNum := 0 if port := u.Port(); port != "" { n, err := strconv.Atoi(port) if err != nil || n < 1 || n > 65535 { return "", usage(fmt.Sprintf("%s has an invalid port: %s", label, raw)) } + portNum = n } // net/url reports an empty Port() for a dangling ":" ("https://host:"), so @@ -187,14 +194,14 @@ func requireOriginRoot(raw, label string) (string, error) { } else { origin += host } - if port := u.Port(); port != "" && !isDefaultPort(scheme, port) { - origin += ":" + port + if portNum != 0 && !isDefaultPort(scheme, portNum) { + origin += ":" + strconv.Itoa(portNum) } return origin, nil } -func isDefaultPort(scheme, port string) bool { - return (scheme == "https" && port == "443") || (scheme == "http" && port == "80") +func isDefaultPort(scheme string, port int) bool { + return (scheme == "https" && port == 443) || (scheme == "http" && port == 80) } // isLaunchpadIssuer reports whether an advertised issuer denotes Launchpad. @@ -404,9 +411,7 @@ func parseAndBindASMetadata(body []byte, expectedIssuerOrigin string) (*Config, GrantTypesSupported: raw.GrantTypesSupported, ScopesSupported: raw.ScopesSupported, CodeChallengeMethodsSupported: raw.CodeChallengeMethodsSupported, - } - if raw.RegistrationEndpoint != nil { - cfg.RegistrationEndpoint = *raw.RegistrationEndpoint + RegistrationEndpoint: raw.RegistrationEndpoint, } return cfg, nil } diff --git a/go/pkg/basecamp/oauth/resource_discovery_test.go b/go/pkg/basecamp/oauth/resource_discovery_test.go index 63771638d..ab883173c 100644 --- a/go/pkg/basecamp/oauth/resource_discovery_test.go +++ b/go/pkg/basecamp/oauth/resource_discovery_test.go @@ -337,6 +337,12 @@ func TestRequireOriginRoot(t *testing.T) { {"https://api.example.com:8443", "https://api.example.com:8443"}, {"https://api.example.com:443", "https://api.example.com"}, // default port dropped {"https://h:443", "https://h"}, // in-range port accepted + // net/url's Port() is the RAW token; the normalized origin must use the + // canonical integer so a leading-zero default drops and a leading-zero + // non-default renders without zeros (else origin-equality / Launchpad + // exclusion breaks). The other SDKs' parsers return an int port for free. + {"https://api.example.com:000443", "https://api.example.com"}, // leading-zero default dropped + {"https://api.example.com:0080", "https://api.example.com:80"}, // leading-zero non-default canonicalized {"http://localhost:3000", "http://localhost:3000"}, {"http://[::1]:3000", "http://[::1]:3000"}, {"http://127.0.0.1:9999", "http://127.0.0.1:9999"}, diff --git a/go/pkg/basecamp/oauth/types.go b/go/pkg/basecamp/oauth/types.go index 7747f495e..639a873cc 100644 --- a/go/pkg/basecamp/oauth/types.go +++ b/go/pkg/basecamp/oauth/types.go @@ -30,8 +30,8 @@ type Config struct { DeviceAuthorizationEndpoint *string `json:"device_authorization_endpoint,omitempty"` // RegistrationEndpoint is the URL of the dynamic client registration - // endpoint (optional). - RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + // endpoint (optional; nil when absent, matching the other optional endpoints). + RegistrationEndpoint *string `json:"registration_endpoint,omitempty"` // GrantTypesSupported lists the OAuth 2.0 grant types the server supports. GrantTypesSupported []string `json:"grant_types_supported,omitempty"` From be0f9922067ca323995e75e88e27206052f8c9d4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 16 Jul 2026 23:59:46 -0700 Subject: [PATCH 38/38] oauth: correct TypeScript selection-error taxonomy comment 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). --- typescript/src/oauth/discovery.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/typescript/src/oauth/discovery.ts b/typescript/src/oauth/discovery.ts index d1dae28c6..f018ea0f1 100644 --- a/typescript/src/oauth/discovery.ts +++ b/typescript/src/oauth/discovery.ts @@ -101,8 +101,10 @@ export class DiscoverySelectionError extends BasecampError { message: string, options?: { cause?: Error; httpStatus?: number } ) { - // capability/expected-issuer are consumer/usage-shaped; the rest are AS - // metadata faults surfaced as api_error. + // Only capability_unavailable is consumer/usage-shaped (validation). Every + // other reason — including expected_issuer_unavailable — is an AS-metadata + // fault surfaced as api_error, matching the other four SDKs (an issuer the + // resource does not advertise is a metadata fault, not caller usage). const code = reason === "capability_unavailable" ? "validation"