#![allow(dead_code)]
use candid::{CandidType, Decode, Encode, Principal};
use http_body_util::BodyExt;
use serde::Deserialize;
use std::time::SystemTime;
use tower::ServiceExt;
use axum::{
body::Body,
http::{header, Request, StatusCode},
};
#[derive(CandidType, Deserialize, Clone, Copy)]
enum Permissions {
#[serde(rename = "queries")]
Queries,
#[serde(rename = "all")]
All,
}
#[derive(CandidType)]
struct McpConfig {
enabled: bool,
url: Option<String>,
}
#[derive(CandidType, Deserialize)]
struct Delegation {
pubkey: Vec<u8>,
expiration: u64,
targets: Option<Vec<Principal>>,
permissions: Option<String>,
}
#[derive(CandidType, Deserialize)]
struct SignedDelegation {
delegation: Delegation,
signature: Vec<u8>,
}
#[derive(CandidType, Deserialize)]
struct PrepareMcpRegistrationDelegation {
user_key: Vec<u8>,
expiration: u64,
}
#[derive(CandidType, Deserialize)]
struct Challenge {
#[allow(dead_code)]
png_base64: String,
challenge_key: String,
}
#[derive(CandidType)]
struct ChallengeResult {
key: String,
chars: String,
}
#[derive(CandidType, Deserialize)]
enum Purpose {
#[serde(rename = "recovery")]
Recovery,
#[serde(rename = "authentication")]
Authentication,
}
#[derive(CandidType, Deserialize)]
enum KeyType {
#[serde(rename = "unknown")]
Unknown,
#[serde(rename = "platform")]
Platform,
#[serde(rename = "cross_platform")]
CrossPlatform,
#[serde(rename = "seed_phrase")]
SeedPhrase,
#[serde(rename = "browser_storage_key")]
BrowserStorageKey,
}
#[derive(CandidType, Deserialize)]
enum DeviceProtection {
#[serde(rename = "protected")]
Protected,
#[serde(rename = "unprotected")]
Unprotected,
}
#[derive(CandidType, Deserialize)]
enum MetadataVal {
#[serde(rename = "map")]
Map(Vec<(String, MetadataVal)>),
#[serde(rename = "string")]
String(String),
#[serde(rename = "bytes")]
Bytes(Vec<u8>),
}
#[derive(CandidType)]
struct DeviceData {
pubkey: Vec<u8>,
alias: String,
credential_id: Option<Vec<u8>>,
aaguid: Option<Vec<u8>>,
purpose: Purpose,
key_type: KeyType,
protection: DeviceProtection,
origin: Option<String>,
metadata: Option<Vec<(String, MetadataVal)>>,
}
#[derive(CandidType, Deserialize)]
enum RegisterResponse {
#[serde(rename = "registered")]
Registered { user_number: u64 },
#[serde(rename = "canister_full")]
CanisterFull,
#[serde(rename = "bad_challenge")]
BadChallenge,
}
#[derive(CandidType)]
enum StaticCaptchaTrigger {
CaptchaEnabled,
CaptchaDisabled,
}
#[derive(CandidType)]
enum CaptchaTrigger {
Dynamic {
threshold_pct: u16,
current_rate_sampling_interval_s: u64,
reference_rate_sampling_interval_s: u64,
},
Static(StaticCaptchaTrigger),
}
#[derive(CandidType)]
struct CaptchaConfig {
max_unsolved_captchas: u64,
captcha_trigger: CaptchaTrigger,
}
#[derive(CandidType)]
struct InternetIdentityInit {
captcha_config: Option<CaptchaConfig>,
}
const DEVICE_PUBKEY: &[u8] = b"test";
fn principal_1() -> Principal {
Principal::self_authenticating(DEVICE_PUBKEY)
}
fn device_data_1() -> DeviceData {
DeviceData {
pubkey: DEVICE_PUBKEY.to_vec(),
alias: "e2e device".to_string(),
credential_id: None,
aaguid: None,
purpose: Purpose::Authentication,
key_type: KeyType::CrossPlatform,
protection: DeviceProtection::Unprotected,
origin: None,
metadata: None,
}
}
async fn register_anchor(pic: &pocket_ic::nonblocking::PocketIc, ii: Principal) -> u64 {
let bytes = pic
.update_call(ii, Principal::anonymous(), "create_challenge", Encode!().unwrap())
.await
.expect("create_challenge");
let challenge = Decode!(&bytes, Challenge).unwrap();
let attempt = ChallengeResult {
key: challenge.challenge_key,
chars: "a".to_string(),
};
let bytes = pic
.update_call(
ii,
principal_1(),
"register",
Encode!(&device_data_1(), &attempt, &Option::<Principal>::None).unwrap(),
)
.await
.expect("register");
match Decode!(&bytes, RegisterResponse).unwrap() {
RegisterResponse::Registered { user_number } => user_number,
RegisterResponse::CanisterFull => panic!("register: canister full"),
RegisterResponse::BadChallenge => panic!("register: bad challenge (captcha not disabled?)"),
}
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn field<'a>(blob: &'a str, key: &str) -> Option<&'a str> {
blob.split('&')
.filter_map(|kv| kv.split_once('='))
.find(|(k, _)| *k == key)
.map(|(_, v)| v)
}
struct StateDirGuard {
dir: std::path::PathBuf,
}
impl Drop for StateDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[tokio::test]
async fn registration_delegation_end_to_end() {
let (Ok(ii_wasm_path), Ok(_)) =
(std::env::var("II_WASM"), std::env::var("POCKET_IC_BIN"))
else {
eprintln!(
"skipping registration_delegation_end_to_end: set II_WASM (internet_identity \
release .wasm.gz) and POCKET_IC_BIN (pocket-ic v15 server) to run it"
);
return;
};
let ii_wasm = std::fs::read(&ii_wasm_path).expect("read II_WASM (gz bytes; PocketIC gunzips)");
let nanos = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let state_dir = std::env::temp_dir().join(format!(
"imcp2-e2e-state-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&state_dir).expect("create e2e state dir");
let _state_guard = StateDirGuard { dir: state_dir.clone() };
let mut pic = pocket_ic::PocketIcBuilder::new()
.with_nns_subnet()
.with_application_subnet()
.build_async()
.await;
pic.set_time(SystemTime::now().into()).await;
pic.tick().await;
let ii = pic.create_canister().await;
pic.add_cycles(ii, 2_000_000_000_000).await;
let init = InternetIdentityInit {
captcha_config: Some(CaptchaConfig {
max_unsolved_captchas: 500,
captcha_trigger: CaptchaTrigger::Static(StaticCaptchaTrigger::CaptchaDisabled),
}),
};
pic.install_canister(ii, ii_wasm, Encode!(&Some(init)).unwrap(), None)
.await;
let gateway = pic.make_live(None).await;
let agent = crate::Agent::builder()
.with_url(gateway.as_str())
.build()
.expect("build agent");
agent.fetch_root_key().await.expect("fetch PocketIC root key");
let public_url = "http://localhost:8000"; let server = crate::McpServer::new(crate::McpConfig {
agent,
instance: crate::IiInstance {
name: "e2e",
ii_url: gateway.to_string(),
ii_canister: ii,
},
public_url: public_url.into(),
mcp_path: "/mcp".into(),
clients: crate::SharedClients::load(&state_dir),
state_dir: state_dir.clone(),
require_resource: false,
});
let app = axum::Router::new()
.nest_service(server.mcp_path(), server.mcp_router())
.merge(server.well_known_router());
let redirect_uri = "http://127.0.0.1:6112/cb";
let resp = app
.clone()
.oneshot(
Request::post("/mcp/oauth/register")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({ "redirect_uris": [redirect_uri] }).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().is_success(), "register status: {}", resp.status());
let client_id = body_json(resp).await["client_id"].as_str().unwrap().to_string();
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
let authorize_url = format!(
"/mcp/oauth/authorize?response_type=code&client_id={client_id}\
&redirect_uri={redirect_uri}&code_challenge={challenge}&code_challenge_method=S256"
);
let resp = app
.clone()
.oneshot(Request::get(&authorize_url).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND, "authorize should 302 to II");
let location = resp.headers().get(header::LOCATION).unwrap().to_str().unwrap();
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
let fragment = location.split_once('#').expect("connect link fragment").1;
let state = field(fragment, "state").expect("state").to_string();
let reg_key_b64 = field(fragment, "registration_key").expect("registration_key");
let reg_key_x = base64::Engine::decode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
reg_key_b64,
)
.expect("decode registration_key");
let cookie_val = set_cookie
.split(';')
.next()
.and_then(|kv| kv.trim().strip_prefix(&format!("{}=", crate::auth::CONNECT_COOKIE)))
.expect("mcp_connect cookie")
.to_string();
let anchor = register_anchor(&pic, ii).await;
let set_cfg = pic
.update_call(
ii,
principal_1(),
"mcp_set_config",
Encode!(
&anchor,
&McpConfig {
enabled: true,
url: Some(format!("{public_url}/mcp")),
}
)
.unwrap(),
)
.await
.expect("mcp_set_config call");
Decode!(&set_cfg, Result<(), String>).unwrap().expect("mcp_set_config Ok");
const GRANT_TTL_NS: u64 = 24 * 60 * 60 * 1_000_000_000;
let prep_bytes = pic
.update_call(
ii,
principal_1(),
"prepare_mcp_registration_delegation",
Encode!(
&anchor,
®_key_x,
&Some(Permissions::All),
&Some(GRANT_TTL_NS)
)
.unwrap(),
)
.await
.expect("prepare call");
let prepared = Decode!(&prep_bytes, Result<PrepareMcpRegistrationDelegation, String>)
.unwrap()
.expect("prepare Ok");
let get_bytes = pic
.query_call(
ii,
principal_1(),
"get_mcp_registration_delegation",
Encode!(&anchor, ®_key_x, &prepared.user_key, &prepared.expiration).unwrap(),
)
.await
.expect("get call");
let signed = Decode!(&get_bytes, Result<SignedDelegation, String>)
.unwrap()
.expect("get Ok");
assert_eq!(signed.delegation.pubkey, reg_key_x, "delegation targets our X");
let mut delegation = serde_json::json!({
"pubkey": hex::encode(&signed.delegation.pubkey),
"expiration": format!("{:x}", signed.delegation.expiration),
});
if let Some(targets) = &signed.delegation.targets {
delegation["targets"] =
serde_json::Value::from(targets.iter().map(|p| p.to_text()).collect::<Vec<_>>());
}
let chain_json = serde_json::json!({
"publicKey": hex::encode(&prepared.user_key),
"delegations": [{ "delegation": delegation, "signature": hex::encode(&signed.signature) }],
})
.to_string();
let resp = app
.clone()
.oneshot(
Request::post("/mcp/oauth/connect/redeem")
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, format!("{}={cookie_val}", crate::auth::CONNECT_COOKIE))
.body(Body::from(
serde_json::json!({ "state": state, "delegation": chain_json }).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "redeem should succeed against live II");
let redirect = body_json(resp).await["redirect"].as_str().unwrap().to_string();
assert!(redirect.starts_with(redirect_uri), "redirect to the client: {redirect}");
let query = redirect.split_once('?').expect("redirect query").1;
let code = field(query, "code").expect("authorization code").to_string();
let resp = app
.clone()
.oneshot(
Request::post("/mcp/oauth/token")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(format!(
"grant_type=authorization_code&code={code}&client_id={client_id}&code_verifier={verifier}"
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "token exchange should succeed");
let tok = body_json(resp).await;
let access_token = tok["access_token"].as_str().expect("access_token").to_string();
assert_eq!(tok["token_type"], "Bearer");
let expires_in = tok["expires_in"].as_u64().expect("expires_in");
assert!(expires_in > 0 && expires_in <= GRANT_TTL_NS / 1_000_000_000, "TTL tracks grant: {expires_in}");
let (principal, session_id) = server
.store
.session_for_token(&access_token)
.await
.expect("the minted token must authenticate");
assert_eq!(session_id, state);
assert_eq!(
Some(principal),
server.identities.session_principal(&state).await,
"token principal == self_authenticating(session key S)"
);
let resp = app
.oneshot(
Request::post("/mcp")
.header(header::AUTHORIZATION, format!("Bearer {access_token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_ne!(resp.status(), StatusCode::UNAUTHORIZED, "valid token must pass require_token");
}