Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
92dd354
Merge branch 'feat/server-binary-config-operator-commands-401' into f…
justin13888 Sep 2, 2026
53388e4
Merge branch 'feat/server-binary-config-operator-commands-401' into f…
justin13888 Sep 2, 2026
8eee213
feat(server): advertise and gate the protocol handshake from one inte…
justin13888 Sep 2, 2026
bdf1128
fix(server): enforce and advertise the configured protocol window
justin13888 Sep 2, 2026
c739ecc
feat(sdk): send the protocol handshake as default headers
justin13888 Sep 2, 2026
b4881d3
docs(design): record the negotiation carriage and the exempt operations
justin13888 Sep 2, 2026
eaee185
feat(server): gate every operation but the ten the design exempts
justin13888 Sep 2, 2026
d7d6f53
feat(sdk): build every transport from one client carrying the handshake
justin13888 Sep 2, 2026
7526395
chore(sdk): drop the redundant must_use on http_builder
justin13888 Sep 2, 2026
8afeb9e
Merge branch 'feat/server-binary-config-operator-commands-401' into f…
justin13888 Sep 5, 2026
1d47163
Merge branch 'fix/protocol-headers-every-route-404' into feat/oidc-re…
justin13888 Sep 5, 2026
b63cf55
feat(server): add the OIDC authorization ceremony store
justin13888 Sep 5, 2026
a11d280
feat(server): hold reads to the handshake's grammar and writes to its…
justin13888 Sep 5, 2026
302da2f
feat(server): verify OIDC ID tokens as a pure function
justin13888 Sep 5, 2026
671f83e
refactor(server): check the ID token audience with contains
justin13888 Sep 5, 2026
05ea8e5
fix(server): validate the protocol window and default it to the policy's
justin13888 Sep 5, 2026
2546328
docs(sdk): say where a caller-supplied HTTP client must come from
justin13888 Sep 5, 2026
892f757
fix(web): send the protocol handshake from the browser client
justin13888 Sep 5, 2026
c21188d
feat(server): add the OIDC identity-provider port and its HTTP adapter
justin13888 Sep 5, 2026
e977c09
Merge branch 'fix/protocol-headers-every-route-404' into feat/oidc-re…
justin13888 Sep 5, 2026
032b6af
test(web): assert the browser client sends the protocol handshake
justin13888 Sep 5, 2026
c27e86b
feat(server): mount the OIDC authorize and callback routes
justin13888 Sep 5, 2026
62376d8
Merge branch 'fix/protocol-headers-every-route-404' into feat/oidc-re…
justin13888 Sep 5, 2026
2c41ec0
feat(sdk): begin and complete an OIDC login
justin13888 Sep 5, 2026
73977f0
docs(design): record the OIDC relying party and ship a dex developmen…
justin13888 Sep 5, 2026
c080a44
test(server): walk every declared response of the OIDC operations
justin13888 Sep 5, 2026
273f42f
fix(server): harden the OIDC token and discovery checks
justin13888 Sep 6, 2026
91cf7a0
feat(server): bound the OIDC authorize by budget and by store ceiling
justin13888 Sep 6, 2026
6512e70
fix(server): reserve a federated address only when the provider verif…
justin13888 Sep 6, 2026
d294ad7
fix(server): make loopback redirects opt-in and hold the redirect URL…
justin13888 Sep 6, 2026
d73f235
feat(server): trust a private CA for the identity provider
justin13888 Sep 6, 2026
5e4d2e8
refactor(sdk): drive the OIDC login through the generated client
justin13888 Sep 6, 2026
e54cfb5
docs(design): record the round-1 OIDC decisions and document the six …
justin13888 Sep 6, 2026
36c7341
fix(server): charge the OIDC authorize budget under a bounded key
justin13888 Sep 9, 2026
d5b0afe
fix(server): purge and bound the in-memory counter windows
justin13888 Sep 9, 2026
739845d
feat(server): give the OIDC at-capacity refusal its own code
justin13888 Sep 9, 2026
856e731
fix(server): bound the enrollment redemption key before charging it
justin13888 Sep 9, 2026
04192ea
fix(server): give each counter key its own ceiling
justin13888 Sep 9, 2026
1a5b9e9
docs(design): record the partitioned counter ceiling and the code sha…
justin13888 Sep 9, 2026
c2a59e8
test(server): fill the drop partition to its shipped ceiling
justin13888 Sep 9, 2026
47ed85a
fix(server): tell a caller a full limiter partition is not an outage
justin13888 Sep 9, 2026
b9e5b8e
docs(server): name the issue the shared counter lock is deferred to
justin13888 Sep 9, 2026
f1ab7d2
test(server): pin that only the ceiling reads as a capacity refusal
justin13888 Sep 9, 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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ jiff = { version = "0.2", features = ["serde"] }
jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] }
nanoid = "0.4.0"
redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] }
# The HTTP client. `capsule-sdk` is the sanctioned client network path and `capsule-server`'s
# OIDC relying party (slice `S-N1`) is the one server egress: discovery, JWKS and the token
# exchange against an identity provider. rustls only, per the TLS row in design/dependencies.md;
# `json` for the provider's documents. The SDK enables `stream` and `multipart` on top.
reqwest = { version = "0.12.28", default-features = false, features = [
"json",
"rustls-tls",
] }
ring = "0.17.14"
sea-orm = { version = "1.1.20" }
sea-orm-migration = { version = "1.1.20", features = [
Expand Down
26 changes: 24 additions & 2 deletions SLICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,8 @@ row's remainder now lives.
| S-I6 | Android ships raw ICU to users; the guard never fires | i18n | — | M | ACTIVE | done | `aapt2` unverified — owed-CI |
| S-I7 | The Rust runtime formatter cannot do ICU plurals | i18n | — | M | ACTIVE | done\* | refuses now; evaluating plurals still owed |
| S-I8 | clap `--help` text is unreachable from the catalogs | i18n | — | S | ACTIVE | ready | found widening `i18n-guard` |
| S-N1 | OIDC relying party (server) | auth | — | L | RETIRED | ready | |
| S-N2 | SDK/CLI OIDC login flows | auth | S-N1 | M | MIXED | blocked | |
| S-N1 | OIDC relying party (server) | auth | — | L | RETIRED | done\* | in-process mock IdP stands in for the testcontainer one; durable adapters owed (#460) |
| S-N2 | SDK/CLI OIDC login flows | auth | S-N1 | M | MIXED | part | SDK half landed with `S-N1`; CLI loopback listener + device grant are #461 |
| S-N3 | `device_id` on session listing + ceremony cohorts | auth | — | S | RETIRED | done | the wire half lands with `S-C13`; the TOTP ceremony with `S-C55`; passkeys retire on `S-C56` |
| S-P1 | `capsule_sdk` FFI workspace verbs | iOS path | S-A10 | L | MIXED | done | feed `manifest_cbor` shape → `S-C30` |
| S-P2 | Swift auth service + Keychain + login screen | iOS path | S-P1 | L | MIXED | ready | |
Expand Down Expand Up @@ -4887,6 +4887,22 @@ lands on Kynos rather than on Salvo.
green. **Tier:** Unit + Smoke. **Blocks:** S-N2.
- **Rebuild note:** unstarted, so there is nothing to re-scope — write it against Kynos
directly rather than adding routes to a server that is being replaced.
- **Landed (issue #407):** `capsule-server::auth::oidc` — a pure ID-token validator
(`claims`), discovery with the issuer mix-up defence, a JWKS cache refetched on an
unknown `kid` and floored at one fetch a minute, the `IdentityProvider` port with its
HTTP adapter and a `Disabled` null object, `FederatedAccounts` keyed on
`(issuer, subject)` with no linking by address, and a typed `OidcAuthorizationStore`
ceremony port (single-use `state`, ten-minute TTL). `POST /v1/auth/oidc/authorize` and
`POST /v1/auth/oidc/callback` mount inside the protocol gate and mint sessions through
the password path's `open_session_for`, second factor included; `server-info` publishes
`auth.oidc` or `null`. **Two deviations, recorded:** the testcontainer IdP is an
in-process mock provider on loopback, because `test-rust` runs offline (dex in
`capsule-server/compose.yaml`, `--profile oidc`, is the manual run); and the Valkey
ceremony-store and Postgres federated-account adapters are owed (#460), so
`OIDC_ISSUER` under the durable backends is refused by name and the development
profile's federated accounts hold their own rows. Hand-written over `jsonwebtoken`
rather than `openidconnect` — see the OIDC row in
[Dependencies](capsule-docs/src/content/docs/design/dependencies.md).

### S-N2 — SDK/CLI OIDC login flows

Expand All @@ -4898,6 +4914,12 @@ lands on Kynos rather than on Salvo.
`cohort_hash` rides the ceremony. **Depends on:** S-N1 (**live block**).
- **Done when:** `capsule auth login --oidc` round-trips against the dev IdP;
mocked-HTTP tests per flow. **Tier:** Unit + Smoke.
- **Part landed (issue #407):** `capsule_sdk::auth::AuthClient::begin_oidc_login` /
`complete_oidc_login` — the two server legs, answering the same `LoginOutcome` a
password login does, with the cohort riding the completing request and the
`error.auth.oidc_*` refusals typed on `AuthError`. **Remainder (#461):** the CLI's
loopback listener and `--oidc` arm, the browser-open policy the docs do not carry, and
the device authorization grant (RFC 8628) with its own ceremony store.

### S-N3 — `device_id` on session listing + ceremony cohorts

Expand Down
11 changes: 11 additions & 0 deletions capsule-android/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1827,6 +1827,14 @@
<string name="error_auth_account_locked">This account is locked after too many failed sign-in attempts.</string>
<string name="error_auth_current_password_invalid">That is not your current password.</string>
<string name="error_auth_invalid_credentials">Invalid email or password.</string>
<string name="error_auth_oidc_address_taken">An account with that email address already exists here. Sign in with its password instead.</string>
<string name="error_auth_oidc_at_capacity">Too many sign-ins are already in progress. Please try again in a moment.</string>
<string name="error_auth_oidc_exchange_failed">Your identity provider didn\'t accept that sign-in. Try again.</string>
<string name="error_auth_oidc_not_configured">Single sign-on isn\'t set up on this server.</string>
<string name="error_auth_oidc_redirect_invalid">That sign-in can\'t return to this app.</string>
<string name="error_auth_oidc_state_invalid">That sign-in has expired. Start again.</string>
<string name="error_auth_oidc_token_invalid">Your identity provider\'s answer couldn\'t be verified.</string>
<string name="error_auth_oidc_unavailable">Capsule couldn\'t reach your identity provider just now. Please try again.</string>
<string name="error_auth_password_invalid">That password cannot be used.</string>
<string name="error_auth_profile_invalid">That display name cannot be used.</string>
<string name="error_auth_profile_not_found">That account no longer exists.</string>
Expand Down Expand Up @@ -1856,6 +1864,7 @@
<string name="error_directory_unsupported_media_type">That device list couldn\'t be read.</string>
<string name="error_directory_version_conflict">This device list is out of date. Capsule will refresh it before continuing.</string>
<string name="error_drop_adoption_refused">That upload could not be added to the album.</string>
<string name="error_drop_at_capacity">This server is handling too many upload links right now. Please try again shortly.</string>
<string name="error_drop_cap_exceeded">This upload link is full.</string>
<string name="error_drop_cap_exhausted">This upload link is full. Ask for a new one.</string>
<string name="error_drop_chunk_refused">That part of the upload could not be accepted. It will be retried.</string>
Expand All @@ -1867,6 +1876,7 @@
<string name="error_drop_passphrase_required">This upload link needs its passphrase.</string>
<string name="error_drop_rate_limited">Too many attempts. Please wait and try again.</string>
<string name="error_drop_unavailable">Capsule couldn\'t reach the upload service. Please try again.</string>
<string name="error_enrollment_at_capacity">This server is handling too many enrollment attempts right now. Please try again shortly.</string>
<string name="error_enrollment_channel_not_found">This device-add session has ended. Start again.</string>
<string name="error_enrollment_code_refused">That device code didn\'t work. Generate a new one and try again.</string>
<string name="error_enrollment_local_auth_required">Confirm it\'s you on this device to add another device.</string>
Expand Down Expand Up @@ -1902,6 +1912,7 @@
<string name="error_request_unauthenticated">Please sign in again.</string>
<string name="error_request_unprocessable">Some of that request didn\'t make sense.</string>
<string name="error_request_unsupported_media_type">Capsule couldn\'t read that content type.</string>
<string name="error_share_at_capacity">This server is handling too many shared links right now. Please try again shortly.</string>
<string name="error_share_malformed">That share link could not be created.</string>
<string name="error_share_rate_limited">Too many attempts. Please wait and try again.</string>
<string name="error_share_unavailable">Capsule couldn\'t reach that share. Please try again.</string>
Expand Down
13 changes: 11 additions & 2 deletions capsule-cli/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,21 @@ impl ServerStatus {
// exactly the base the generated operation paths hang off.
let api_endpoint = remote.sync_endpoint.clone();

let client = match capsule_sdk::rest::Client::new(&api_endpoint) {
// Over the SDK's one HTTP client rather than the generated `Client::new`, so the probe
// carries the same protocol handshake every other request does; `/v1/version` is
// exempt from the gate, and a probe that spoke differently from the calls it precedes
// would tell the user nothing about them.
let client = match capsule_sdk::net::http_client()
.map_err(|error| error.to_string())
.and_then(|http| {
capsule_sdk::rest::Client::with_client(http, &api_endpoint)
.map_err(|error| error.to_string())
}) {
Ok(client) => client,
Err(error) => {
return Ok(ServerStatus {
api_endpoint,
connection_status: ConnectionStatus::Error(error.to_string()),
connection_status: ConnectionStatus::Error(error),
api_version: None,
response_time: None,
server_health: None,
Expand Down
46 changes: 40 additions & 6 deletions capsule-docs/src/content/docs/design/api-surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,46 @@ Every public route applies the same headers:

| Header | Direction |
| --- | --- |
| `X-Capsule-Protocol` | request |
| `X-Capsule-Crypto-Suite` | request for writes |
| `X-Capsule-Sidecar-Schema` | request |
| `X-Capsule-Protocol-Min` | response |
| `X-Capsule-Protocol-Max` | response |
| `X-Capsule-Min-Client-Build` | response |
| `X-Capsule-Protocol` | request, required on every gated route |
| `X-Capsule-Crypto-Suite` | request for writes; validated when present |
| `X-Capsule-Sidecar-Schema` | request on metadata updates; validated when present |
| `X-Capsule-Protocol-Min` | response, on every response of every operation |
| `X-Capsule-Protocol-Max` | response, on every response of every operation |
| `X-Capsule-Min-Client-Build` | response, on every response of every operation; advisory (`0.0.0` = no cutoff) |

The carriage is two Kynos interceptors in `capsule-server/src/negotiation.rs`, and the split
is the point: `Negotiation` is mounted on the whole router, outside everything that can refuse,
so the three response headers ride a `413`, a `401` and a `426` exactly as they ride a `200`
(an unrouted `404`/`405` is the router's own and carries none — Kynos runs interceptors per
operation, after routing);
the gate is two `Group`s — `ProtocolGate` holding every non-safe operation and
`ProtocolReadGate` every gated `GET`/`HEAD` — so an operation is gated by being mounted inside
one and exempt by being mounted outside both. The two gates are the two halves of the
fail-closed rules: a **write** with a grammatical `X-Capsule-Protocol` outside `[Min, Max]` is
`426`; a **read** with the same header is admitted ("reads of any past version succeed" — and a
future date on a read is admitted too, since the rule is the grammar and nothing else), and a
missing or malformed header is `400 error.request.malformed` on every gated operation. All
three read one protocol window — the upload policy's, built from `PROTOCOL_MIN`/`PROTOCOL_MAX`
at boot — so the window a client is told and the window it is held to cannot be two numbers. A
`426` carries the window on the headers and the stable `error.protocol.version_unsupported` code
in the body; nothing restates the window as a body member.

**Exempt from the request gate** (and still carrying the response headers), ten operations:

- `GET /v1/version` — the reachability probe a client hits before it knows the window.
- `GET /.well-known/capsule/attestation-keys`, `GET /.well-known/capsule/server-info`,
`GET /.well-known/capsule/deprecation`, `GET /.well-known/capsule/revoked-jti` — public
discovery, read before any handshake.
- `GET /s/{opaque_id}`, `GET /s/{opaque_id}/wrapped-secret`, `GET /s/{opaque_id}/blob/{hash}` —
[Share Links](/design/share-links/) requires an indistinguishable `404` there, and a `426`
would be a probing oracle.
- `POST /d/{opaque_id}`, `PATCH /d/{opaque_id}/{upload_id}` — the link record pins
`protocol_version` and `crypto_suite_id` at issuance ([Web Upload](/design/web-upload/)), so a
browser guest has nothing to assert.

`capsule-server/tests/conformance.rs` pins both the gated set and this exempt set against the
emitted document, and walks every operation on the wire, so a route cannot join or leave the
gate by accident.

Credentials use `Authorization: Bearer`. Session access tokens and federation capabilities are
different token types verified by their owning modules, even though both use the standard HTTP
Expand Down
Loading
Loading