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/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/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/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/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/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/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/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/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/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/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/conformance/oauth/schema.json b/conformance/oauth/schema.json new file mode 100644 index 000000000..2f10b6aea --- /dev/null +++ b/conformance/oauth/schema.json @@ -0,0 +1,112 @@ +{ + "$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)." + } + }, + "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"] } + } + ] + } + } +} 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..41be9519c 100644 --- a/go/pkg/basecamp/oauth/discovery.go +++ b/go/pkg/basecamp/oauth/discovery.go @@ -1,15 +1,45 @@ package oauth import ( + "bytes" "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 +53,581 @@ 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 +} + +// 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} + } + + // 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)) + } + + // 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)) + } + // 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 != "/" { + 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. 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 + // 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 + // 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). + origin += "[" + host + "]" + } else { + origin += host + } + if portNum != 0 && !isDefaultPort(scheme, portNum) { + origin += ":" + strconv.Itoa(portNum) + } + return origin, nil +} + +func isDefaultPort(scheme string, port int) 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, "GET", discoveryURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { - return nil, fmt.Errorf("creating discovery request: %w", err) + 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))) } - var config Config - if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { - return nil, fmt.Errorf("parsing discovery response: %w", err) + 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.CodeNetwork, + Message: fmt.Sprintf("reading OAuth discovery response: %v", err), + Retryable: true, + 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 + } + // 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 +// 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"` +} + +// 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, bindIssuer) +} + +// 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 + } + if err := rejectNullListFields(body, "grant_types_supported", "scopes_supported", "code_challenge_methods_supported"); 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, + RegistrationEndpoint: raw.RegistrationEndpoint, + } + return cfg, nil +} + +// 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 + // 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: %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: %s must be an array when present, not null", k), + } + } + } + return nil +} + +// 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, resourceOrigin, newDiscoverConfig(opts)) +} + +// 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 + } + + 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)"} + } + if err := rejectNullListFields(body, "authorization_servers"); err != nil { + return nil, err + } + // 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, bindResource), + } + } + + 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). --- + // 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 { + 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 + } + + 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 { + // 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) { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + 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) + } + + // 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) + } + // 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) + } + + 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..eb08f6ff2 100644 --- a/go/pkg/basecamp/oauth/oauth_test.go +++ b/go/pkg/basecamp/oauth/oauth_test.go @@ -3,40 +3,39 @@ 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) { 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 +43,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 +66,80 @@ 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_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 + // 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) { + // 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) } - _ = json.NewEncoder(w).Encode(Config{ - TokenEndpoint: "https://example.com/token", - }) + _, _ = fmt.Fprintf(w, `{"issuer":%q,"token_endpoint":%q}`, caller, caller+"token") })) defer server.Close() + caller = server.URL + "/" d := NewDiscoverer(server.Client()) - // Test with trailing slash - _, err := d.Discover(context.Background(), server.URL+"/") + cfg, err := d.Discover(context.Background(), caller) if err != nil { - t.Errorf("Discover() with trailing slash failed: %v", err) + t.Fatalf("Discover() with trailing slash failed: %v", err) + } + if cfg.Issuer != caller { + t.Errorf("Discover() issuer = %q, want %q", cfg.Issuer, caller) } } 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..ab883173c --- /dev/null +++ b/go/pkg/basecamp/oauth/resource_discovery_test.go @@ -0,0 +1,732 @@ +package oauth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "testing" + "time" + + "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 + // 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"}, + } + 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) + } +} + +// 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) + } +} + +// 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 + // 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) + } + } +} diff --git a/go/pkg/basecamp/oauth/types.go b/go/pkg/basecamp/oauth/types.go index 628cbff7d..639a873cc 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; 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"` + + // 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. 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..bc7b3d99b --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Urls.kt @@ -0,0 +1,155 @@ +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 { + // 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)}") + + 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)}") + } + // 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) { + // 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)}") + } + } + // 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 + // 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 + 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. + // 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/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Discovery.kt index 3c2e0f294..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 @@ -1,69 +1,548 @@ 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 +import kotlinx.serialization.json.JsonPrimitive + +/** + * 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. */ +// 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, + @SerialName("authorization_endpoint") val authorizationEndpoint: String? = null, @SerialName("token_endpoint") val tokenEndpoint: String, @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, ) -/** 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 { + // 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 + // 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?, + bindIssuer: String? = null, +): 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 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) + rejectInvalidEndpoints(obj) + val raw = try { + discoveryJson.decodeFromString(body) + } catch (e: SerializationException) { + 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 + // 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) +} - if (!response.status.isSuccess()) { +/** + * 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 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( - "OAuth discovery failed: HTTP ${response.status.value}", - httpStatus = response.status.value, + "Invalid OAuth discovery response: $key must be a non-empty string", + httpStatus = 200, ) } + } +} - val body = response.bodyAsText() - return discoveryJson.decodeFromString(body) - } finally { - if (shouldClose) httpClient.close() +/** + * 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, + ) + } + // Present-but-empty endpoint strings (modeled and unmodeled alike) are already + // rejected at the JSON layer by rejectInvalidEndpoints before decode. + + 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) + // 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) { + 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 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 \"$resourceOrigin\"", + 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 { + // --- 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(resourceOrigin, 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 { + // 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", + "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. + // 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) + } 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..4e6b31591 --- /dev/null +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthDiscoveryTest.kt @@ -0,0 +1,864 @@ +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 `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 `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 `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 `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 `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 `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 `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 `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", + 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", + 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 `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 + // 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")) + } +} 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..7f94fbf79 100644 --- a/python/src/basecamp/_security.py +++ b/python/src/basecamp/_security.py @@ -40,6 +40,113 @@ 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 ``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* + origin catch and reclassify (e.g. ``invalid_issuer_origin``). + + 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 + # 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: + url = httpx.URL(raw) + scheme = url.scheme + # 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 + + 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: 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}") + # 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}") + # 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 + # 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 + # 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}") + + host_part = f"[{host}]" if ":" in host else host + if port is None: + 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..36373b1ba 100644 --- a/python/src/basecamp/oauth/config.py +++ b/python/src/basecamp/oauth/config.py @@ -1,14 +1,105 @@ 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 + # 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 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) +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 __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": + # 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}") + + 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..30d20380f 100644 --- a/python/src/basecamp/oauth/discovery.py +++ b/python/src/basecamp/oauth/discovery.py @@ -1,77 +1,413 @@ +"""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 +import math +import time +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) + + +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. + """ + + 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 discover(base_url: str) -> OAuthConfig: - """Fetch OAuth 2 server configuration from a well-known discovery endpoint. +def _normalize_timeout(timeout: object) -> float: + """Coerce the public timeout to a finite, positive float. - 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. + ``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 not is_localhost(base_url): - require_https(base_url, "discovery base URL") + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): + return _DISCOVERY_TIMEOUT + 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 value - normalized = base_url.rstrip("/") - url = f"{normalized}/.well-known/oauth-authorization-server" +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``). + """ + 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: - 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: + # 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 + # 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") + # 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( + 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") - _validate(data) + return _parse_and_bind_as_metadata(data, bind_issuer) + + +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", + ): + # 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", + ) 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") + 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 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 "{resource_origin}"', + ) -def _validate(data: dict) -> None: - missing = [f for f in ("issuer", "authorization_endpoint", "token_endpoint") if not data.get(f)] - if missing: + # 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 JSON null is malformed too (NOT normalized to []). + 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: + # 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", - f"Invalid OAuth discovery response: missing required fields: {', '.join(missing)}", + "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. + """ + # --- 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(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: + # 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", + "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: + # 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. + 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..f8e91ffc9 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,26 @@ 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`). + + 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): + 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..bff458751 --- /dev/null +++ b/python/tests/oauth/test_resource_discovery.py @@ -0,0 +1,542 @@ +"""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 ( + DiscoveryResult, + DiscoverySelectionError, + OAuthConfig, + 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"): + # 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"]) + + 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" + + +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) + + +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_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. + # 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 + 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 + # host; the up-front raw scan must reject these before they are cleaned. + with pytest.raises(UsageError, match="invalid characters"): + 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. + with pytest.raises(UsageError, match="invalid port"): + require_origin_root("https://bc5.example:") + + +@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 + + +@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}}"] + 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_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_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" + 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_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. + # 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_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", + 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. + 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() + + +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/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/generated/services/authorization_service.rb b/ruby/lib/basecamp/generated/services/authorization_service.rb deleted file mode 100644 index 4b2cc1ee1..000000000 --- a/ruby/lib/basecamp/generated/services/authorization_service.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module Basecamp - module Services - # Service for authorization operations. - # This is the only service that doesn't require an account context. - # - # @example Get authorization info - # auth = client.authorization.get - # puts "Identity: #{auth["identity"]["email_address"]}" - # auth["accounts"].each do |account| - # 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. - # - # Returns the authenticated user's identity and list of accounts - # they have access to. - # - # @return [Hash] authorization info with :identity and :accounts - # @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 - 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/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..90a6cf4de 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,56 @@ 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). + # 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(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 +146,116 @@ 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) + # 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", + "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) + 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}" + ) + rescue OauthError => e + raise as_failure_error(selected_issuer, 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..f26209474 100644 --- a/ruby/lib/basecamp/oauth/config.rb +++ b/ruby/lib/basecamp/oauth/config.rb @@ -2,26 +2,41 @@ 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 + # 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, :registration_endpoint, - :scopes_supported + :scopes_supported, + :grant_types_supported, + :device_authorization_endpoint ) do def initialize( issuer:, - authorization_endpoint:, token_endpoint:, + authorization_endpoint: nil, registration_endpoint: nil, - scopes_supported: nil + scopes_supported: nil, + grant_types_supported: nil, + device_authorization_endpoint: nil ) super end diff --git a/ruby/lib/basecamp/oauth/discovery.rb b/ruby/lib/basecamp/oauth/discovery.rb index 640397426..86c2bd875 100644 --- a/ruby/lib/basecamp/oauth/discovery.rb +++ b/ruby/lib/basecamp/oauth/discovery.rb @@ -1,91 +1,161 @@ # 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 + # 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+), + # 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 + # {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") + # 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 - normalized_base = base_url.chomp("/") - discovery_url = "#{normalized_base}/.well-known/oauth-authorization-server" + # 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. + # + # @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 - response = @http_client.get(discovery_url) do |req| - req.headers["Accept"] = "application/json" - end + private - unless response.success? - raise OauthError.new( - "network", - "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(response.body)}", - http_status: response.status - ) + # 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 - Basecamp::Security.check_body_size!(response.body, Basecamp::Security::MAX_ERROR_BODY_BYTES, "Discovery") + # 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 - data = JSON.parse(response.body) - validate_discovery_response!(data) + # 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 - 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 + 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 - private + reject_empty_endpoints!(data) + validate_string_array!(data, "grant_types_supported") + validate_string_array!(data, "scopes_supported") - def build_default_client(timeout) - Faraday.new do |conn| - conn.options.timeout = timeout - conn.options.open_timeout = timeout - conn.adapter Faraday.default_adapter + 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 - 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"] + # 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, 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") - return if missing.empty? + unless value.is_a?(String) && !value.empty? + raise OauthError.new("api_error", "Invalid OAuth discovery response: invalid #{key}") + end + end + end - raise OauthError.new( - "api_error", - "Invalid OAuth discovery response: missing required fields: #{missing.join(", ")}" - ) - end + # 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) + # 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", + "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..20c3474e3 --- /dev/null +++ b/ruby/lib/basecamp/oauth/discovery_selection_error.rb @@ -0,0 +1,30 @@ +# 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) + # 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 + 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..b240d01b2 --- /dev/null +++ b/ruby/lib/basecamp/oauth/fetcher.rb @@ -0,0 +1,204 @@ +# 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 — 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; + # 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 + + # 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) + # +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 + + # 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 + + # 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 + # {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. + # + # +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, 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 + + 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 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 non-adapter middleware is present + def self.ensure_redirects_suppressed!(client) + 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 + + raise OauthError.new( + "validation", + "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 + + # 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) + # 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" + # 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 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 + 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..b6695ad0d --- /dev/null +++ b/ruby/lib/basecamp/oauth/resource.rb @@ -0,0 +1,95 @@ +# 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 + # 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. + @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 + # {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 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 #{resource_origin.inspect}" + ) + end + + ProtectedResourceMetadata.new( + resource: resource, + authorization_servers: extract_authorization_servers(data) + ) + end + + private + + # 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") + + servers = data["authorization_servers"] + # 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", + "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..d253b4307 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,77 @@ 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") + # 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 + + 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? + + # 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:") 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 + + # 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 new file mode 100644 index 000000000..b0949ea2e --- /dev/null +++ b/ruby/lib/basecamp/services/authorization_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Basecamp + module Services + # Service for authorization operations. + # This is the only service that doesn't require an account context. + # + # @example Get authorization info + # auth = client.authorization.get + # puts "Identity: #{auth["identity"]["email_address"]}" + # auth["accounts"].each do |account| + # puts "Account: #{account["name"]} (#{account["id"]})" + # end + class AuthorizationService < BaseService + # Gets authorization information for the current user. + # + # 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 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 + # 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 +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..681b8f52a --- /dev/null +++ b/ruby/test/basecamp/oauth_resource_discovery_test.rb @@ -0,0 +1,174 @@ +# 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"]) + # 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"]) + 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..f72e4e8f0 --- /dev/null +++ b/ruby/test/basecamp/oauth_ssrf_test.rb @@ -0,0 +1,242 @@ +# 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 + + # 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 + 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 + @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 + 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 + + # 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_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_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 + # 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), + "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" + + 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..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 @@ -64,14 +67,144 @@ 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_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_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. 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: { issuer: advertised, token_endpoint: "https://bc5.example/token" }.to_json, + headers: { "Content-Type" => "application/json" }) + + 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 + 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_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), + # 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 @@ -175,4 +308,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..dc48571ba 100644 --- a/ruby/test/basecamp/security_test.rb +++ b/ruby/test/basecamp/security_test.rb @@ -187,6 +187,84 @@ 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_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_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. + 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") + 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 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..372c15718 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -435,14 +435,23 @@ export type { RefreshRequest, RawTokenResponse, OAuthErrorResponse, + ProtectedResourceMetadata, + FallbackReason, + DiscoverySelectionErrorReason, + DiscoverFromResourceResult, } from "./oauth/types.js"; // OAuth functions export { discover, + discoverProtectedResource, + discoverFromResource, discoverLaunchpad, + requireOriginRoot, + 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..f018ea0f1 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 { BasecampError, truncateErrorMessage } 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,276 @@ 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 } + ) { + // 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" + : "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 { + // 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") + // 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}`); + } + // 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); + } 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}`); + } + // 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}`); + } + // 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 !== "/") { + 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 + // 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}`); + } + // 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; +} - // 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`); + } + return text; + } + + 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); } - } catch (err) { - if (err instanceof BasecampError) throw err; - throw new BasecampError("validation", `Invalid OAuth discovery base URL: ${baseUrl}`); + } + // 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`); } - // Normalize base URL (remove trailing slash) - const normalizedBase = baseUrl.replace(/\/$/, ""); - const discoveryUrl = `${normalizedBase}/.well-known/oauth-authorization-server`; + 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. 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( - "network", - `OAuth discovery failed with status ${response.status}: ${body}`, + "api_error", + `OAuth discovery failed with status ${response.status}: ${truncateErrorMessage(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 +345,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 +359,292 @@ 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"); + // 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); +} /** - * Discovers OAuth configuration from Basecamp's Launchpad server. + * Fetch AS metadata from `issuerOrigin`'s well-known URL but bind the returned + * `issuer` against `bindIssuer` by code-point. Routing and binding are distinct: + * 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, + bindIssuer: string, + options: DiscoverOptions +): Promise { + 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, bindIssuer); +} + +/** + * 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" + ); + } + // 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, + 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 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 "${resourceOrigin}"` + ); + } + + // 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 (isStringArray(raw)) { + authorizationServers = raw; + } else { + throw new BasecampError( + "api_error", + "Invalid resource metadata: authorization_servers must be an array of strings when present" + ); + } + } + + 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; + + // --- 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(resourceOrigin, 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 { + // 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", + `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 { + // 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). + 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..5a19c4d3f 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 } 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"; @@ -24,9 +30,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 +99,47 @@ export async function performInteractiveLogin( clientSecret, store, baseUrl, - useLegacyFormat = true, + resourceBaseUrl, + expectedIssuer, + useLegacyFormat, callbackPort, openBrowser, promptForManualVisit, onStatus, } = options; + // 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" + ); + } + // 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. + // 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 DiscoverySelectionError( + "capability_unavailable", + "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 +194,7 @@ export async function performInteractiveLogin( clientId, clientSecret, codeVerifier: pkce?.verifier, - useLegacyFormat, + useLegacyFormat: legacy, }); // 8. Save token @@ -151,3 +206,45 @@ 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; + + // 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 + // 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 }; + } + + // 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/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..301a3d0fe 100644 --- a/typescript/tests/oauth/interactive-login.test.ts +++ b/typescript/tests/oauth/interactive-login.test.ts @@ -8,6 +8,8 @@ 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 { BasecampError } from "../../src/errors.js"; import type { TokenStore } from "../../src/oauth/token-store.js"; import type { OAuthToken } from "../../src/oauth/types.js"; @@ -137,12 +139,78 @@ 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("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( "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 +349,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..e52b7f693 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( @@ -80,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 () => { @@ -106,11 +143,35 @@ 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); } }); + 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( diff --git a/typescript/tests/oauth/resource-discovery.test.ts b/typescript/tests/oauth/resource-discovery.test.ts new file mode 100644 index 000000000..1cec7179d --- /dev/null +++ b/typescript/tests/oauth/resource-discovery.test.ts @@ -0,0 +1,472 @@ +/** + * 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, + discoverLaunchpad, + 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 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; + return HttpResponse.json({ + issuer: ORIGINS["{{LAUNCHPAD_ORIGIN}}"], + 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}}"] }); + }) + ); + }); + + 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) { + // 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) { + 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); + // 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(); + 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. + } + + // 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); + } + }); + } + + 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); + }); +}); + +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/); + } + ); + + // 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/); + } + ); + + // 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/); + }); + + // 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/); + }); + + // 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/); + } + ); + + // 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/); + } + ); + + // 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)", () => { + 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); + }); + + 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("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`, () => + 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/); + }); +});