diff --git a/README.md b/README.md index ad07cbf..ec0f219 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ covers the IC's labelled services, which is where the meaningful ones live. `call_canister` runs anonymously by default; pass a `domain` (e.g. `oisy.com`) to call as your account at that app. For a domain, the server mints a **short-lived -(≤5 min) account delegation on demand** from the connection's standing Internet -Identity credential (see [Domain identities](#domain-identities-on-demand)) — +account delegation on demand** using the connection's registered Internet +Identity session key (see [Domain identities](#domain-identities-on-demand)) — there is no per-app sign-in step. `get_principal` returns that account's principal without a call. A user may hold several accounts at an app — a default ("synthetic") account everyone gets automatically, plus any they have named — so @@ -96,10 +96,11 @@ Add the server to Claude Code (replace the URL with wherever it's hosted): claude mcp add --transport http ic-poc https://YOUR-HOST/mcp ``` -Then run `/mcp` → **ic-poc** → authenticate: the browser is sent to **Internet -Identity**'s `/mcp` flow, you sign in once, and the tools become available -— that single login is the connection's standing credential. (Any MCP client -with remote HTTP + OAuth support works.) +Then run `/mcp` → **ic-poc** → authenticate: the client obtains a device code and +opens a verification link that launches **Internet Identity**'s `/mcp` handshake; +you sign in once, and the tools become available — that single login registers the +connection's session key with II as a time-boxed grant. (Any MCP client that +supports the OAuth 2.0 device authorization grant, RFC 8628, works.) ## Run @@ -148,90 +149,90 @@ curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"n # => ("Internet Computer") ``` -## Auth (OAuth 2.1, login via Internet Identity) +## Auth (OAuth 2.0 device grant, login via Internet Identity) -`/mcp` is gated by a bearer token. The MCP client obtains it with a standard -OAuth 2.1 authorization-code flow, except logging in runs **Internet Identity's -`/mcp` delegation flow** instead of username/password, and the issued token is -bound to the resulting **principal**. +`/mcp` is gated by a bearer token. II's `/mcp` handshake has **no redirect back +to this server** — the II tab makes two background `fetch()` POSTs to our +callback and then finishes on its own — so a classic authorization-code +`redirect_uri` can't be delivered. The MCP client's login is therefore modelled +as the **OAuth 2.0 Device Authorization Grant (RFC 8628)**: the client gets a +`device_code` + a verification link, the user opens it (which launches II's +handshake), and the client polls the token endpoint until the grant is live. Endpoints: -- `GET /.well-known/oauth-authorization-server` — AS metadata +- `GET /.well-known/oauth-authorization-server` — AS metadata (advertises the + device-code grant and the `device_authorization_endpoint`) - `GET /.well-known/oauth-protected-resource` — points clients at the AS -- `POST /oauth/register` — dynamic client registration (RFC 7591); the client's - `redirect_uris` are stored (persisted to `OAUTH_CLIENTS_FILE`) and bound to the - issued `client_id` -- `GET /oauth/authorize` — mints the connection's backend key and redirects the - browser to II's `/mcp` flow, sending the backend **public** key -- `POST /oauth/connect/callback` — II form-POSTs the delegation chain here; the - server verifies + stores it and redirects back with a principal-bound code -- `POST /oauth/token` — exchanges the code for an access token +- `POST /oauth/register` — dynamic client registration (RFC 7591); `redirect_uris` + are optional (the device grant has none) and persisted to `OAUTH_CLIENTS_FILE` +- `POST /oauth/device_authorization` — mints a `device_code`/`user_code` and a + `verification_uri` (RFC 8628 §3.2) +- `GET /oauth/device` — the user opens this; it launches II's `/mcp` handshake + with the connect `state` (= session id) in the URL fragment, `ttl` in **seconds** +- `POST /oauth/connect/callback` — serves II's **two cross-origin JSON POSTs**: + a key request `{state}` → `{public_key}` (a fresh session keypair, minted here), + and a completion notification `{state, expiration}` → mark the grant live +- `POST /oauth/token` — the client polls with the `device_code` + (`authorization_pending`/`slow_down`) until the token is issued Unauthenticated `/mcp` requests get `401` with a `WWW-Authenticate` header pointing at the resource metadata, as the MCP spec expects. -**Redirect validation is per-client, not a host allowlist.** `/oauth/authorize` -accepts a `redirect_uri` only if the requesting `client_id` registered it (exact -match, OAuth 2.1) — so any registration-compliant client (Claude, ChatGPT, Grok, -…) works without code changes, and the server can't be steered to an -unregistered URL. The one exception is loopback (`http://127.0.0.1|localhost|[::1]`, -any port) per RFC 8252, for native clients that bind an ephemeral callback port. - -**No private key is ever transmitted.** The backend generates a per-connection -Ed25519 key and sends only its **public** key to II. II logs the user in and -returns a delegation chain `anchor → backend key` (the 60-minute standing -credential). The chain itself is the proof of identity, so there is no nonce -round-trip; the server (`src/delegation.rs`) verifies: - -1. the chain links to the II root (the II canister signature is checked against - the IC mainnet root key via `ic-signature-verification`); -2. no delegation has expired; -3. the chain ends at this connection's backend key (so the backend, holding the - private half, can sign with it); -4. the principal is `self_authenticating(user_key)`. - -Only then is a principal-bound code minted. This matters because the server -keys per-principal session data off that identity — a spoofable principal would -let one user read another's session. (Fund safety is independent: that's -enforced by the IC at signing time, not here.) **PKCE (S256) is enforced**; -codes live 120s, connects 600s, access tokens 1h. - -Set the public base URL (used in the discovery docs and as the MCP origin) with -`PUBLIC_URL`. The Internet Identity instance is `II_URL` (browser login, default -`beta.id.ai`) plus `II_CANISTER_ID` (the canister the account-delegation calls -target, default `fgte5-ciaaa-aaaad-aaatq-cai`) — both point at the same II. +**The server is passive during the handshake and holds no key at link time.** On +the key-request POST it generates a fresh per-connection Ed25519 keypair and +returns only its **public** key (base64url, unpadded, DER). II's *frontend* +registers that key with the II canister (`mcp_register`, under the user's own +authentication) as a time-boxed grant bound to the user's anchor. **The server +never receives or verifies a delegation chain that represents itself, and never +calls `mcp_register`.** The issued access token is bound to the session key's +principal (`self_authenticating(session_pubkey)`), which is exactly the identity +the grant is bound to. + +**PKCE (S256)** is bound to the poll when the client supplies a `code_challenge`; +device codes live 600s, access tokens 1h. Treat any `Unauthorized` from II as +"session over → reconnect": the server surfaces a reconnect message and does not +retry. + +Set the public base URL (used in the discovery docs, as the MCP origin, and as the +management identity's derivation origin) with `PUBLIC_URL`. The Internet Identity +instance is `II_URL` (browser login, default `beta.id.ai`) plus `II_CANISTER_ID` +(the canister the `mcp_*` calls target, default `fgte5-ciaaa-aaaad-aaatq-cai`) — +both point at the same II. ## Domain identities (on demand) There is no per-app browser sign-in. Instead the model is: -- **One standing credential per connection.** When you connect (authenticate via - Internet Identity), the backend obtains a **60-minute standing delegation** — - a chain `anchor → backend session key` issued for the MCP origin. The backend - holds a per-session Ed25519 key that this delegation ends at, so it can sign as - the anchor's MCP-origin principal. Reconnect when it expires. +- **One registered session key per connection.** When you connect, the backend + generates a per-connection Ed25519 **session key** and II's frontend registers + it as a time-boxed grant bound to your anchor. The backend signs II's `mcp_*` + calls directly with that key (its principal `self_authenticating(session_pubkey)` + is what the grant is bound to). Reconnect when the grant expires or is revoked. - **App delegations minted on demand.** When `call_canister` (or `get_principal`) is invoked with a `domain` (e.g. `oisy.com`), the backend mints a **short-lived - (≤5 min) per-app account delegation on demand**: signing *as the standing - identity*, it calls Internet Identity's account-derivation methods directly — - no browser round-trip — with the app's target origin and the backend session - key as `session_key`. The returned chain ends at the backend session key, so - the backend signs the canister call with `ic-agent`'s `DelegatedIdentity`. + per-app account delegation on demand**: signing *as the session key*, it calls + Internet Identity's account-derivation methods directly — no browser round-trip + — with the app's target origin and a fresh **per-app key** as `session_key`. + The returned delegation is issued to that per-app key, so the backend signs the + canister call with `ic-agent`'s `DelegatedIdentity` over `[user_key → per-app key]`. -The on-demand derivation calls two **new II canister methods**: +The on-demand derivation calls these II canister methods (per +[dfinity/internet-identity#4086](https://github.com/dfinity/internet-identity/pull/4086)): ```candid -mcp_prepare_account_delegation : +mcp_prepare_delegation : (target_origin: text, account_number: opt nat64, session_key: blob, max_ttl: opt nat64) -> (variant { Ok: record { user_key: blob; account_number: opt nat64; expiration: nat64 }; Err: AccountDelegationError }); -mcp_get_account_delegation : +mcp_get_delegation : (target_origin: text, account_number: opt nat64, session_key: blob, expiration: nat64) -> (variant { Ok: SignedDelegation; Err: AccountDelegationError }) query; ``` +- `session_key` is the DER pubkey of a **fresh per-app key**, distinct from the + connection's session key; the minted delegation is issued to it. - `target_origin` is `https://`, with IC gateway domains remapped: `*.icp0.io` / `*.icp.net` → `*.ic0.app`. - `account_number` names which of the anchor's accounts at `target_origin` to act @@ -240,9 +241,8 @@ mcp_get_account_delegation : `get` so both calls sign for the same account. The server passes `null` for the default account, or a specific number when an `account` name was given — resolved from `mcp_get_accounts` (see [Listing accounts](#listing-accounts) below). -- `max_ttl` is in **nanoseconds**; the server passes 5 minutes - (`APP_DELEGATION_TTL_NS`), which is also II's hard cap. (Distinct from the - browser `/mcp` flow's `ttl`, which is in minutes.) +- `max_ttl` is in **nanoseconds**; the server passes `null`, so II applies its + default (≤ 1 hour, and never past the grant). - These methods live on the **same II instance** as the connect-time login: `II_URL` (default `https://beta.id.ai`) is the browser login origin and `II_CANISTER_ID` (default `fgte5-ciaaa-aaaad-aaatq-cai`, that instance's @@ -265,48 +265,39 @@ type AccountInfo = record { }; ``` -signed as the standing identity. Like the `mcp_*_account_delegation` methods, II -**recovers the anchor from the caller** (the connect-time MCP-origin principal), -so no anchor number is needed. To act as a non-default account, pass its `name` to +signed as the session key. Like the delegation methods, II **recovers the anchor +from the caller** (the registered session-key principal), so no anchor number is +needed. To act as a non-default account, pass its `name` to `call_canister`/`get_principal` as `account`; the server resolves the name to its `account_number` via `mcp_get_accounts` and threads that into the on-demand -delegation. Omitting `account` uses the default account, as before. - -> **Status:** the standing-credential connect flow runs against II's existing -> `/mcp` delegation flow. The two `mcp_*_account_delegation` canister methods used -> for on-demand app delegations were introduced in -> [dfinity/internet-identity#4034](https://github.com/dfinity/internet-identity/pull/4034) -> and reshaped (account-bound delegations) in -> [dfinity/internet-identity#4052](https://github.com/dfinity/internet-identity/pull/4052); -> the on-demand path works once that II build is deployed to the configured -> `II_URL` (the server is built against the same candid contract). This is -> compatible both before and after -> [dfinity/internet-identity#4066](https://github.com/dfinity/internet-identity/pull/4066): -> #4066 leaves these two methods' signatures unchanged (it only drops the -> connect-time account picker and the `account_number` arg on -> `mcp_set_access`/`mcp_access_enabled`, which this server never calls), and -> passing `account_number = null` keeps resolving to the anchor's default -> account, so the same build works against either II version. -> -> **Account listing/selection** (`list_accounts`, the `account` arg) calls II's -> `mcp_get_accounts(target_origin)` query, which — like the -> `mcp_*_account_delegation` methods — recovers the anchor from the caller, so it -> needs no anchor number and works as soon as the same II build is live. +delegation. Omitting `account` uses the default account. + +> **Status:** the connect handshake and the `mcp_register` / `mcp_get_accounts` / +> `mcp_prepare_delegation` / `mcp_get_delegation` canister methods are the +> session-key registration model from +> [dfinity/internet-identity#4086](https://github.com/dfinity/internet-identity/pull/4086) +> (the server is built against that candid contract). #4086 renames the on-demand +> delegation methods from the earlier `mcp_prepare_account_delegation` / +> `mcp_get_account_delegation` and removes `mcp_set_access` / `mcp_access_enabled`. +> The live round-trip works once that II build is deployed to the configured +> `II_URL`. Passing `account_number = null` resolves to the anchor's default +> ("synthetic") account. ## Roadmap - [x] Candid tools over MCP streamable-HTTP; `discover_canisters`; Candid reference resources. -- [x] OpenID/OAuth auth: connecting runs II's `/mcp` delegation flow (backend - public key out, delegation in); verified II delegation; PKCE; expiring tokens. -- [x] On-demand **domain identities**: a 60-min standing II delegation per - connection mints ≤5-min per-app account delegations directly via II canister - methods (`call_canister`/`get_principal` `domain`); no per-app browser flow. +- [x] OAuth auth (device grant, RFC 8628): the client polls while II's `/mcp` + handshake registers the connection's session key (two JSON callback POSTs, + no delegation chain); PKCE; expiring tokens. +- [x] On-demand **domain identities**: the registered session key mints per-app + account delegations directly via II canister methods + (`call_canister`/`get_principal` `domain`); no per-app browser flow. - [x] **Per-app accounts**: `list_accounts(domain)` lists the user's accounts at an app (via `mcp_get_accounts`), and `call_canister`/`get_principal` take an `account` name to act as a specific (non-default) account. -- [ ] Deploy the `mcp_*_account_delegation` + `mcp_get_accounts` canister methods - (server is built against their candid contract; the live round-trip lands - with the II side). +- [ ] Deploy the `mcp_register` + `mcp_get_accounts` + `mcp_prepare_delegation` + + `mcp_get_delegation` canister methods (server is built against #4086's candid + contract; the live round-trip lands with the II side). - [ ] Persist sessions/delegations (currently in-memory, lost on restart). - [ ] Scoped delegations / per-call confirmation for sensitive methods. diff --git a/src/auth.rs b/src/auth.rs index bfcfb84..0511d37 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,14 +1,24 @@ //! OAuth 2.1 authorization server for the MCP endpoint, with **Internet Identity** -//! as the login mechanism. Connecting runs II's `/mcp` delegation flow: the -//! authorize endpoint sends the browser to II with this connection's backend -//! **public** key, and II form-POSTs back a delegation chain `anchor -> backend -//! key` (the 60-minute standing credential). The server verifies the chain (see -//! [`crate::delegation`]) — the chain itself is the proof of identity — stores -//! it, and mints a principal-bound authorization code. No private key is ever -//! transmitted. +//! as the login mechanism, using II's session-key registration handshake. //! -//! Implemented: dynamic client registration, PKCE (S256, enforced), short-lived -//! codes, 1h access tokens, verified principal binding. +//! II's new `/mcp` handshake has NO redirect back to this server — the II tab +//! runs two background `fetch()` POSTs to our callback and then "finishes on its +//! own" (see `docs/mcp-server-guide.md` / dfinity/internet-identity#4086). A +//! classic auth-code `redirect_uri` therefore can't be delivered. We model the +//! MCP client's login as the **OAuth 2.0 Device Authorization Grant (RFC 8628)**: +//! the client asks `/oauth/device_authorization` for a `device_code` + a +//! `verification_uri`, the user opens that URI (which launches II's `/mcp` +//! handshake), and the client polls `/oauth/token` until the grant is live. +//! +//! Connect handshake (Phase 1b): our `/oauth/connect/callback` serves the two +//! cross-origin JSON POSTs II makes — a key request `{state}` → `{public_key}` +//! (a fresh session keypair minted per connection) and a completion notification +//! `{state, expiration}` → mark the grant live. We never receive or verify a +//! delegation chain, and never call `mcp_register` (II's frontend does, under the +//! user's own authentication). +//! +//! Implemented: dynamic client registration, PKCE (S256) bound to the poll, +//! short-lived device codes, 1h access tokens, session-key-bound principal. use std::{ collections::HashMap, @@ -33,27 +43,36 @@ use uuid::Uuid; use crate::identities::Identities; -const CODE_TTL: Duration = Duration::from_secs(120); -/// How long a started connect (pending II `/mcp` round-trip) stays valid. -const CONNECT_TTL: Duration = Duration::from_secs(600); +/// How long a device-authorization request (and its pending II handshake) stays +/// valid before the user must restart. +const DEVICE_CODE_TTL: Duration = Duration::from_secs(600); +/// Minimum seconds a client should wait between token polls (RFC 8628 `interval`). +const POLL_INTERVAL_SECS: u64 = 5; +/// Access-token lifetime (also the II grant's default, 1h). const TOKEN_TTL: Duration = Duration::from_secs(3600); +/// `ttl` (seconds) requested for the II grant. Omitting would default to 3600; +/// we send it explicitly. Clamped by II to [600, 2592000]. +const GRANT_TTL_SECS: u64 = 3600; + +/// RFC 8628 device-code grant type. +const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; /// Public base URL clients use to reach this server. Override with PUBLIC_URL. pub fn base_url() -> String { std::env::var("PUBLIC_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()) } -/// A registered OAuth client (RFC 7591): the redirect URIs it declared. The -/// authorize flow only redirects a code to one of these (exact match), so the -/// server is not an open redirector and needs no hardcoded host allowlist. +/// A registered OAuth client (RFC 7591). Redirect URIs are recorded for +/// completeness but unused: the device grant has no redirect. #[derive(Clone, Debug, Serialize, Deserialize)] struct ClientReg { + #[serde(default)] redirect_uris: Vec, } /// File the dynamic client registrations are persisted to. RFC 7591 clients are /// long-lived (they cache their `client_id`), so registrations must survive a -/// restart — unlike codes/tokens/sessions, which are short-lived and stay in +/// restart — unlike device codes/tokens, which are short-lived and stay in /// memory. Override with `OAUTH_CLIENTS_FILE`. fn clients_file() -> String { std::env::var("OAUTH_CLIENTS_FILE").unwrap_or_else(|_| "oauth-clients.json".to_string()) @@ -65,10 +84,6 @@ fn load_clients() -> HashMap { tracing::warn!("could not parse {}: {e}; starting with no clients", clients_file()); HashMap::new() }), - // No file yet (first run) is normal and silent; a real read error - // (permissions, EIO) is logged loudly — it silently drops previously - // issued client_ids, so it must be diagnosable — but we still start - // (clients can re-register) rather than refuse to boot. Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::new(), Err(e) => { tracing::warn!("could not read {}: {e}; starting with no clients", clients_file()); @@ -91,54 +106,38 @@ fn persist_clients(clients: &HashMap) { } } -/// Acceptance rule for a redirect: loopback (any port, RFC 8252 §7.3) or a URI -/// the client registered (exact match, OAuth 2.1). -fn redirect_allowed(reg: Option<&ClientReg>, redirect_uri: &str) -> bool { - is_loopback_redirect(redirect_uri) - || reg.is_some_and(|c| c.redirect_uris.iter().any(|u| u == redirect_uri)) -} - #[derive(Clone)] pub struct AuthStore { clients: Arc>>, - codes: Arc>>, tokens: Arc>>, - /// Pending connects keyed by the single-use `state` carried through II's - /// `/mcp` flow (set at authorize, consumed at the callback). - connects: Arc>>, - /// Shared with the MCP tools: the connect callback stores the standing - /// credential here, keyed by `session_id`, for the tools to sign with. + /// Device-authorization grants in flight, keyed by `session_id` (which is + /// also the II connect `state`). Holds the `device_code`/`user_code` used by + /// the token poll and verification page. + devices: Arc>>, + /// Shared with the MCP tools: the session's backend key / grant expiration + /// live here (keyed by `session_id`) for the tools to sign with. identities: Identities, } +/// A device-authorization grant awaiting the user's II handshake. #[derive(Clone, Debug)] -struct CodeGrant { - client_id: String, - scope: Option, - /// Verified Internet Identity principal. - principal: String, - /// Session id minted at authorize and carried to the issued token. It keys - /// the connection's per-session backend key, standing II credential, and - /// on-demand per-app account delegations (see `crate::identities`). +struct DeviceAuth { + device_code: String, + user_code: String, + /// The connection's session id; also the II connect `state`. session_id: String, - code_challenge: Option, - created: Instant, -} - -/// A connect started at `/oauth/authorize`, awaiting the delegation II will -/// form-POST back to `/oauth/connect/callback`. -#[derive(Clone, Debug)] -struct PendingConnect { - client_id: String, - redirect_uri: String, + #[allow(dead_code)] + client_id: Option, scope: Option, - /// The OAuth client's own `state`, echoed back on the final redirect. - client_state: String, + /// PKCE challenge (S256), if the client supplied one; verified at token time. code_challenge: Option, - /// The connection's session id (its backend key already exists in - /// `identities`); the standing credential lands here. - session_id: String, created: Instant, + /// Verification page visited and II launched. + launched: bool, + /// Grant confirmed live (completion POST or a signed-call fallback). + live: bool, + /// Last token poll, for RFC 8628 `slow_down` enforcement. + last_poll: Option, } #[derive(Clone, Debug)] @@ -146,167 +145,226 @@ struct TokenInfo { principal: String, session_id: String, created: Instant, + ttl: Duration, } impl AuthStore { pub fn new(identities: Identities) -> Self { Self { clients: Arc::new(RwLock::new(load_clients())), - codes: Arc::default(), tokens: Arc::default(), - connects: Arc::default(), + devices: Arc::default(), identities, } } - /// Whether `redirect_uri` is acceptable for `client_id`. Per OAuth 2.1 the - /// redirect must be one the client registered via DCR (exact match) — that, - /// not a hardcoded host list, is what keeps the server from being an open - /// redirector and lets any registration-compliant client (Claude, ChatGPT, - /// Grok, …) connect without code changes. Loopback is the one exception - /// (RFC 8252 §7.3): native clients bind an ephemeral port at runtime, so - /// any-port loopback is accepted regardless of the registered port. - async fn validate_client(&self, client_id: &str, redirect_uri: &str) -> bool { - redirect_allowed(self.clients.read().await.get(client_id), redirect_uri) - } - /// The verified principal + session id behind a bearer token, if valid. pub async fn session_for_token(&self, token: &str) -> Option<(String, String)> { let tokens = self.tokens.read().await; let info = tokens.get(token)?; - (info.created.elapsed() < TOKEN_TTL).then(|| (info.principal.clone(), info.session_id.clone())) + (info.created.elapsed() < info.ttl).then(|| (info.principal.clone(), info.session_id.clone())) } - } -// ---- Authorize: start the II /mcp delegation flow ---------------------- +// ---- Device authorization: start the II handshake ----------------------- #[derive(Debug, Deserialize)] -pub struct AuthorizeQuery { - #[allow(dead_code)] - response_type: Option, - client_id: String, - redirect_uri: String, +pub struct DeviceAuthzForm { #[serde(default)] - state: Option, + client_id: Option, + #[serde(default)] + scope: Option, #[serde(default)] code_challenge: Option, - #[allow(dead_code)] #[serde(default)] code_challenge_method: Option, - #[serde(default)] - scope: Option, } -/// GET /oauth/authorize — validate the client, mint this connection's session -/// (its backend key), and redirect the browser to II's `/mcp` delegation flow, -/// sending the backend **public** key. II will log the user in, then form-POST -/// the delegation chain back to `/oauth/connect/callback`. -pub async fn authorize(State(store): State, Query(q): Query) -> Response { - if !store.validate_client(&q.client_id, &q.redirect_uri).await { - return oauth_err(StatusCode::BAD_REQUEST, "invalid_request", "unknown client_id / redirect_uri"); +/// POST /oauth/device_authorization (RFC 8628 §3.1–3.2) — mint a device code and +/// point the user at the verification URI that launches II's `/mcp` handshake. +pub async fn device_authorization( + State(store): State, + Form(req): Form, +) -> Response { + // We only verify PKCE with S256; reject any other method up front (rather + // than storing the challenge and failing confusingly with S256 at token + // time), and require a challenge whenever a method is named. + if let Some(method) = &req.code_challenge_method { + if method != "S256" { + return oauth_err(StatusCode::BAD_REQUEST, "invalid_request", "only code_challenge_method=S256 is supported"); + } + if req.code_challenge.is_none() { + return oauth_err(StatusCode::BAD_REQUEST, "invalid_request", "code_challenge required when code_challenge_method is set"); + } } let session_id = format!("sess-{}", Uuid::new_v4()); - let pubkey_b64 = store.identities.session_pubkey_b64(&session_id).await; - - let connect_state = Uuid::new_v4().to_string(); - store.connects.write().await.insert( - connect_state.clone(), - PendingConnect { - client_id: q.client_id.clone(), - redirect_uri: q.redirect_uri.clone(), - scope: q.scope.clone().filter(|s| !s.is_empty()), - client_state: q.state.clone().unwrap_or_default(), - code_challenge: q.code_challenge.clone(), - session_id, + let device_code = format!("dc-{}", Uuid::new_v4()); + // A short, human-typable code for the verification page. + let user_code = Uuid::new_v4().simple().to_string()[..8].to_uppercase(); + + store.devices.write().await.insert( + session_id.clone(), + DeviceAuth { + device_code: device_code.clone(), + user_code: user_code.clone(), + session_id: session_id.clone(), + client_id: req.client_id.clone(), + scope: req.scope.clone().filter(|s| !s.is_empty()), + code_challenge: req.code_challenge.clone(), created: Instant::now(), + launched: false, + live: false, + last_poll: None, }, ); - // II `/mcp` flow: backend public key out, delegation in. No `app` param — - // the connection is to the MCP server itself, whose origin II derives from - // this `callback` URL (used both for the delegation's derivation origin and - // the per-user trust check against the identity's config). `ttl` is 60 min. + let base = base_url(); + let verification_uri = format!("{base}/oauth/device"); + let verification_uri_complete = format!("{verification_uri}?user_code={user_code}"); + Json(json!({ + "device_code": device_code, + "user_code": user_code, + "verification_uri": verification_uri, + "verification_uri_complete": verification_uri_complete, + "expires_in": DEVICE_CODE_TTL.as_secs(), + "interval": POLL_INTERVAL_SECS, + })) + .into_response() +} + +// ---- Verification page: launch II's /mcp handshake ---------------------- + +#[derive(Debug, Deserialize)] +pub struct DeviceVerifyQuery { + #[serde(default)] + user_code: Option, +} + +/// GET /oauth/device — the user opens this (via `verification_uri_complete`). +/// With a valid `user_code` it launches II's `/mcp` handshake; otherwise it +/// offers a minimal form to enter the code. +pub async fn device_verify(State(store): State, Query(q): Query) -> Response { + let Some(user_code) = q.user_code.filter(|c| !c.is_empty()) else { + return device_code_form(); + }; + let user_code = user_code.trim().to_uppercase(); + + let session_id = { + let mut devices = store.devices.write().await; + let entry = devices + .values_mut() + .find(|d| d.user_code == user_code && d.created.elapsed() < DEVICE_CODE_TTL); + match entry { + Some(d) => { + d.launched = true; + d.session_id.clone() + } + None => return connect_error("unknown or expired code — restart the connection from your client"), + } + }; + + // Launch II's `/mcp` handshake. Everything is in the URL fragment (never sent + // to II's servers): the callback on our origin, the single-use `state` (= the + // session id), and the requested grant `ttl` in SECONDS. NO key material is + // put in the link — the session key is minted inside the key-request callback. let base = base_url(); let callback = format!("{base}/oauth/connect/callback"); - // II's `/mcp` reads `ttl` as MINUTES (it converts to ns canister-side), so - // send 60, not the nanosecond value. - let ttl_minutes: u64 = 60; let ii_mcp_url = format!( - "{ii}/mcp#public_key={pk}&callback={cb}&state={st}&ttl={ttl}", + "{ii}/mcp#callback={cb}&state={st}&ttl={ttl}", ii = crate::identities::ii_url(), - pk = urlencoding::encode(&pubkey_b64), cb = urlencoding::encode(&callback), - st = urlencoding::encode(&connect_state), - ttl = ttl_minutes, + st = urlencoding::encode(&session_id), + ttl = GRANT_TTL_SECS, ); js_redirect(&ii_mcp_url) } -// ---- Connect callback: II form-POSTs the delegation chain here --------- +/// A minimal manual code-entry page for the bare `verification_uri`. +fn device_code_form() -> Response { + Html( + "\ +

Connect Internet Identity

\ +
\ +

Enter the code shown by your client:

\ + \ + \ +
" + .to_string(), + ) + .into_response() +} + +// ---- Connect callback: II's two cross-origin JSON POSTs ----------------- #[derive(Debug, Deserialize)] pub struct ConnectCallback { - /// `DelegationChain.toJSON()` for `anchor -> backend session key`. - delegation: String, - /// The single-use connect state set at `/oauth/authorize`. + /// The single-use connect state (= session id) set at device-authorization. state: String, + /// Present only on the completion notification; a decimal string of ns since + /// the epoch (u64 ns overflows JSON numbers, so it is a string on the wire). + #[serde(default)] + expiration: Option, } -/// POST /oauth/connect/callback — verify and store the standing credential, then -/// redirect the browser back to the OAuth client with a principal-bound code. -pub async fn connect_callback( - State(store): State, - Form(form): Form, -) -> Response { - let pending = match store.connects.write().await.remove(&form.state) { - Some(p) if p.created.elapsed() < CONNECT_TTL => p, - Some(_) => return connect_error("connect request expired — reconnect and try again"), - None => return connect_error("unknown or already-used connect request"), - }; - - let principal = match store - .identities - .accept_standing(&pending.session_id, &form.delegation) - .await - { - Ok(p) => p, - Err(e) => return connect_error(&format!("could not accept Internet Identity credential: {e}")), - }; - - let code = format!("mcp-code-{}", Uuid::new_v4()); - store.codes.write().await.insert( - code.clone(), - CodeGrant { - client_id: pending.client_id.clone(), - scope: pending.scope.clone(), - principal: principal.clone(), - session_id: pending.session_id.clone(), - code_challenge: pending.code_challenge.clone(), - created: Instant::now(), - }, - ); - tracing::info!(%principal, "captured standing II credential, issued authorization code"); - - let mut redirect = format!("{}?code={}", pending.redirect_uri, code); - if !pending.client_state.is_empty() { - redirect.push_str(&format!("&state={}", urlencoding::encode(&pending.client_state))); +/// POST /oauth/connect/callback — II's frontend makes TWO cross-origin JSON +/// POSTs here, distinguished by the `expiration` field: +/// (a) key request `{state}` → 200 `{public_key}` (fresh session keypair); +/// (b) completion `{state, expiration}` → mark the grant live; any 2xx. +/// Never returns a redirect (the response is consumed by `fetch()`), and never +/// receives or verifies a delegation chain. +pub async fn connect_callback(State(store): State, Json(body): Json) -> Response { + match &body.expiration { + // (a) Key request — require a valid, unexpired pending connection (reject + // unknown/replayed/expired state with a non-2xx so II aborts), then + // generate (lazily) this connection's session keypair and return its + // public key for II's frontend to register. + None => { + { + let devices = store.devices.read().await; + match devices.get(&body.state) { + Some(d) if d.created.elapsed() < DEVICE_CODE_TTL => {} + _ => { + return (StatusCode::FORBIDDEN, Json(json!({ "error": "invalid_state" }))).into_response() + } + } + } + let public_key = store.identities.session_pubkey_b64(&body.state).await; + (StatusCode::OK, Json(json!({ "public_key": public_key }))).into_response() + } + // (b) Completion notification — best-effort. Update the grant if the + // connection is still known; tolerate a missing/expired state (e.g. the + // grant was already consumed via the signed-call fallback) with a 2xx, so + // a late completion POST doesn't make II treat an otherwise-successful + // connect as failed. Never create a session for an unknown state. + Some(exp) => { + let known = { + let mut devices = store.devices.write().await; + match devices.get_mut(&body.state) { + Some(d) => { + d.live = true; + true + } + None => false, + } + }; + if known { + match exp.trim().parse::() { + Ok(exp_ns) => store.identities.set_grant_expiration(&body.state, exp_ns).await, + // A malformed expiration is non-fatal: the grant may still be + // live (registration happened under the user's auth); a signed + // call is the source of truth. + Err(_) => tracing::warn!("connect completion had unparseable expiration"), + } + } + StatusCode::NO_CONTENT.into_response() + } } - // JS navigation, not a 30x: II form-POSTed here, and `form-action` is enforced - // across redirects, so a `Location` to the client's redirect_uri would be - // blocked. See `js_redirect`. - js_redirect(&redirect) } /// Top-level redirect via a script-initiated navigation (`location.replace`) -/// rather than an HTTP `Location` header. Two reasons: -/// * the II `/mcp` URL carries its params in the fragment (`#…`), which a -/// `Location` redirect drops in some clients; and -/// * the post-connect hop back to the OAuth client must NOT be a 30x response -/// to II's form POST — browsers enforce `form-action` across redirects, so a -/// `Location` to the client's `redirect_uri` (not in II's `form-action`) is -/// blocked. A fresh JS navigation isn't a form submission, so it's exempt. +/// rather than an HTTP `Location` header, so the II `/mcp` URL's fragment (`#…`) +/// is preserved (a `Location` redirect drops it in some clients). fn js_redirect(url: &str) -> Response { let safe = url .replace('\\', "\\\\") @@ -329,37 +387,75 @@ fn connect_error(message: &str) -> Response { .into_response() } -// ---- Token: exchange auth code for an access token --------------------- +// ---- Token: poll for the device grant ----------------------------------- #[derive(Debug, Deserialize)] pub struct TokenForm { grant_type: String, #[serde(default)] - code: String, + device_code: String, #[serde(default)] client_id: String, #[serde(default)] code_verifier: Option, } -/// POST /oauth/token +/// POST /oauth/token (RFC 8628 §3.4–3.5) — the client polls here with the device +/// code until the user finishes the II handshake, then receives the access token. pub async fn token(State(store): State, Form(req): Form) -> Response { - if req.grant_type != "authorization_code" { - return oauth_err(StatusCode::BAD_REQUEST, "unsupported_grant_type", "only authorization_code"); + if req.grant_type != DEVICE_GRANT_TYPE { + return oauth_err(StatusCode::BAD_REQUEST, "unsupported_grant_type", "only the device_code grant"); + } + if req.device_code.is_empty() { + return oauth_err(StatusCode::BAD_REQUEST, "invalid_request", "device_code required"); } - let grant = match store.codes.write().await.remove(&req.code) { - Some(g) if g.created.elapsed() < CODE_TTL => g, - Some(_) => return oauth_err(StatusCode::BAD_REQUEST, "invalid_grant", "code expired"), - None => return oauth_err(StatusCode::BAD_REQUEST, "invalid_grant", "unknown or used code"), + // Locate the device grant, enforce expiry + slow_down, and snapshot what we + // need. Done means: completion POST arrived, or a signed call now succeeds. + let (session_id, code_challenge, scope) = { + let mut devices = store.devices.write().await; + let Some(d) = devices.values_mut().find(|d| d.device_code == req.device_code) else { + return oauth_err(StatusCode::BAD_REQUEST, "invalid_grant", "unknown or used device_code"); + }; + if d.created.elapsed() >= DEVICE_CODE_TTL { + return oauth_err(StatusCode::BAD_REQUEST, "expired_token", "device_code expired — restart"); + } + if !req.client_id.is_empty() { + if let Some(cid) = &d.client_id { + if cid != &req.client_id { + return oauth_err(StatusCode::BAD_REQUEST, "invalid_client", "client_id mismatch"); + } + } + } + // slow_down if polling faster than the advertised interval. + if let Some(last) = d.last_poll { + if last.elapsed() < Duration::from_secs(POLL_INTERVAL_SECS) { + return oauth_err(StatusCode::BAD_REQUEST, "slow_down", "poll no faster than the interval"); + } + } + d.last_poll = Some(Instant::now()); + (d.session_id.clone(), d.code_challenge.clone(), d.scope.clone()) }; - if !req.client_id.is_empty() && req.client_id != grant.client_id { - return oauth_err(StatusCode::BAD_REQUEST, "invalid_client", "client_id mismatch"); + // Determine liveness. Prefer the completion-POST flag; fall back to a cheap + // signed call (the completion POST is best-effort and may never arrive). + let live = { + let flag = store + .devices + .read() + .await + .values() + .find(|d| d.device_code == req.device_code) + .map(|d| d.live) + .unwrap_or(false); + flag || store.identities.grant_is_live(&session_id).await + }; + if !live { + return oauth_err(StatusCode::BAD_REQUEST, "authorization_pending", "waiting for Internet Identity"); } - // Enforce PKCE when a challenge was supplied at authorize time. - if let Some(challenge) = &grant.code_challenge { + // Enforce PKCE if the client bound one at device-authorization time. + if let Some(challenge) = &code_challenge { let verifier = match &req.code_verifier { Some(v) => v, None => return oauth_err(StatusCode::BAD_REQUEST, "invalid_grant", "code_verifier required"), @@ -369,43 +465,36 @@ pub async fn token(State(store): State, Form(req): Form) - } } + // Consume the device grant (single use) and issue the token, bound to the + // session key's principal (self_authenticating(session_pubkey)). + store.devices.write().await.retain(|_, d| d.device_code != req.device_code); + let principal = store + .identities + .session_principal(&session_id) + .await + .unwrap_or_else(|| "unknown".to_string()); + let access_token = format!("mcp-token-{}", Uuid::new_v4()); store.tokens.write().await.insert( access_token.clone(), TokenInfo { - principal: grant.principal.clone(), - session_id: grant.session_id.clone(), + principal: principal.clone(), + session_id, created: Instant::now(), + ttl: TOKEN_TTL, }, ); - tracing::info!(principal = %grant.principal, "issued MCP access token"); + tracing::info!(%principal, "issued MCP access token (device grant)"); - let mut body = json!({ + let mut resp = json!({ "access_token": access_token, "token_type": "Bearer", "expires_in": TOKEN_TTL.as_secs(), }); - if let Some(scope) = grant.scope { - body["scope"] = json!(scope); + if let Some(scope) = scope { + resp["scope"] = json!(scope); } - Json(body).into_response() -} - -/// An `http://` loopback redirect (any port), matched on the parsed **host** so -/// look-alikes can't slip through. Parsing (not `strip_prefix`) is what defends -/// against authority tricks: `http://localhost.evil.com`, `http://localhost@evil.com`, -/// and the userinfo-with-port form `http://localhost:1234@evil.com` all parse to -/// host `evil.com` (or carry userinfo) and are rejected. Userinfo is rejected -/// outright since a legitimate loopback callback never carries credentials. -fn is_loopback_redirect(redirect_uri: &str) -> bool { - let Ok(url) = url::Url::parse(redirect_uri) else { - return false; - }; - url.scheme() == "http" - && url.username().is_empty() - && url.password().is_none() - // host_str() serializes an IPv6 host with brackets ("[::1]"). - && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")) + Json(resp).into_response() } fn pkce_s256(verifier: &str) -> String { @@ -419,18 +508,14 @@ fn pkce_s256(verifier: &str) -> String { pub struct RegisterRequest { #[serde(default)] client_name: Option, + #[serde(default)] redirect_uris: Vec, } -/// POST /oauth/register +/// POST /oauth/register — the device grant needs no redirect, so `redirect_uris` +/// is optional; any supplied are recorded but unused. pub async fn register(State(store): State, Json(req): Json) -> Response { - if req.redirect_uris.is_empty() { - return oauth_err(StatusCode::BAD_REQUEST, "invalid_request", "redirect_uris required"); - } let client_id = format!("client-{}", Uuid::new_v4()); - // Insert under the lock, then persist a snapshot off the lock (and off the - // async runtime thread) so disk I/O never blocks readers like - // `/oauth/authorize`. Registration is infrequent, so the clone is cheap. let snapshot = { let mut clients = store.clients.write().await; clients.insert( @@ -441,19 +526,16 @@ pub async fn register(State(store): State, Json(req): Json, Json(req): Json Response { let base = base_url(); Json(json!({ "issuer": base, - "authorization_endpoint": format!("{base}/oauth/authorize"), + "device_authorization_endpoint": format!("{base}/oauth/device_authorization"), "token_endpoint": format!("{base}/oauth/token"), "registration_endpoint": format!("{base}/oauth/register"), - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code"], + "grant_types_supported": [DEVICE_GRANT_TYPE], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], })) @@ -547,7 +623,7 @@ pub type _JsonValue = Value; #[cfg(test)] mod tests { - use super::{is_loopback_redirect, pkce_s256, redirect_allowed, ClientReg}; + use super::pkce_s256; /// RFC 7636 Appendix B test vector. #[test] @@ -556,43 +632,4 @@ mod tests { let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; assert_eq!(pkce_s256(verifier), expected); } - - #[test] - fn redirect_requires_registration_or_loopback() { - let reg = ClientReg { - redirect_uris: vec![ - "https://grok.com/connector/oauth/cb".to_string(), - "https://claude.ai/api/mcp/auth_callback".to_string(), - ], - }; - // A hosted redirect is accepted iff this client registered it (exact). - assert!(redirect_allowed(Some(®), "https://grok.com/connector/oauth/cb")); - assert!(redirect_allowed(Some(®), "https://claude.ai/api/mcp/auth_callback")); - assert!(!redirect_allowed(Some(®), "https://grok.com/connector/oauth/other")); - assert!(!redirect_allowed(Some(®), "https://claude.ai/api/mcp/auth_callback/x")); - // An unregistered / unknown client can't use a hosted redirect. - assert!(!redirect_allowed(None, "https://grok.com/connector/oauth/cb")); - // Loopback (RFC 8252) is accepted at any port, even unregistered. - assert!(redirect_allowed(None, "http://127.0.0.1:51000/callback")); - assert!(redirect_allowed(None, "http://localhost:1234/cb")); - assert!(redirect_allowed(None, "http://[::1]:8080/cb")); - } - - /// Loopback matching is on the parsed host, so authority tricks (suffix, - /// userinfo, userinfo-with-port) can't redirect a code off-box. - #[test] - fn loopback_rejects_lookalikes() { - assert!(is_loopback_redirect("http://127.0.0.1:51000/callback")); - assert!(is_loopback_redirect("http://localhost/cb")); - assert!(is_loopback_redirect("http://[::1]:8080/cb")); - assert!(!is_loopback_redirect("http://localhost.evil.com/cb")); - assert!(!is_loopback_redirect("http://127.0.0.1.evil.com/cb")); - assert!(!is_loopback_redirect("http://localhost@evil.com/cb")); - // userinfo-with-port bypass: real host is evil.com. - assert!(!is_loopback_redirect("http://localhost:1234@evil.com/cb")); - assert!(!is_loopback_redirect("http://127.0.0.1:5000@evil.com/cb")); - // https is not a loopback scheme; credentials never belong on a callback. - assert!(!is_loopback_redirect("https://localhost/cb")); - assert!(!is_loopback_redirect("https://evil.com/cb")); - } } diff --git a/src/delegation.rs b/src/delegation.rs deleted file mode 100644 index 4290311..0000000 --- a/src/delegation.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Verify an Internet Identity login server-side: a delegation chain (rooted at -//! an II canister-signature key) plus a fresh signature over a server-issued -//! nonce by the leaf session key. -//! -//! This makes the principal the OpenID flow asserts **verified** rather than -//! browser-asserted, so anything keyed on it (per-principal session data, access -//! decisions) is sound. Funds are unaffected either way — that's enforced by the -//! IC at signing time, not here. -//! -//! Verification per delegation: -//! message = 0x1A ++ "ic-request-auth-delegation" ++ repr_independent_hash({pubkey, expiration[, targets]}) -//! signed by the previous key in the chain (root for the first delegation). The -//! root is an II canister signature, checked against the IC root key. The final -//! delegated-to key (the session key) must have signed the nonce. - -use candid::Principal; -use ic_representation_independent_hash::{representation_independent_hash, Value}; -use ic_signature_verification::verify_canister_sig; - -/// DER-encoded IC mainnet root public key (BLS12-381 G2); raw key is the last 96 bytes. -const IC_ROOT_KEY_DER: &[u8; 133] = b"\x30\x81\x82\x30\x1d\x06\x0d\x2b\x06\x01\x04\x01\x82\xdc\x7c\x05\x03\x01\x02\x01\x06\x0c\x2b\x06\x01\x04\x01\x82\xdc\x7c\x05\x03\x02\x01\x03\x61\x00\x81\x4c\x0e\x6e\xc7\x1f\xab\x58\x3b\x08\xbd\x81\x37\x3c\x25\x5c\x3c\x37\x1b\x2e\x84\x86\x3c\x98\xa4\xf1\xe0\x8b\x74\x23\x5d\x14\xfb\x5d\x9c\x0c\xd5\x46\xd9\x68\x5f\x91\x3a\x0c\x0b\x2c\xc5\x34\x15\x83\xbf\x4b\x43\x92\xe4\x67\xdb\x96\xd6\x5b\x9b\xb4\xcb\x71\x71\x12\xf8\x47\x2e\x0d\x5a\x4d\x14\x50\x5f\xfd\x74\x84\xb0\x12\x91\x09\x1c\x5f\x87\xb9\x88\x83\x46\x3f\x98\x09\x1a\x0b\xaa\xae"; - -fn ic_root_key_raw() -> &'static [u8] { - &IC_ROOT_KEY_DER[IC_ROOT_KEY_DER.len() - 96..] -} - -// DER SubjectPublicKeyInfo prefixes for the two basic session-key algorithms -// auth-client uses. Matching the prefix lets us slice out the raw key without a -// full ASN.1 parser. -const ED25519_SPKI_PREFIX: &[u8] = &[ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, -]; -const P256_SPKI_PREFIX: &[u8] = &[ - 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, - 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, -]; - -/// One signed delegation from a chain. -#[derive(Debug, Clone)] -pub struct SignedDelegation { - /// The key this delegation delegates *to* (DER SubjectPublicKeyInfo). - pub pubkey: Vec, - /// Expiration in nanoseconds since the Unix epoch. - pub expiration: u64, - /// Optional canister-id restriction. - pub targets: Option>>, - /// Signature over this delegation by the previous key in the chain. - pub signature: Vec, -} - -/// Verify a delegation chain: each delegation signed by the previous key (root -/// first), the root checked as an II canister signature against the IC root key, -/// none expired. The chain itself is the proof of identity for the connect-time -/// standing credential — possession of the key the chain ends at is proven -/// later, when the backend signs canister calls with it. Returns the verified -/// self-authenticating principal of the chain root (the II `user_key`). -pub fn verify_chain( - root_pubkey_der: &[u8], - delegations: &[SignedDelegation], - now_ns: u64, -) -> Result { - let principal = Principal::self_authenticating(root_pubkey_der); - let mut signer_der = root_pubkey_der.to_vec(); - for d in delegations { - if d.expiration <= now_ns { - return Err("delegation expired".into()); - } - let message = delegation_message(d); - verify_chain_link(&signer_der, &message, &d.signature)?; - signer_der = d.pubkey.clone(); - } - Ok(principal) -} - -fn delegation_message(d: &SignedDelegation) -> Vec { - let mut map = vec![ - ("pubkey".to_string(), Value::Bytes(d.pubkey.clone())), - ("expiration".to_string(), Value::Number(d.expiration)), - ]; - if let Some(targets) = &d.targets { - map.push(( - "targets".to_string(), - Value::Array(targets.iter().map(|t| Value::Bytes(t.clone())).collect()), - )); - } - let hash = representation_independent_hash(&map); - let mut message = b"\x1Aic-request-auth-delegation".to_vec(); - message.extend_from_slice(&hash); - message -} - -/// Verify a delegation's signature. A basic (Ed25519/P-256) signer is verified -/// directly; anything else is treated as an II canister signature and checked -/// against the IC root key. -fn verify_chain_link(signer_der: &[u8], message: &[u8], signature: &[u8]) -> Result<(), String> { - if is_basic_key(signer_der) { - verify_basic_sig(signer_der, message, signature) - } else { - verify_canister_sig(message, signature, signer_der, ic_root_key_raw()) - .map_err(|e| format!("canister signature invalid: {e}")) - } -} - -fn is_basic_key(der: &[u8]) -> bool { - der.starts_with(ED25519_SPKI_PREFIX) || der.starts_with(P256_SPKI_PREFIX) -} - -fn verify_basic_sig(der: &[u8], message: &[u8], signature: &[u8]) -> Result<(), String> { - if let Some(raw) = der.strip_prefix(ED25519_SPKI_PREFIX) { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - let key: [u8; 32] = raw.try_into().map_err(|_| "bad Ed25519 key length")?; - let vk = VerifyingKey::from_bytes(&key).map_err(|e| format!("bad Ed25519 key: {e}"))?; - let sig = Signature::from_slice(signature).map_err(|e| format!("bad Ed25519 sig: {e}"))?; - vk.verify(message, &sig).map_err(|e| format!("Ed25519 verify failed: {e}")) - } else if der.starts_with(P256_SPKI_PREFIX) { - use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; - // The prefix includes the bit-string header and its 0x00 unused-bits byte, - // so the SEC1 uncompressed point (0x04 ++ X ++ Y) starts right after it. - let point = &der[P256_SPKI_PREFIX.len()..]; - let vk = VerifyingKey::from_sec1_bytes(point) - .map_err(|e| format!("bad P-256 key: {e}"))?; - let sig = Signature::from_slice(signature).map_err(|e| format!("bad P-256 sig: {e}"))?; - vk.verify(message, &sig).map_err(|e| format!("P-256 verify failed: {e}")) - } else { - Err("unsupported session key algorithm".into()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn ed25519_der(vk: &ed25519_dalek::VerifyingKey) -> Vec { - [ED25519_SPKI_PREFIX, vk.as_bytes()].concat() - } - fn p256_der(vk: &p256::ecdsa::VerifyingKey) -> Vec { - let point = vk.to_encoded_point(false); // 0x04 ++ X ++ Y - [P256_SPKI_PREFIX, point.as_bytes()].concat() - } - - /// Ed25519 root delegates to a P-256 session key. Exercises the chain-link - /// (basic Ed25519) path and derives the principal from the root. - #[test] - fn verifies_valid_chain_and_derives_principal() { - use ed25519_dalek::{Signer as _, SigningKey}; - use p256::ecdsa::SigningKey as P256Key; - - let root = SigningKey::from_bytes(&[7u8; 32]); - let root_der = ed25519_der(&root.verifying_key()); - let session = P256Key::from_bytes(&[9u8; 32].into()).unwrap(); - let session_der = p256_der(session.verifying_key()); - - let now_ns = 1_000_000_000_000u64; - let deleg = SignedDelegation { - pubkey: session_der, - expiration: now_ns + 3_600_000_000_000, - targets: None, - signature: vec![], // filled below - }; - let msg = delegation_message(&deleg); - let root_sig = root.sign(&msg).to_bytes().to_vec(); - let deleg = SignedDelegation { signature: root_sig, ..deleg }; - - let principal = verify_chain(&root_der, &[deleg], now_ns).expect("should verify"); - assert_eq!(principal, Principal::self_authenticating(&root_der)); - } - - #[test] - fn rejects_tampered_delegation_signature() { - use ed25519_dalek::{Signer as _, SigningKey}; - let root = SigningKey::from_bytes(&[1u8; 32]); - let root_der = ed25519_der(&root.verifying_key()); - let session = SigningKey::from_bytes(&[2u8; 32]); - let session_der = ed25519_der(&session.verifying_key()); - let now_ns = 1_000_000_000_000u64; - let mut deleg = SignedDelegation { - pubkey: session_der, - expiration: now_ns + 1_000_000_000, - targets: None, - signature: vec![], - }; - // A signature over a *different* delegation must not validate this one. - let other = SignedDelegation { expiration: now_ns + 2, ..deleg.clone() }; - deleg.signature = root.sign(&delegation_message(&other)).to_bytes().to_vec(); - assert!(verify_chain(&root_der, &[deleg], now_ns).is_err()); - } - - #[test] - fn rejects_expired_delegation() { - use ed25519_dalek::{Signer as _, SigningKey}; - let root = SigningKey::from_bytes(&[3u8; 32]); - let root_der = ed25519_der(&root.verifying_key()); - let session = SigningKey::from_bytes(&[4u8; 32]); - let session_der = ed25519_der(&session.verifying_key()); - let now_ns = 2_000_000_000_000u64; - let mut deleg = SignedDelegation { - pubkey: session_der, - expiration: now_ns - 1, // already expired - targets: None, - signature: vec![], - }; - deleg.signature = root.sign(&delegation_message(&deleg)).to_bytes().to_vec(); - assert!(verify_chain(&root_der, &[deleg], now_ns).is_err()); - } -} diff --git a/src/identities.rs b/src/identities.rs index ceb225d..4410c0d 100644 --- a/src/identities.rs +++ b/src/identities.rs @@ -1,23 +1,27 @@ -//! On-demand per-app delegated identities. +//! On-demand per-app delegated identities — **session-key registration model**. //! -//! Model: at connect/OpenID time the MCP backend obtains a **60-minute standing -//! delegation** from Internet Identity — a chain `anchor -> backend session key` -//! issued for the MCP origin. The backend holds an Ed25519 **session key per -//! authenticated MCP (OpenID) session**; the standing delegation ends at that -//! key, so the backend can sign as the anchor's MCP-origin principal. +//! Model (Internet Identity MCP connect, per `docs/mcp-server-guide.md` and +//! dfinity/internet-identity#4086): at connect time the server generates a fresh +//! Ed25519 **session key per user-connection** (inside the key-request callback) +//! and returns only its public key to II's frontend, which registers it with the +//! II canister via `mcp_register` — under the user's own authentication — as a +//! time-boxed **grant** bound to the user's anchor. The server never handles a +//! delegation chain that represents itself, and never calls `mcp_register`. The +//! session key's principal `self_authenticating(session_pubkey)` IS the identity +//! the grant is bound to. //! //! To call a canister as the user's account for a given app (e.g. `oisy.com`) -//! the backend mints a **short-lived (<=5 min) per-app account delegation ON -//! DEMAND**: signing AS the standing identity, it calls II's -//! `mcp_prepare_account_delegation` / `mcp_get_account_delegation` directly with -//! the app's target origin and the backend session key as `session_key`. The -//! returned chain ends at the backend session key, so the backend can sign -//! canister calls as that per-app identity with `ic-agent`'s `DelegatedIdentity`. -//! There is no per-app browser sign-in flow. +//! the server mints a **short-lived per-app account delegation ON DEMAND**: +//! signing the II calls DIRECTLY with the session key, it calls II's +//! `mcp_get_accounts` / `mcp_prepare_delegation` / `mcp_get_delegation`, passing a +//! fresh **per-app key** as the `session_key` argument. The returned delegation +//! is issued to that per-app key, so the server acts as the user's app account +//! via a `DelegatedIdentity` over the chain `[user_key -> per-app key]`. There is +//! no per-app browser sign-in flow. //! //! The derived `(user_key, chain, expiration)` is cached per `(session_id, -//! domain)` with a TTL slightly under the delegation's expiration; it is reused -//! until near-expiry, then re-derived. +//! domain, account_number)` with a margin under the delegation's expiration; it +//! is reused until near-expiry, then re-derived. use std::{ collections::HashMap, @@ -37,13 +41,6 @@ use tokio::sync::RwLock; /// Public IC API boundary node the II canister calls are made against. const IC_URL: &str = "https://icp-api.io"; -/// Requested lifetime of an on-demand app delegation: 5 minutes, in -/// **nanoseconds** — the unit of the contract's `max_ttl` arg (II caps it at its -/// own 5-minute `MCP_MAX_EXPIRATION_PERIOD_NS`). Not to be confused with the -/// browser `/mcp` flow's `ttl`, which is in minutes. The cache TTL is set -/// slightly under the returned expiration. -const APP_DELEGATION_TTL_NS: u64 = 5 * 60 * 1_000_000_000; - /// Re-derive once the cached delegation is within this margin of expiry, so a /// call never goes out with an about-to-expire delegation. const REDERIVE_MARGIN_NS: u64 = 30 * 1_000_000_000; @@ -51,14 +48,22 @@ const REDERIVE_MARGIN_NS: u64 = 30 * 1_000_000_000; /// Internet Identity instance, single source of truth. Default: **`beta.id.ai`**. /// A real domain is required: the raw `.icp0.io` origin is rate-limited /// (HTTP 429) for the browser login SPA, leaving the II popup blank. Used for the -/// connect-time `/mcp` delegation flow (browser). Override with `II_URL`. +/// connect-time `/mcp` handshake (browser). Override with `II_URL`. const II_URL_DEFAULT: &str = "https://beta.id.ai"; -/// Canister id of that same II instance, used for the on-demand -/// account-delegation calls (`mcp_*_account_delegation`). Default is the -/// `beta.id.ai` canister. Override with `II_CANISTER_ID`. +/// Canister id of that same II instance, used for the on-demand account +/// delegation calls (`mcp_get_accounts` / `mcp_prepare_delegation` / +/// `mcp_get_delegation`). Default is the `beta.id.ai` canister. Override with +/// `II_CANISTER_ID`. const II_CANISTER_ID_DEFAULT: &str = "fgte5-ciaaa-aaaad-aaatq-cai"; +/// Message shown whenever II reports the grant is gone (`Unauthorized`) or the +/// stored grant expiration has passed. Per the spec, any `Unauthorized` means the +/// session is over — the caller must start a fresh connect with a fresh session +/// key; it must NOT retry. +const RECONNECT_MSG: &str = "Your Internet Identity session is over (the grant expired, was revoked, \ + or was replaced by a newer connection). Reconnect with Internet Identity to continue — do not retry."; + /// Origin of the II instance (no trailing slash). Override with `II_URL`. pub fn ii_url() -> String { let raw = std::env::var("II_URL").unwrap_or_else(|_| II_URL_DEFAULT.to_string()); @@ -94,35 +99,33 @@ fn target_origin(domain: &str) -> String { struct Session { /// Ed25519 session-key seed; rebuild a `BasicIdentity` from it on demand. - /// This is the backend session key the standing delegation ends at, and the - /// `session_key` sent to II so app delegations end at it too. Its private key - /// never leaves the backend — only its public key is ever sent to II. + /// This is the key generated for this connection and registered with II (via + /// the frontend's `mcp_register`); the server signs II's `mcp_*` calls + /// directly with it. Its private half never leaves the backend — only its + /// public key is ever sent to II. key_seed: [u8; 32], /// DER public key of the session key. pubkey_der: Vec, - /// The connect-time 60-minute standing delegation, captured via the II `/mcp` - /// flow (`anchor -> this session key`, issued for the MCP origin). `None` - /// until the connect flow completes. - standing: Option, + /// The grant's expiration (ns since the Unix epoch), as reported by the + /// completion-notification callback POST. `None` until (or unless) that + /// best-effort POST arrives; a missing value is not treated as expired — an + /// `Unauthorized` from a signed call is the authoritative "session over" + /// signal. + grant_expiration_ns: Option, /// `(domain, account_number)` -> most recently derived per-app delegation. /// Keyed by account too, since each account at an origin signs as a distinct /// principal (`account_number == None` is that origin's default account). app_delegations: HashMap<(String, Option), AppDelegation>, } -/// The connect-time standing credential: a chain `anchor -> backend session key` -/// the backend signs II's account-derivation calls with. -struct Standing { - user_key: Vec, - chain: Vec, - expiration_ns: u64, -} - -/// A cached on-demand per-app account delegation. +/// A cached on-demand per-app account delegation. The chain ends at a per-app key +/// distinct from the session key; its seed is kept so the identity can be rebuilt. struct AppDelegation { user_key: Vec, chain: Vec, expiration_ns: u64, + /// Ed25519 seed of the per-app key the delegation is issued to. + app_key_seed: [u8; 32], } impl AppDelegation { @@ -133,7 +136,7 @@ impl AppDelegation { } /// One of the user's Internet Identity accounts at an app origin, as returned by -/// [`Identities::list_accounts`] (II's `get_accounts`). Each account is a +/// [`Identities::list_accounts`] (II's `mcp_get_accounts`). Each account is a /// distinct per-origin principal; `account_number == None`/`name == None` is the /// origin's default ("synthetic") account, which every user has automatically. pub struct AccountInfo { @@ -160,31 +163,27 @@ impl Identities { async fn ensure_session(&self, session_id: &str) { let mut sessions = self.sessions.write().await; sessions.entry(session_id.to_string()).or_insert_with(|| { - let mut seed = [0u8; 32]; - getrandom::fill(&mut seed).expect("getrandom"); - let pubkey_der = BasicIdentity::from_raw_key(&seed) - .public_key() - .expect("ed25519 public key"); + let (key_seed, pubkey_der) = fresh_ed25519(); Session { - key_seed: seed, + key_seed, pubkey_der, - standing: None, + grant_expiration_ns: None, app_delegations: HashMap::new(), } }); } - /// The backend session key (its DER pubkey) and a `BasicIdentity` over it. + /// The backend session key seed and its DER pubkey. async fn session_key(&self, session_id: &str) -> Option<([u8; 32], Vec)> { let sessions = self.sessions.read().await; let s = sessions.get(session_id)?; Some((s.key_seed, s.pubkey_der.clone())) } - /// Ensure a session exists and return its backend **public** key (base64url, - /// no pad). This is the only thing sent to II — as `public_key` in the `/mcp` - /// delegation flow, so the standing delegation ends at this key and the - /// backend can sign with the private half it never discloses. + /// Ensure a session exists and return its session **public** key (base64url, + /// no pad, DER). This is what the key-request callback POST returns to II's + /// frontend, which registers it as the grant. Its private half never leaves + /// the backend. pub async fn session_pubkey_b64(&self, session_id: &str) -> String { self.ensure_session(session_id).await; let sessions = self.sessions.read().await; @@ -192,90 +191,80 @@ impl Identities { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der) } - /// Accept the connect-time standing delegation chain (the - /// `DelegationChain.toJSON()` II form-POSTs back from the `/mcp` flow): parse - /// it, verify it (II canister signature at the root, none expired), require - /// it to end at this session's backend key, and store it. Returns the - /// verified principal (`self_authenticating(user_key)` = `derive(anchor, - /// mcp_origin)`, the caller the II account methods recover the anchor from). - pub async fn accept_standing( - &self, - session_id: &str, - chain_json: &str, - ) -> Result { - self.ensure_session(session_id).await; - let (_, pubkey_der) = self.session_key(session_id).await.ok_or("session vanished")?; - - let parsed = parse_chain_json(chain_json)?; - let principal = crate::delegation::verify_chain(&parsed.user_key, &parsed.verify_chain, now_ns())?; - - let leaf = parsed.agent_chain.last().ok_or("empty delegation chain")?; - if leaf.delegation.pubkey != pubkey_der { - return Err("standing delegation does not end at this connection's key".into()); - } + /// The session's principal (`self_authenticating(session_pubkey)`), the + /// identity the II grant is bound to. Used for logging/attribution. + pub async fn session_principal(&self, session_id: &str) -> Option { + let (_, der) = self.session_key(session_id).await?; + Some(Principal::self_authenticating(&der).to_text()) + } + /// Record the grant's expiration reported by the completion-notification + /// callback POST (`{state, expiration}`). The value is nanoseconds since the + /// epoch (a decimal string on the wire, parsed before it reaches here). + pub async fn set_grant_expiration(&self, session_id: &str, expiration_ns: u64) { + self.ensure_session(session_id).await; let mut sessions = self.sessions.write().await; if let Some(s) = sessions.get_mut(session_id) { - s.standing = Some(Standing { - user_key: parsed.user_key, - chain: parsed.agent_chain, - expiration_ns: parsed.max_exp, - }); + s.grant_expiration_ns = Some(expiration_ns); } - Ok(principal.to_text()) } - /// The connect-time **60-minute standing delegation** (`anchor -> backend - /// session key`, issued for the MCP origin), as an `ic-agent` identity the - /// backend signs II's account-derivation calls with. The backend's private - /// key signs locally; it is never transmitted. - /// - /// Public so the canister-management tools can act as this stable per-user - /// principal (the user's controller/funder identity). - pub async fn standing_identity(&self, session_id: &str) -> Result { + /// Build the plain session-key identity (`BasicIdentity`) the server signs + /// II's `mcp_*` calls with. Errors early if the stored grant expiration has + /// passed (a missing expiration is not treated as expired — the spec says to + /// fall back to attempting the signed call, where an `Unauthorized` is the + /// authoritative signal). + async fn session_signer(&self, session_id: &str) -> Result { + self.ensure_session(session_id).await; let sessions = self.sessions.read().await; let s = sessions.get(session_id).ok_or("no such session")?; - let standing = s.standing.as_ref().ok_or( - "no standing Internet Identity credential for this connection — reconnect and sign \ - in with Internet Identity", - )?; - if standing.expiration_ns <= now_ns() { - return Err( - "this connection's Internet Identity credential has expired (~60 min) — \ - reconnect to sign in again" - .to_string(), - ); + if let Some(exp) = s.grant_expiration_ns { + if exp <= now_ns() { + return Err(RECONNECT_MSG.to_string()); + } } - let key = BasicIdentity::from_raw_key(&s.key_seed); - DelegatedIdentity::new(standing.user_key.clone(), Box::new(key), standing.chain.clone()) - .map_err(|e| format!("invalid standing delegation chain: {e}")) + Ok(BasicIdentity::from_raw_key(&s.key_seed)) + } + + /// An `ic-agent` pointed at mainnet II, signing as this connection's session + /// key. This is the caller II recovers the anchor from for the `mcp_*` calls. + async fn session_agent(&self, session_id: &str) -> Result { + let signer = self.session_signer(session_id).await?; + Agent::builder() + .with_url(IC_URL) + .with_identity(signer) + .build() + .map_err(|e| format!("could not build II agent: {e}")) + } + + /// A stable per-user identity for the canister-management tools — the user's + /// default account at *this* MCP server's own origin, derived on demand like + /// any other app. Its principal (`self_authenticating(user_key)`) is stable + /// across reconnects (unlike the ephemeral session key), so it works as the + /// user's controller/funder identity. + pub async fn management_identity(&self, session_id: &str) -> Result { + let origin = crate::auth::base_url(); + self.delegated_identity(session_id, &origin, None).await } /// List the user's Internet Identity accounts at an app `domain`, via II's - /// `mcp_get_accounts(target_origin)` signed as the standing identity. II - /// recovers the anchor from the caller (the connect-time MCP-origin - /// principal), so no anchor number is needed. Every user has a default - /// ("synthetic") account (`account_number == None`, no name) at any origin, - /// plus any named accounts they created there; each is a distinct per-origin - /// principal. Requires the standing credential. + /// `mcp_get_accounts(target_origin)` signed as the session key. II recovers + /// the anchor from the caller (the registered session-key principal), so no + /// anchor number is needed. Every user has a default ("synthetic") account + /// (`account_number == None`, no name) at any origin, plus any named accounts + /// they created there; each is a distinct per-origin principal. pub async fn list_accounts( &self, session_id: &str, domain: &str, ) -> Result, String> { - self.ensure_session(session_id).await; - let standing = self.standing_identity(session_id).await?; - let agent = Agent::builder() - .with_url(IC_URL) - .with_identity(standing) - .build() - .map_err(|e| format!("could not build II agent: {e}"))?; + let agent = self.session_agent(session_id).await?; let canister = ii_canister_id()?; let origin = target_origin(domain); // mcp_get_accounts(target_origin) -> variant { Ok: vec AccountInfo; Err } - // A signed query: II recovers the anchor from the caller (the standing - // identity) and returns that anchor's accounts at `target_origin`. + // A signed query: II recovers the anchor from the caller (the session key) + // and returns that anchor's accounts at `target_origin`. let arg = Encode!(&origin).map_err(|e| format!("could not encode mcp_get_accounts args: {e}"))?; let reply = agent .query(&canister, "mcp_get_accounts") @@ -285,7 +274,7 @@ impl Identities { .map_err(|e| format!("mcp_get_accounts failed: {e}"))?; let accounts = Decode!(&reply, McpGetAccountsReply) .map_err(|e| format!("could not decode mcp_get_accounts reply: {e}"))? - .map_err(|e| format!("II refused mcp_get_accounts: {e:?}"))?; + .map_err(map_delegation_error)?; Ok(accounts .into_iter() @@ -297,6 +286,16 @@ impl Identities { .collect()) } + /// Whether the grant is currently usable: a signed `mcp_get_accounts` for the + /// MCP origin succeeds. Used by the device-flow poll as a best-effort "the + /// user has finished connecting" signal, since the completion-notification + /// POST is best-effort and may never arrive. + pub async fn grant_is_live(&self, session_id: &str) -> bool { + self.list_accounts(session_id, &crate::auth::base_url()) + .await + .is_ok() + } + /// Resolve an optional account `name` at `domain` to its account number /// (`None` = the default account, used when `name` is `None`). Looks the name /// up via [`Self::list_accounts`]; errors if no account (or more than one) at @@ -350,12 +349,12 @@ impl Identities { // Reuse a cached, still-fresh delegation if present. if let Some(app) = self.cached_fresh(session_id, domain, account_number).await { - return self.build_identity(session_id, &app).await; + return build_identity(&app); } // Otherwise derive a fresh one on demand against the II canister. let app = self.derive_app_delegation(session_id, domain, account_number).await?; - let identity = self.build_identity(session_id, &app).await?; + let identity = build_identity(&app)?; self.store(session_id, domain, account_number, app).await; Ok(identity) } @@ -378,6 +377,7 @@ impl Identities { user_key: app.user_key.clone(), chain: app.chain.clone(), expiration_ns: app.expiration_ns, + app_key_seed: app.app_key_seed, }) } @@ -388,25 +388,9 @@ impl Identities { } } - /// Build a `DelegatedIdentity` for a derived app delegation: the chain ends - /// at the backend session key, so the backend `BasicIdentity` signs. - async fn build_identity( - &self, - session_id: &str, - app: &AppDelegation, - ) -> Result { - let (seed, _) = self - .session_key(session_id) - .await - .ok_or("session vanished")?; - let key = BasicIdentity::from_raw_key(&seed); - DelegatedIdentity::new(app.user_key.clone(), Box::new(key), app.chain.clone()) - .map_err(|e| format!("invalid delegation chain: {e}")) - } - /// Derive a fresh per-app account delegation by calling II's - /// `mcp_prepare_account_delegation` then `mcp_get_account_delegation`, AS the - /// standing identity, with the backend session key as `session_key`. + /// `mcp_prepare_delegation` then `mcp_get_delegation`, SIGNED AS the session + /// key, passing a fresh **per-app key** as the `session_key` argument. /// `account_number == None` selects the origin's default account. async fn derive_app_delegation( &self, @@ -414,189 +398,115 @@ impl Identities { domain: &str, account_number: Option, ) -> Result { - let (_, session_key_der) = self - .session_key(session_id) - .await - .ok_or("session vanished")?; let origin = target_origin(domain); let canister = ii_canister_id()?; - // Call II AS the standing delegation identity (the anchor's MCP-origin - // principal) — that's the caller II requires for account derivation. - let standing = self.standing_identity(session_id).await?; - let agent = Agent::builder() - .with_url(IC_URL) - .with_identity(standing) - .build() - .map_err(|e| format!("could not build II agent: {e}"))?; + // The per-app key B the delegation is issued to — distinct from the + // session key. Its DER pubkey is the `session_key` argument to + // prepare/get; the returned chain ends at B, so the server signs canister + // calls at the app as B via a DelegatedIdentity over [user_key -> B]. + let (app_key_seed, app_key_der) = fresh_ed25519(); - // mcp_prepare_account_delegation(target_origin, opt account_number, session_key, opt max_ttl) + // Call II SIGNED AS the session key (the registered grant principal) — + // that's the caller II recovers the anchor from. + let agent = self.session_agent(session_id).await?; + + // mcp_prepare_delegation(target_origin, opt account_number, session_key, opt max_ttl) // -> variant { Ok: McpPrepareDelegation; Err: AccountDelegationError } - // `max_ttl` is in nanoseconds (we pass APP_DELEGATION_TTL_NS = 5 min). - // `account_number = null` selects the anchor's default account at - // `target_origin`; `Some(n)` selects a specific named account (its number - // resolved from `list_accounts`). The default is mutable, so II resolves - // the request to a concrete account at prepare time and returns it in the - // reply; we thread that resolved account into `mcp_get_account_delegation` - // below so `get` reads the same account `prepare` signed for. - // - // Threading the *returned* account (rather than re-sending our request) is - // what keeps this working across II versions: the two methods' signatures - // are identical in dfinity/internet-identity#4052 and #4066, and `null` - // resolves to the anchor's default account in both (synthetic fallback in - // #4052, `default_account_number()` in #4066 — both authorized, neither - // errors). #4066 only drops the connect-time account picker and the - // `account_number` arg on `mcp_set_access`/`mcp_access_enabled`, none of - // which this server calls. - let prepare_arg = Encode!( - &origin, - &account_number, - &session_key_der, - &Some(APP_DELEGATION_TTL_NS) - ) - .map_err(|e| format!("could not encode prepare args: {e}"))?; + // `session_key` is the PER-APP key's DER pubkey. `max_ttl = null` uses + // II's default (<= 1 hour, and never past the grant). `account_number = + // null` selects the anchor's default account at `target_origin`; `Some(n)` + // selects a specific named account. The default is mutable, so II resolves + // the request to a concrete account at prepare time and returns it; we + // thread that resolved account into `mcp_get_delegation` so `get` reads the + // same account `prepare` signed for. + let prepare_arg = Encode!(&origin, &account_number, &app_key_der, &None::) + .map_err(|e| format!("could not encode prepare args: {e}"))?; let prepared = agent - .update(&canister, "mcp_prepare_account_delegation") + .update(&canister, "mcp_prepare_delegation") .with_arg(prepare_arg) .call_and_wait() .await - .map_err(|e| format!("mcp_prepare_account_delegation failed: {e}"))?; + .map_err(|e| format!("mcp_prepare_delegation failed: {e}"))?; let prepared = Decode!(&prepared, PrepareReply) .map_err(|e| format!("could not decode prepare reply: {e}"))? - .map_err(|e| format!("II refused mcp_prepare_account_delegation: {e:?}"))?; - - // mcp_get_account_delegation(target_origin, opt account_number, session_key, expiration) - // -> variant { Ok: SignedDelegation; Err: AccountDelegationError } - // Thread the account `prepare` resolved to, so `get` reads the same one - // (the default account at `target_origin` is mutable between the calls). - let get_arg = Encode!( - &origin, - &prepared.account_number, - &session_key_der, - &prepared.expiration - ) - .map_err(|e| format!("could not encode get args: {e}"))?; + .map_err(map_delegation_error)?; + + // mcp_get_delegation(target_origin, opt account_number, session_key, expiration) + // -> variant { Ok: SignedDelegation; Err: AccountDelegationError } query + // Thread the account + expiration `prepare` returned VERBATIM, or II + // returns NoSuchDelegation (the default account is mutable between calls). + let get_arg = Encode!(&origin, &prepared.account_number, &app_key_der, &prepared.expiration) + .map_err(|e| format!("could not encode get args: {e}"))?; let got = agent - .query(&canister, "mcp_get_account_delegation") + .query(&canister, "mcp_get_delegation") .with_arg(get_arg) .call() .await - .map_err(|e| format!("mcp_get_account_delegation failed: {e}"))?; + .map_err(|e| format!("mcp_get_delegation failed: {e}"))?; let signed = Decode!(&got, GetReply) .map_err(|e| format!("could not decode get reply: {e}"))? - .map_err(|e| format!("II refused account delegation: {e:?}"))?; + .map_err(map_delegation_error)?; - let chain = vec![signed.into_agent(&session_key_der)?]; + let chain = vec![signed.into_agent(&app_key_der)?]; Ok(AppDelegation { user_key: prepared.user_key, chain, expiration_ns: prepared.expiration, + app_key_seed, }) } } -// ---- Standing-credential chain parsing (DelegationChain.toJSON()) ---------- - -/// A delegation chain parsed into both representations we need: `agent_chain` -/// for `ic-agent` (signing), `verify_chain` for [`crate::delegation`] (checking). -struct ParsedChain { - user_key: Vec, - agent_chain: Vec, - verify_chain: Vec, - max_exp: u64, +/// Generate a fresh Ed25519 keypair; return its seed and DER SubjectPublicKeyInfo. +fn fresh_ed25519() -> ([u8; 32], Vec) { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("getrandom"); + let pubkey_der = BasicIdentity::from_raw_key(&seed) + .public_key() + .expect("ed25519 public key"); + (seed, pubkey_der) } -#[derive(Deserialize)] -struct ChainJson { - #[serde(rename = "publicKey")] - public_key: String, - delegations: Vec, -} -#[derive(Deserialize)] -struct SignedDelJson { - delegation: DelJson, - signature: String, -} -#[derive(Deserialize)] -struct DelJson { - pubkey: String, - /// Nanoseconds since epoch, hex-encoded (agent-js bigint form). - expiration: String, - #[serde(default)] - targets: Option>, +/// Build a `DelegatedIdentity` for a derived app delegation: the chain ends at +/// the per-app key, so a `BasicIdentity` over that key's seed signs. +fn build_identity(app: &AppDelegation) -> Result { + let key = BasicIdentity::from_raw_key(&app.app_key_seed); + DelegatedIdentity::new(app.user_key.clone(), Box::new(key), app.chain.clone()) + .map_err(|e| format!("invalid delegation chain: {e}")) } -/// Parse `DelegationChain.toJSON()` (all hex) into [`ParsedChain`]. -fn parse_chain_json(json: &str) -> Result { - let chain: ChainJson = - serde_json::from_str(json).map_err(|e| format!("bad delegation JSON: {e}"))?; - let user_key = hex::decode(&chain.public_key).map_err(|_| "bad publicKey hex")?; - let mut agent_chain = Vec::with_capacity(chain.delegations.len()); - let mut verify_chain = Vec::with_capacity(chain.delegations.len()); - let mut max_exp = 0u64; - for d in chain.delegations { - let expiration = u64::from_str_radix(d.delegation.expiration.trim_start_matches("0x"), 16) - .map_err(|_| "bad expiration")?; - max_exp = max_exp.max(expiration); - let pubkey = hex::decode(&d.delegation.pubkey).map_err(|_| "bad delegation pubkey hex")?; - let signature = hex::decode(&d.signature).map_err(|_| "bad delegation signature hex")?; - let targets_hex = d.delegation.targets; - let agent_targets = match &targets_hex { - Some(ts) => Some( - ts.iter() - .map(|t| hex::decode(t).map(|b| Principal::from_slice(&b))) - .collect::, _>>() - .map_err(|_| "bad target hex")?, - ), - None => None, - }; - let verify_targets = match &targets_hex { - Some(ts) => Some( - ts.iter() - .map(|t| hex::decode(t).map_err(|_| "bad target hex".to_string())) - .collect::, _>>()?, - ), - None => None, - }; - agent_chain.push(SignedDelegation { - delegation: Delegation { - pubkey: pubkey.clone(), - expiration, - targets: agent_targets, - }, - signature: signature.clone(), - }); - verify_chain.push(crate::delegation::SignedDelegation { - pubkey, - expiration, - targets: verify_targets, - signature, - }); +/// Render an `AccountDelegationError` as an actionable message. Any `Unauthorized` +/// means the grant is gone → reconnect (do not retry). +fn map_delegation_error(e: AccountDelegationError) -> String { + match e { + AccountDelegationError::Unauthorized(_) => RECONNECT_MSG.to_string(), + AccountDelegationError::NoSuchDelegation => { + "Internet Identity returned NoSuchDelegation — the prepared account/expiration were not \ + threaded through. Retry the request." + .to_string() + } + AccountDelegationError::InternalCanisterError(t) => { + format!("Internet Identity internal error: {t}") + } } - Ok(ParsedChain { - user_key, - agent_chain, - verify_chain, - max_exp, - }) } -// ---- II candid contract for the mcp_*_account_delegation methods ---- +// ---- II candid contract for the mcp_* delegation methods -------------------- -/// `Ok` payload of `mcp_prepare_account_delegation` (II `McpPrepareDelegation`). +/// `Ok` payload of `mcp_prepare_delegation` (II `McpPrepareDelegation`). #[derive(CandidType, Deserialize)] struct PreparedDelegation { user_key: Vec, /// The account II resolved the request to (`opt AccountNumber`, `null` = - /// the default account at `target_origin`). The default is mutable, so it's - /// resolved here and threaded back into `mcp_get_account_delegation` so both - /// calls sign for the same account. + /// the default account at `target_origin`). Threaded back into + /// `mcp_get_delegation` so both calls sign for the same account. account_number: Option, expiration: u64, } -/// II's `AccountDelegationError` — the `Err` arm of both methods. We only need -/// to decode and display it. +/// II's `AccountDelegationError` — the `Err` arm of the delegation methods. We +/// only need to decode and act on it. #[derive(CandidType, Deserialize, Debug)] enum AccountDelegationError { InternalCanisterError(String), @@ -604,16 +514,15 @@ enum AccountDelegationError { NoSuchDelegation, } -// Both methods return `variant { Ok; Err }`, i.e. a Rust `Result`. Aliased so the +// The methods return `variant { Ok; Err }`, i.e. a Rust `Result`. Aliased so the // `Decode!` macro doesn't choke on the comma inside the generic. type PrepareReply = std::result::Result; type GetReply = std::result::Result; - -// ---- II candid contract for mcp_get_accounts -------------------------------- +type McpGetAccountsReply = std::result::Result, AccountDelegationError>; /// One of an anchor's accounts at an origin (II `AccountInfo`). Decoded by name, -/// so field order is irrelevant; the wire record's `origin` field is skipped (we -/// already know the origin we queried). +/// so field order is irrelevant and the wire record's `origin` field is skipped +/// (we already know the origin we queried). #[derive(CandidType, Deserialize)] struct IiAccountInfo { account_number: Option, @@ -621,12 +530,7 @@ struct IiAccountInfo { name: Option, } -// `mcp_get_accounts` returns `variant { Ok: vec AccountInfo; Err: AccountDelegationError }` -// (the same `Err` type as the delegation methods). Aliased so the `Decode!` macro -// doesn't choke on the comma inside the generic. -type McpGetAccountsReply = std::result::Result, AccountDelegationError>; - -/// One delegation as returned by II's `mcp_get_account_delegation`. +/// One delegation as returned by II's `mcp_get_delegation`. #[derive(CandidType, Deserialize)] struct IiDelegation { pubkey: Vec, @@ -634,7 +538,7 @@ struct IiDelegation { targets: Option>, } -/// `SignedDelegation` as returned by II's `mcp_get_account_delegation`. +/// `SignedDelegation` as returned by II's `mcp_get_delegation`. #[derive(CandidType, Deserialize)] struct IiSignedDelegation { delegation: IiDelegation, @@ -642,14 +546,11 @@ struct IiSignedDelegation { } impl IiSignedDelegation { - /// Convert into `ic-agent`'s `SignedDelegation`, checking that the - /// delegation actually targets the backend session key (so the chain ends - /// where we can sign). - fn into_agent(self, session_key_der: &[u8]) -> Result { - if self.delegation.pubkey != session_key_der { - return Err( - "II delegation does not delegate to the backend session key".to_string(), - ); + /// Convert into `ic-agent`'s `SignedDelegation`, checking that the delegation + /// actually targets the per-app key (so the chain ends where we can sign). + fn into_agent(self, app_key_der: &[u8]) -> Result { + if self.delegation.pubkey != app_key_der { + return Err("II delegation does not delegate to this app's per-app key".to_string()); } Ok(SignedDelegation { delegation: Delegation { @@ -672,10 +573,7 @@ mod tests { target_origin("rdmx6-jaaaa-aaaaa-aaadq-cai.icp0.io"), "https://rdmx6-jaaaa-aaaaa-aaadq-cai.ic0.app" ); - assert_eq!( - target_origin("foo.icp.net"), - "https://foo.ic0.app" - ); + assert_eq!(target_origin("foo.icp.net"), "https://foo.ic0.app"); } #[test] @@ -685,16 +583,12 @@ mod tests { assert_eq!(target_origin("http://oisy.com"), "https://oisy.com"); } - // Seed a session with a standing credential, bypassing the network connect flow. - async fn seed_standing(ids: &Identities, session_id: &str) { + // Seed a session with a live grant, bypassing the network connect flow. + async fn seed_live(ids: &Identities, session_id: &str) { ids.ensure_session(session_id).await; let mut sessions = ids.sessions.write().await; let s = sessions.get_mut(session_id).expect("ensured session"); - s.standing = Some(Standing { - user_key: vec![1, 2, 3], - chain: vec![], - expiration_ns: u64::MAX, - }); + s.grant_expiration_ns = Some(u64::MAX); } // Insert a cached app delegation for (domain, account_number) directly. @@ -703,14 +597,19 @@ mod tests { let s = sessions.get_mut(session_id).expect("session"); s.app_delegations.insert( (domain.to_string(), account), - AppDelegation { user_key: vec![account.unwrap_or(0) as u8], chain: vec![], expiration_ns: exp }, + AppDelegation { + user_key: vec![account.unwrap_or(0) as u8], + chain: vec![], + expiration_ns: exp, + app_key_seed: [account.unwrap_or(0) as u8; 32], + }, ); } #[tokio::test] async fn resolve_account_defaults_to_none_without_network() { let ids = Identities::new(); - seed_standing(&ids, "sess").await; + seed_live(&ids, "sess").await; // No account name -> the default account, resolved with no network call. assert_eq!(ids.resolve_account("sess", "oisy.com", None).await.unwrap(), None); } @@ -718,7 +617,7 @@ mod tests { #[tokio::test] async fn cached_delegations_are_keyed_by_account_number() { let ids = Identities::new(); - seed_standing(&ids, "sess").await; + seed_live(&ids, "sess").await; let future = now_ns() + REDERIVE_MARGIN_NS + 60 * 1_000_000_000; seed_app(&ids, "sess", "oisy.com", None, future).await; seed_app(&ids, "sess", "oisy.com", Some(7), future).await; @@ -735,16 +634,24 @@ mod tests { #[tokio::test] async fn cached_delegation_near_expiry_is_a_miss() { let ids = Identities::new(); - seed_standing(&ids, "sess").await; + seed_live(&ids, "sess").await; // Expiry within the re-derive margin -> treated as stale. seed_app(&ids, "sess", "oisy.com", None, now_ns() + 1).await; assert!(ids.cached_fresh("sess", "oisy.com", None).await.is_none()); } + #[tokio::test] + async fn expired_grant_blocks_signing() { + let ids = Identities::new(); + ids.ensure_session("sess").await; + ids.set_grant_expiration("sess", now_ns().saturating_sub(1)).await; + // A past grant expiration short-circuits to the reconnect message. + assert!(ids.session_signer("sess").await.is_err()); + } + // Lock in the mcp_get_accounts Candid contract: a `vec AccountInfo` (with the - // full four-field record, incl. `origin`) decodes into our subset - // `IiAccountInfo` (origin skipped), and the Ok/Err variant maps to a Rust - // Result over AccountDelegationError. + // full record incl. `origin`) decodes into our subset `IiAccountInfo` (origin + // skipped), and the Ok/Err variant maps to a Rust Result over the error type. #[test] fn mcp_get_accounts_reply_decodes_account_records() { #[derive(CandidType)] diff --git a/src/main.rs b/src/main.rs index 761a183..9efec37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,6 @@ //! on demand from the connection's standing II delegation (see `identities`). mod auth; -mod delegation; mod discover; mod identities; mod management; @@ -671,49 +670,20 @@ fn authed_session(ctx: &RequestContext) -> Option, next: axum::middleware::Next, ) -> axum::response::Response { let method = req.method().clone(); let path = req.uri().path().to_string(); - // For /oauth/authorize only, capture which query KEYS the client sent (names - // only, never values). `` distinguishes an absent/empty query from a - // request that did carry params. - let authorize_keys = (path == "/oauth/authorize").then(|| { - let keys: Vec<&str> = req - .uri() - .query() - .map(|q| { - q.split('&') - .filter_map(|kv| kv.split('=').next()) - .filter(|k| !k.is_empty()) - .collect() - }) - .unwrap_or_default(); - if keys.is_empty() { - "".to_string() - } else { - keys.join(",") - } - }); let started = std::time::Instant::now(); let resp = next.run(req).await; let status = resp.status().as_u16(); let elapsed_ms = started.elapsed().as_millis() as u64; - // Attach `authorize_query_keys` only for /oauth/authorize, so it doesn't add - // an empty field to every other log line. - match authorize_keys { - Some(keys) => { - tracing::info!(%method, %path, status, elapsed_ms, authorize_query_keys = %keys, "http request") - } - None => tracing::info!(%method, %path, status, elapsed_ms, "http request"), - } + tracing::info!(%method, %path, status, elapsed_ms, "http request"); resp } @@ -1013,7 +983,8 @@ async fn main() -> anyhow::Result<()> { "/.well-known/oauth-protected-resource/mcp", axum::routing::get(auth::protected_resource_metadata), ) - .route("/oauth/authorize", axum::routing::get(auth::authorize)) + .route("/oauth/device_authorization", axum::routing::post(auth::device_authorization)) + .route("/oauth/device", axum::routing::get(auth::device_verify)) .route("/oauth/connect/callback", axum::routing::post(auth::connect_callback)) .route("/oauth/token", axum::routing::post(auth::token)) .route("/oauth/register", axum::routing::post(auth::register)) diff --git a/src/management.rs b/src/management.rs index b88edda..626882f 100644 --- a/src/management.rs +++ b/src/management.rs @@ -1,5 +1,6 @@ -//! Canister creation & management as the connection's **standing Internet -//! Identity principal** — a stable per-user controller/funder. +//! Canister creation & management as the connection's **management identity** — +//! the user's default account at this server's own origin, a stable per-user +//! controller/funder (derived on demand like any per-app account). //! //! Two on-chain surfaces are used: //! @@ -8,7 +9,7 @@ //! `update_settings`, `start`/`stop`/`uninstall`/`delete`. Every call sets //! the ic-agent **effective canister id** to the *target* canister (not //! `aaaaa-aa`), as the boundary node requires, and is signed by a -//! controller (the standing identity). +//! controller (the management identity). //! //! * The **cycles ledger** (`um5iw-rqaaa-aaaaq-qaaba-cai`) for funding: a user //! ingress message cannot attach cycles, so `create_canister`/`top_up` draw @@ -150,7 +151,7 @@ fn default_init_arg() -> String { /// Your cycles-ledger balance (the funds `create_canister`/`top_up_canister` spend). pub async fn cycles_balance(ids: &Identities, session_id: &str) -> Result { - let (agent, principal) = standing_agent(ids, session_id).await?; + let (agent, principal) = management_agent(ids, session_id).await?; let ledger = parse_principal(CYCLES_LEDGER)?; let account = Account { owner: principal, @@ -175,7 +176,7 @@ pub async fn create_canister( session_id: &str, args: CreateCanisterArgs, ) -> Result { - let (agent, principal) = standing_agent(ids, session_id).await?; + let (agent, principal) = management_agent(ids, session_id).await?; let cycles = resolve_cycles(&agent, args.icp.as_deref(), args.cycles).await?; let controllers = if args.controllers.is_empty() { @@ -235,7 +236,7 @@ pub async fn top_up_canister( args: TopUpArgs, ) -> Result { let target = parse_principal(&args.canister_id)?; - let (agent, _) = standing_agent(ids, session_id).await?; + let (agent, _) = management_agent(ids, session_id).await?; let cycles = resolve_cycles(&agent, args.icp.as_deref(), args.cycles).await?; let ledger = parse_principal(CYCLES_LEDGER)?; @@ -269,7 +270,7 @@ pub async fn install_code( } let mode = parse_mode(&args.mode)?; let init_arg = encode_textual_arg(&args.arg)?; - let (agent, _) = standing_agent(ids, session_id).await?; + let (agent, _) = management_agent(ids, session_id).await?; if wasm.len() <= MAX_SINGLE_SHOT_WASM { let install = InstallCodeArg { @@ -304,7 +305,7 @@ pub async fn canister_status( args: CanisterRefArgs, ) -> Result { let target = parse_principal(&args.canister_id)?; - let (agent, _) = standing_agent(ids, session_id).await?; + let (agent, _) = management_agent(ids, session_id).await?; let arg = Encode!(&CanisterIdRecord { canister_id: target }) @@ -338,7 +339,7 @@ pub async fn update_canister_settings( settings.log_visibility = Some(parse_log_visibility(lv)?); } - let (agent, _) = standing_agent(ids, session_id).await?; + let (agent, _) = management_agent(ids, session_id).await?; let arg = Encode!(&UpdateSettingsArg { canister_id: target, settings, @@ -379,10 +380,11 @@ pub async fn delete_canister(ids: &Identities, sid: &str, canister_id: &str) -> // Internal helpers // =========================================================================== -/// Build an ic-agent backed by the connection's standing II identity, plus that -/// identity's principal (the default controller/funder). -async fn standing_agent(ids: &Identities, session_id: &str) -> Result<(Agent, Principal), String> { - let identity = ids.standing_identity(session_id).await?; +/// Build an ic-agent backed by the connection's stable management identity (the +/// user's default account at this server's own origin), plus that identity's +/// principal (the default controller/funder). +async fn management_agent(ids: &Identities, session_id: &str) -> Result<(Agent, Principal), String> { + let identity = ids.management_identity(session_id).await?; let principal = identity .sender() .map_err(|e| format!("could not derive your principal: {e}"))?; @@ -434,7 +436,7 @@ async fn lifecycle( method: &str, ) -> Result<(), String> { let target = parse_principal(canister_id)?; - let (agent, _) = standing_agent(ids, session_id).await?; + let (agent, _) = management_agent(ids, session_id).await?; let arg = Encode!(&CanisterIdRecord { canister_id: target })