Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ board (`backlog/` · `in-progress/` · `blocked/` · `done/`).
| [plans/2026-07-02-webhook_user_sync_conformance/plan.md](plans/2026-07-02-webhook_user_sync_conformance/plan.md) | Done | [changes/merged/2026-07-01-webhook_user_sync_conformance.md](changes/merged/2026-07-01-webhook_user_sync_conformance.md) |
| [plans/2026-07-02-server_error_handling_and_shutdown/plan.md](plans/2026-07-02-server_error_handling_and_shutdown/plan.md) | Done | [changes/merged/2026-07-01-server_error_handling_and_shutdown.md](changes/merged/2026-07-01-server_error_handling_and_shutdown.md) |
| [plans/2026-07-02-implement_lambda_runtime/plan.md](plans/2026-07-02-implement_lambda_runtime/plan.md) | Done | [changes/merged/2026-07-01-implement_lambda_runtime.md](changes/merged/2026-07-01-implement_lambda_runtime.md) |
| [plans/2026-08-05-bind_grant_type_at_token_endpoint/plan.md](plans/2026-08-05-bind_grant_type_at_token_endpoint/plan.md) | Ready | [changes/2026-08-05-bind_grant_type_at_token_endpoint.md](changes/2026-08-05-bind_grant_type_at_token_endpoint.md) |

## Conventions

Expand Down
444 changes: 444 additions & 0 deletions .specs/changes/2026-08-05-bind_grant_type_at_token_endpoint.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 01 · Typed exchange credential

**Plan:** [plan.md](../plan.md) · **Source:** [.specs/changes/2026-08-05-bind_grant_type_at_token_endpoint.md](../../../changes/2026-08-05-bind_grant_type_at_token_endpoint.md)

**Implements:** source-spec implementation notes 1–2 and 6; the structural portion of [03-service-flows.md](../../../service/specs/03-service-flows.md) and [01-domain-model.md](../../../service/specs/01-domain-model.md).

**Depends on:** —

**Produces:** a non-default `ExchangeRequest` with exactly one typed `ExchangeCredential`, so `AppService::exchange` chooses code exchange vs direct ID-token validation by the credential variant rather than optional-field presence.

**Pointers:** `crates/core/src/service/exchange.rs:12-27,64-94`; all `ExchangeRequest` constructors in `crates/core/tests/{exchange,refresh,revoke,user_admin}.rs`; server construction in `crates/server/src/routes/token.rs`.

## Steps

- [ ] In `crates/core/src/service/exchange.rs`, define public `ExchangeCredential` variants `AuthorizationCode { code: String, redirect_uri: String }` and `IdTokenAssertion { id_token: String }`; replace the optional credential fields in `ExchangeRequest` with `credential: ExchangeCredential` and remove `#[derive(Default)]`.
- [ ] Refactor `AppService::exchange` to resolve the provider once and then exhaustively `match request.credential`: the code variant calls `exchange_code(&code, &redirect_uri)` then validates its returned ID token; the assertion variant calls `validate_id_token(&id_token)` directly. Remove unreachable core-level missing-field / “either code or id_token” errors.
- [ ] Preserve the existing request context (`ip_address`, `user_agent`, `device_id`) explicitly on each `ExchangeRequest`; do not make credentials defaultable. If reducing test-literal churn, introduce a separate defaultable context value only if it does not permit credential omission.
- [ ] Migrate every repository-owned constructor across `crates/core/tests/exchange.rs`, `refresh.rs`, `revoke.rs`, and `user_admin.rs`, plus the server route after task 02's contract lands. Use the authorization-code variant for existing code-flow tests and add/retain an ID-token variant test that proves direct assertion still succeeds.
- [ ] Add meaningful precondition/invariant assertions in touched core functions consistent with the guidelines without testing untrusted HTTP input in core; the route remains the validation boundary.
- [ ] Run targeted core tests covering both variants, existing-user/suspended/registration/audit behavior, and helpers that indirectly construct exchange requests.

## Definition of done

- [ ] `ExchangeRequest` cannot be default-constructed and cannot express code plus ID token or a missing credential.
- [ ] `AppService::exchange` has no branch based on `Option` field presence; it is exhaustive over `ExchangeCredential`.
- [ ] Authorization-code exchange still sends both required values to the provider; direct ID-token assertion still validates the supplied assertion directly.
- [ ] Every existing core test constructor compiles with a coherent credential variant; no stale `code`, `redirect_uri`, or `id_token` request fields remain.
- [ ] Negative space is structural: no service-level call site can construct a request mixing the two exchange credentials.
- [ ] No certificate file is created; the user explicitly prohibited done certificates.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 02 · Strict token form parser

**Plan:** [plan.md](../plan.md) · **Source:** [.specs/changes/2026-08-05-bind_grant_type_at_token_endpoint.md](../../../changes/2026-08-05-bind_grant_type_at_token_endpoint.md)

**Implements:** source-spec `POST /token request` table and errors, implementation notes 3–8, and grant-boundary behavior for [04-http-api.md](../../../service/specs/04-http-api.md).

**Depends on:** 01 (contract — parser constructs the new `ExchangeCredential` / `ExchangeRequest` types)

**Produces:** an HTTP-boundary parser that makes declared `grant_type` binding and returns every specified malformed-grant case as a 400 OAuth error envelope before core or a provider is called.

**Pointers:** `crates/server/src/routes/token.rs:14-65`; `crates/server/src/error.rs:16-38`; `crates/server/tests/routes.rs:68-146`; `crates/test-utils/src/lib.rs:493-554`.

## Steps

- [ ] Keep `TokenForm` as the untrusted flattened wire shape and add a private `TokenGrant` discriminated representation for authorization-code, ID-token assertion, and refresh grants. Parse explicitly; do not use serde tagged enums/flattening, which is incompatible with the axum 0.8 form path described by the source spec.
- [ ] Ensure a missing `grant_type` reaches `ApiError` as `400 invalid_request` with `missing required parameter: grant_type`, rather than axum's default form-rejection response. Prefer a `FromRequest` wrapper with an `ApiError` rejection mapping that preserves `TokenForm.grant_type: String`; validate the exact pinned-axum rejection API while implementing.
- [ ] Implement `TryFrom<TokenForm> for TokenGrant` (or equivalent isolated parser) with the source table exactly: `authorization_code` requires `provider`, `code`, `redirect_uri` and rejects `id_token`, `refresh_token`; `id_token` requires `provider`, `id_token` and rejects `code`, `redirect_uri`, `refresh_token`; `refresh_token` requires `refresh_token` and rejects `provider`, `code`, `redirect_uri`, `id_token`.
- [ ] Return `InvalidRequest` for each missing required member with `missing required parameter: <name>`, reject a known field assigned to another grant with `<name> is not a parameter of the <grant_type> grant`, and retain `ApiError::UnsupportedGrantType` for present unsupported or empty values. Ignore parameters entirely outside the known form set.
- [ ] Dispatch `token_handler` on `TokenGrant`, constructing `ExchangeRequest { credential, provider, audit context }` only for exchange grants and `RefreshRequest` only for refresh. Keep handlers as parse/validate/call-core/map-response only.
- [ ] Add parser/route assertions and tests in `crates/server/tests/routes.rs`. Extend `MockIdentityProvider` with safe, deterministic call counters or use a local observing double to prove rejected payloads do not call either provider method.
- [ ] Cover: valid authorization-code, ID-token, and refresh requests; existing unknown grant and missing-code behavior; missing `grant_type` → JSON 400 invalid_request; empty/unknown `grant_type` → 400 unsupported_grant_type; each missing required member; every cross-grant field rejection; unknown unrelated form parameter ignored; and the regression where declared authorization-code includes code plus ID token, which must fail before either provider call.
- [ ] Preserve existing server E2E code-and-refresh flow and audit-context tests, updating only their constructor/import expectations as task 01 requires.

## Definition of done

- [ ] The declared, non-empty-supported `grant_type` is the sole selector of the executed token flow.
- [ ] A field belonging to another known grant is rejected at the HTTP boundary; it is never silently ignored or used to choose a service branch.
- [ ] Missing `grant_type` is a `400` JSON OAuth `invalid_request`, not axum's `422` plain-text/form rejection.
- [ ] Empty and unrecognized grant values are `400 unsupported_grant_type` with the existing stable description.
- [ ] Required-field error descriptions and cross-grant error descriptions exactly match the source specification.
- [ ] Regression tests prove malformed mixed requests invoke neither provider method; valid id-token and refresh flows retain their documented behavior.
- [ ] No certificate file is created; the user explicitly prohibited done certificates.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 03 · Credential route cache control

**Plan:** [plan.md](../plan.md) · **Source:** [.specs/changes/2026-08-05-bind_grant_type_at_token_endpoint.md](../../../changes/2026-08-05-bind_grant_type_at_token_endpoint.md)

**Implements:** source-spec `POST /token response headers` section and implementation note 9; the cache-directive portion of [04-http-api.md](../../../service/specs/04-http-api.md).

**Depends on:** 02 (build — validates route error responses after token parsing has the required OAuth envelope)

**Produces:** route-scoped `Cache-Control: no-store` and `Pragma: no-cache` headers on all `/token` and `/revoke` responses, without changing cache behavior for public metadata endpoints.

**Pointers:** `crates/server/src/routes/mod.rs:13-23`; `crates/server/src/middleware/mod.rs`; existing `from_fn` middleware patterns in `crates/server/src/middleware/{audit_context,request_id}.rs`; `crates/server/tests/routes.rs` public-router helper and token/discovery tests.

## Steps

- [ ] Add `crates/server/src/middleware/cache_control.rs` with a small `from_fn` middleware that runs the next service, inserts `Cache-Control: no-store` and `Pragma: no-cache` into the resulting response, and returns it. Export it from `middleware/mod.rs`.
- [ ] In `public_routes()`, build a merged credential route group containing only `POST /token` and `POST /revoke`, apply the cache layer to that group, then merge it with `/health`, `/keys`, and discovery. Do not apply the layer router-wide and do not add a `tower-http` feature/dependency just for header insertion.
- [ ] Preserve behavior for handler-produced successes and `ApiError::into_response` errors: since the route-scoped middleware observes `next.run()`'s response, both must carry the headers. Do not claim coverage for router-wide timeout/catch-panic responses, which are outside this route group and do not return credentials.
- [ ] Add server route tests proving: a successful `/token` response has both exact headers; an unsupported-grant `/token` OAuth error has both; `/revoke` has both; `/keys` and `/.well-known/openid-configuration` have neither. Keep the current success/error body and status assertions.
- [ ] Run focused server route and E2E tests, then workspace format/lint/tests as appropriate. If full `cargo test --workspace` still reports the known three missing-`providers.*.adapter` config failures, record them as pre-existing and do not modify config code.

## Definition of done

- [ ] Every successful and handler-error `/token` response contains `Cache-Control: no-store` and `Pragma: no-cache`.
- [ ] `/revoke` receives the same headers through the shared credential route group.
- [ ] `/keys`, discovery, and health are not blanket-marked no-store.
- [ ] The layer is route-scoped and mechanically inherited by any route intentionally added to the credential group.
- [ ] No new dependency or feature bump is introduced solely for cache headers.
- [ ] No certificate file is created; the user explicitly prohibited done certificates.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 04 · Canonical specs and binding examples

**Plan:** [plan.md](../plan.md) · **Source:** [.specs/changes/2026-08-05-bind_grant_type_at_token_endpoint.md](../../../changes/2026-08-05-bind_grant_type_at_token_endpoint.md)

**Implements:** all affected canonical pages and the source-spec compatibility/documentation requirements.

**Depends on:** 01 (contract), 02 (contract), 03 (review)

**Produces:** canonical service prose/schema and binding README examples that exactly describe the implemented strict grant parsing and credential-response cache policy.

**Pointers:** `.specs/service/specs/{00-overview,01-domain-model,03-service-flows,04-http-api}.md`; `.specs/service/specs/canonical-types.schema.json`; `bindings/nodejs/README.md:37-42`; `bindings/python/README.md:48-53`; source-spec merge plan and proposed blocks.

## Steps

- [ ] Update `04-http-api.md`: replace the `/token` request text with the binding `grant_type` per-grant required/rejected parameter table; document ignored unknown parameters, exact OAuth error table, and all-response `Cache-Control: no-store` / `Pragma: no-cache` behavior for the `/token` + `/revoke` route group. Bump its date.
- [ ] Update `03-service-flows.md`: state that the handler passes a typed `ExchangeRequest` selected by declared grant; describe exhaustive `ExchangeCredential` matching; add the declared-grant decision. Bump its date.
- [ ] Update `01-domain-model.md`: add the `ExchangeCredential` and non-default `ExchangeRequest` entity block after token types, including context fields and the separate `RefreshRequest` boundary. Bump its date.
- [ ] Update `00-overview.md`: revise the two-grant-input decision so `grant_type`, not field presence, selects code vs ID-token exchange. Bump its date.
- [ ] Add `ExchangeCredential` and `ExchangeRequest` definitions to `.specs/service/specs/canonical-types.schema.json`, using closed `oneOf` variants with the implementation's naming and all required fields. Validate JSON and `$ref` paths; retain the repo-wide OAuth error-envelope schema unchanged.
- [ ] Correct the Node and Python binding README request snippets to use `application/x-www-form-urlencoded`, encode an authorization-code request with `redirect_uri`, and retain `provider`; ensure examples match the endpoint’s required shape rather than the current JSON/missing-redirect form.
- [ ] Review all internal Markdown links and source spec references. Do not mark the source spec Merged, move it, update the change-spec table, or add done certificates; those merge lifecycle actions are explicitly outside this unstacked PR's plan implementation.

## Definition of done

- [ ] Each of the five source-identified canonical targets is updated and says no more or less than the implementation/tests prove.
- [ ] Schema definitions represent the closed exchange credential variants and a required credential/provider request without adding invented API fields.
- [ ] Node/Python examples are valid form-encoded authorization-code requests including `redirect_uri`.
- [ ] Discovery/id-token grant gating remains explicitly out of scope; no unrelated proposed spec is absorbed.
- [ ] All local Markdown links resolve, JSON parses, source-spec requirements map to one of tasks 01–04, and the README plan index is updated by the planning change (not deferred).
- [ ] No certificate file is created; the user explicitly prohibited done certificates.
Loading