Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
bb349a8
OAuth resource-first discovery contract: SPEC.md + fixtures + oauth-f…
jeremy Jul 14, 2026
9aa5e70
TypeScript: resource-first OAuth discovery + issuer binding + SSRF
jeremy Jul 14, 2026
23e1448
Python: resource-first OAuth discovery + issuer binding + SSRF
jeremy Jul 15, 2026
b65f85d
Go: resource-first OAuth discovery + issuer binding + SSRF
jeremy Jul 15, 2026
1d7db43
Ruby: resource-first OAuth discovery + issuer binding + SSRF + empty-…
jeremy Jul 15, 2026
7e63ff0
Kotlin: resource-first OAuth discovery + issuer binding + SSRF + shar…
jeremy Jul 15, 2026
2d5ba7f
discovery: reject delimiter-only userinfo, keep OAuthConfig field ord…
jeremy Jul 16, 2026
1b1f8ff
Go: case-insensitive host, reject present-null lists, non-string endp…
jeremy Jul 16, 2026
634f69d
TypeScript: reject present-null authorization_servers, dedupe issuers…
jeremy Jul 16, 2026
744e81e
Kotlin: lowercase origin host (case-insensitive), reject bare-query o…
jeremy Jul 16, 2026
d9ffe1d
Python: httpx.URL origin validation, appended field, result invariant…
jeremy Jul 17, 2026
98971d9
Ruby: issuer code-point bind, enforceable redirect policy, present-nu…
jeremy Jul 17, 2026
7ca2f12
conformance: require error on raise, fallbackReason on fallback (oaut…
jeremy Jul 17, 2026
4285324
oauth: address #369 review round under the three shared discovery con…
jeremy Jul 17, 2026
19f5e47
oauth: internal issuer binding, typed capability_unavailable, bare-# …
jeremy Jul 17, 2026
12d33a8
oauth: reject present-null list metadata, close Ruby SSRF entry, cove…
jeremy Jul 17, 2026
d797886
oauth: reject null authorization_servers in Kotlin, port 0 in TS, fix…
jeremy Jul 17, 2026
49e566f
oauth: presence-check baseUrl, root-export requireOriginRoot, reject …
jeremy Jul 17, 2026
48467d7
oauth(python): normalize discovery body cap to a finite non-negative int
jeremy Jul 17, 2026
28f3393
oauth: uniform dangling-port + empty-userinfo origin-root rejection
jeremy Jul 17, 2026
ed83fe5
oauth: bind resource identifier to the raw caller origin; Kotlin port-0
jeremy Jul 17, 2026
773f1ea
oauth: raw-identifier binding through the orchestrator + public disco…
jeremy Jul 17, 2026
da97a49
oauth: correct stale binding comments on the internal bind helpers
jeremy Jul 17, 2026
2748394
oauth: reject C0/space/backslash origin spellings that parsers normal…
jeremy Jul 17, 2026
eb7a0bd
oauth: append device endpoint for positional compat; reject missing-a…
jeremy Jul 17, 2026
ccdae8b
oauth: reject dot-segment paths that URL parsers resolve away
jeremy Jul 17, 2026
59ed7e4
oauth: validate every advertised *_endpoint in Kotlin discovery
jeremy Jul 17, 2026
1605714
oauth: propagate caller cancellation before Go soft fallback
jeremy Jul 17, 2026
b776533
oauth: truncate non-2xx discovery body in TypeScript error message
jeremy Jul 17, 2026
21cacc8
oauth: bound Ruby discovery reads with a wall-clock deadline
jeremy Jul 17, 2026
22f8046
oauth: propagate caller cancellation during Go hop-2 AS fetch
jeremy Jul 17, 2026
d3dfa5c
oauth: correct Ruby selection-error taxonomy comment and test adapter…
jeremy Jul 17, 2026
c83f57f
oauth: reject non-finite discovery timeouts and fix taxonomy docstrin…
jeremy Jul 17, 2026
0561652
oauth: normalize non-finite discovery timeouts in Ruby
jeremy Jul 17, 2026
fc20ce5
oauth: harden Python timeout normalization against oversized ints
jeremy Jul 17, 2026
8f63c49
oauth: guard Ruby timeout normalization against non-real numerics
jeremy Jul 17, 2026
9f4f5d0
oauth: make Go RegistrationEndpoint optional and normalize ports nume…
jeremy Jul 17, 2026
be0f992
oauth: correct TypeScript selection-error taxonomy comment
jeremy Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"

#------------------------------------------------------------------------------
Expand Down Expand Up @@ -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"
Expand Down
157 changes: 151 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions conformance/oauth/README.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions conformance/oauth/fixtures/01-two-hop-happy-path.json
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 16 additions & 0 deletions conformance/oauth/fixtures/02-no-as-advertised-absent.json
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 16 additions & 0 deletions conformance/oauth/fixtures/03-no-as-advertised-empty-array.json
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 16 additions & 0 deletions conformance/oauth/fixtures/04-only-launchpad.json
Original file line number Diff line number Diff line change
@@ -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
}
}
19 changes: 19 additions & 0 deletions conformance/oauth/fixtures/05-resource-mismatch.json
Original file line number Diff line number Diff line change
@@ -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
}
}
13 changes: 13 additions & 0 deletions conformance/oauth/fixtures/06-hop1-transport-failure.json
Original file line number Diff line number Diff line change
@@ -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
}
}
32 changes: 32 additions & 0 deletions conformance/oauth/fixtures/07-issuer-binding-mismatch.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading