Rust client
Call Silicon Accounts from Rust with silicon-accounts-client, with every type, method and helper and working examples for accounts, apps and custodians.
On this page
silicon-accounts-client is how you call Silicon Accounts from Rust. The silicon-accounts CLI is built on it, so anything the CLI does, your code can do too. Each method calls one endpoint of the HTTP API.
You decide where credentials live. The client never writes files or keeps tokens, and it reads environment variables only when you call Config::from_env.
Here a Silicon signs in, then signs into an app:
use silicon_accounts_client::AccountsClient;
#[tokio::main]
async fn main() -> silicon_accounts_client::Result<()> {
let url = std::env::var("ACCOUNTS_URL").unwrap_or_else(|_| "https://accounts.teamofsilicons.com".into());
let client = AccountsClient::new(url)?;
let stk = std::env::var("STK").expect("set STK");
let tokens = client.silicon_login("si:scout", &stk, Some("scout on build box")).await?;
let session = client.with_token(tokens.access_token.expose());
let me = session.me().await?;
println!("signed in as {} ({})", me.id, me.uuid);
let slt = session.short_lived_token("briefcase").await?; // single use, 2 minutes
println!("hand {} to briefcase", slt.slt.expose());
Ok(())
}Run against a local stack (ACCOUNTS_URL=http://localhost:8590 STK=stk-… cargo run), it printed:
signed in as si:scout (K1E)
hand slt_f_92poub5NdUOgmXcmMSPdMIhg3HvS18a1v147ycz3M to briefcaseInstall
[dependencies]
silicon-accounts-client = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }To build from a local checkout of the Silicon Accounts repository instead, point the dependency at /path/to/silicon-accounts/crates/client, replaced with its location on your machine. Cargo then builds the package with that checkout's workspace settings.
You need Rust 1.98 or newer (edition 2024). Every call is async and returns
silicon_accounts_client::Result<T>.
Three handles
| Handle | Made with | Acts as | Auth sent |
|---|---|---|---|
AccountsClient | AccountsClient::new(url) or ::builder() | nobody: public calls, sign-ins | none |
AccountSession<'_> | client.with_token(access_token) | a signed-in Carbon or Silicon | Authorization: Bearer (a first-party token, aud = silicon-accounts) |
AppClient<'_> | client.as_app(app_id, app_secret), or session.app(app_id) for an app you author | an app | HTTP Basic, or the author's Bearer token |
AccountsClient holds only configuration and a connection pool, so it's cheap to clone: share one
per process. The handles borrow it and hold one credential each. Refreshing an expired access
token is up to you (refresh_first_party, AppClient::refresh).
In author mode (session.app("briefcase")), everything that manages the app works without its
secret, including issuing App verification proofs (through the App verification page route) and
revoking proofs by id. Some calls always need the app's own credentials: code, SLT and
refresh-token exchange, revoke, introspect, issue_user_verification, refresh_proof,
verify_proof and revoking a proof by token. In author mode they fail before sending anything,
with Error::InvalidInput and code invalid_input, like this:
Exchanging a short-lived token needs app briefcase's own credentials (app_id + app secret); an author's session can't do it on the app's behalf.Configuration
AccountsClient::builder():
| Method | Default | |
|---|---|---|
.base_url(url) | https://accounts.teamofsilicons.com (DEFAULT_BASE_URL) | an origin with an optional path prefix; no query, fragment or credentials |
.timeout(d) | 30 s | whole request |
.connect_timeout(d) | 10 s | |
.user_agent("my-app/1.2") | No custom prefix | prepended to silicon-accounts-client/<version> |
.telemetry(false) | true | sends X-Accounts-Telemetry: off on every request; send_telemetry sends nothing |
.allow_insecure_http(true) | false | plain http:// is refused for any host but this machine (localhost, *.localhost, loopback IPs): STKs and tokens would travel unencrypted |
.max_retries(n) | 2 | retries, with backoff, after a 502/503/504 or a timeout only for GET requests and requests carrying an idempotency key (safe to repeat); any request is retried when the connection never opened |
client.with_timeout(d) and client.with_telemetry(on) return adjusted copies.
Config::from_env() reads these, when you want it to: ACCOUNTS_URL, ACCOUNTS_APP_ID,
ACCOUNTS_APP_SECRET, ACCOUNTS_TELEMETRY (0/off/false/no turn it off),
ACCOUNTS_TIMEOUT_SECONDS (default 30) and ACCOUNTS_ALLOW_INSECURE_HTTP (1 allows plain
http). Then call config.client()?, and config.app_client(&client) for an AppClient when both
app variables are set.
let client = AccountsClient::builder()
.base_url("http://localhost:8590") // a local stack
.user_agent("docs-demo/1.0")
.timeout(std::time::Duration::from_secs(20))
.build()?;AccountsClient
| Method | Endpoint | Returns |
|---|---|---|
meta() | GET /v1/meta | Meta (name, version, environment, public_url, silicon_apps_url, docs_url, providers, delivery) |
id_available(id) | GET /v1/ids/available | IdAvailability (id, available, reason, message, reclaimable, suggestions) |
app_public(app_id) | GET /v1/apps/{app_id}/public | AppPublic |
jwks() | GET /.well-known/jwks.json | Jwks (cache it; refetch on an unknown kid) |
oidc_discovery() | GET /.well-known/openid-configuration | OidcDiscovery |
authorize_url(&AuthorizeParams) | builds /authorize?… | Url (no request) |
silicon_login(id, stk, client_label) | POST /v1/silicons/login | TokenResponse |
exchange_federated_token(silicon, subject_token) | POST /v1/oauth/token (token exchange, client_id=silicon-accounts) | TokenResponse with issued_token_type; the sign-in ends when the outside token expires |
silicon_self_create(&SiliconSelfCreate, idempotency_key) | POST /v1/silicons | SiliconSelfCreated (silicon, stk, request, request_token, webhook_secret) |
silicon_request_status(request_id, request_token) | GET /v1/silicons/requests/{id} | CustodianRequestStatus |
wait_for_custodian_decision(request_id, request_token, &WaitOptions, on_event) | polls the above | the final CustodianRequestStatus |
device_authorize(client_label) | POST /v1/device/authorize | DeviceAuthorization |
device_poll(device_code) | POST /v1/oauth/token (device grant) | DevicePoll: Pending, SlowDown, Denied, Expired or Tokens(..) |
wait_for_device_tokens(&DeviceAuthorization, on_event) | polls at interval, honouring slow_down | TokenResponse (access_denied / expired_token as Error::OAuth) |
cli_login_start(&Contact) | POST /v1/cli/login/start | CliLoginChallenge |
cli_login_verify(challenge_id, code, client_label) | POST /v1/cli/login/verify | TokenResponse |
refresh_first_party(refresh_token) | POST /v1/oauth/token (client_id=silicon-accounts) | TokenResponse with a new refresh token |
revoke_first_party(token) | POST /v1/oauth/revoke (client_id=silicon-accounts) | () |
exchange_developer_code(code, redirect_uri, code_verifier) | POST /v1/oauth/token (client_id=developer, PKCE S256, no secret) | TokenResponse with aud = developer tokens (the developer platform's server side) |
refresh_public_client(client_id, refresh_token), revoke_public_client(client_id, token) | POST /v1/oauth/token / revoke for silicon-accounts or developer | TokenResponse / () |
report(message, pr_url, access_token, idempotency_key) | POST /v1/reports | ReportReceipt |
send_telemetry(&[TelemetryEvent]) | POST /v1/telemetry/events (3-second timeout) | (); nothing when telemetry is off |
with_token(access_token) | No request | AccountSession |
as_app(app_id, app_secret) | No request | AppClient |
Contact is Contact::Email(String) or Contact::Phone { phone, country: Option<String> }.
Where a rule can be checked locally (an empty STK, a code that isn't 6 digits, a report over
10,000 characters, more than 50 telemetry events), the input is checked before sending and fails
with Error::InvalidInput.
AccountSession
A signed-in Carbon or Silicon. Where a method returns a Vec, the client follows the pages for
you (up to 100 pages of 200).
| Method | Endpoint | Returns |
|---|---|---|
me() | GET /v1/me | Me |
update_me(&ProfileUpdate) | PATCH /v1/me | Me |
change_id(new_id) | POST /v1/me/id | Me |
id_available(id) | GET /v1/ids/available (signed in: your reserved ids are reclaimable) | IdAvailability |
silicon_id_available(silicon, id) | GET /v1/ids/available?for= | IdAvailability |
set_photo(bytes, content_type) | POST /v1/me/photo | PhotoUploaded |
remove_photo() | DELETE /v1/me/photo | Me |
emails(), add_email(email), verify_email(challenge_id, code), make_email_primary(email), remove_email(email) | /v1/me/emails… | Vec<EmailAddress> / ContactChallenge |
phones(), add_phone(phone, country), verify_phone(..), make_phone_primary(..), remove_phone(..) | /v1/me/phones… | Vec<PhoneNumber> / ContactChallenge |
identities(), remove_identity(provider, subject) | /v1/me/identities… | Vec<Identity> / () |
apps(), remove_app_access(app_id) | /v1/me/apps… | Vec<MyApp> / () |
sessions(), revoke_session(id) | /v1/me/sessions… | Vec<SessionInfo> / () |
history(&HistoryQuery) | GET /v1/me/history | Page<HistoryItem> |
delete_account(confirm) | DELETE /v1/me | () |
signout(refresh_token) | POST /v1/oauth/revoke | () |
short_lived_token(app_id) | POST /v1/me/short-lived-tokens | ShortLivedToken (slt, app_id, expires_at) |
identity_token(audience, ttl_seconds) | POST /v1/me/identity-tokens (Silicons) | IdentityToken (identity_token, audience, subject, jti, kid, expires_at, …) |
add_federation(silicon, &NewFederation), federations(silicon), remove_federation(silicon, id) | /v1/silicons/{id}/federations… | Federation / Vec<Federation> / () |
identity_audiences(silicon), set_identity_audiences(silicon, &[..]) | /v1/silicons/{id}/identity-audiences | IdentityAudiences |
proofs(), revoke_proof(proof_id) | /v1/me/proofs… | Vec<MyProof> / () |
set_my_webhook(url), remove_my_webhook(), test_my_webhook() | /v1/me/webhook… (Silicons) | SiliconWebhook / () / WebhookTestResult |
my_webhook_deliveries(&DeliveriesQuery), my_webhook_delivery(id) | GET /v1/me/webhook/deliveries… (Silicons) | Page<WebhookDelivery> / DeliveryDetail |
replay_my_webhook(&ReplayRequest, idempotency_key) | POST /v1/me/webhook/replay (Silicons) | ReplayResult |
silicons(), get_silicon(uuid) | /v1/me/silicons… | Vec<ManagedSilicon> / ManagedSilicon |
create_silicon(&CreateSilicon, idempotency_key) | POST /v1/me/silicons | SiliconCreated (silicon, stk, webhook_secret) |
update_silicon(uuid, &UpdateSilicon) | PATCH /v1/me/silicons/{uuid} | SiliconView |
set_silicon_photo(uuid, bytes, content_type, idempotency_key) | POST /v1/me/silicons/{uuid}/photo | SiliconPhotoUploaded |
change_silicon_id(uuid, new_id) | POST /v1/me/silicons/{uuid}/id | SiliconView |
rotate_stk(uuid, stk) | POST /v1/me/silicons/{uuid}/stk | StkRotated (stk when generated, rotated_at) |
set_silicon_webhook(uuid, url), remove_silicon_webhook(uuid) | /v1/me/silicons/{uuid}/webhook | SiliconWebhook / () |
silicon_webhook_deliveries(uuid, &DeliveriesQuery), silicon_webhook_delivery(uuid, id) | GET /v1/me/silicons/{uuid}/webhook/deliveries… | Page<WebhookDelivery> / DeliveryDetail |
replay_silicon_webhook(uuid, &ReplayRequest, idempotency_key) | POST /v1/me/silicons/{uuid}/webhook/replay | ReplayResult |
transfer_silicon(uuid, to), cancel_transfer(uuid) | /v1/me/silicons/{uuid}/transfer | CustodianRequest / () |
delete_silicon(uuid, confirm) | DELETE /v1/me/silicons/{uuid} | () |
custodian_requests(), accept_custodian_request(id), decline_custodian_request(id) | /v1/me/custodian-requests… | Vec<CustodianRequest> / () |
owned_apps() | GET /v1/me/owned-apps | Vec<OwnedApp> |
app(app_id) | No request | an author-mode AppClient |
device_request(user_code), approve_device(user_code), deny_device(user_code) | /v1/device/{user_code}… (user codes are normalized: wdjb mjht → WDJB-MJHT) | DeviceRequest / () |
lookup(uuid), lookup_by_id(id), resolve(uuid_or_id) | /v1/accounts/… | AccountSummary |
SiliconView is Me. Photos are checked before sending: PNG, JPEG, WebP or GIF, at most 2 MB.
In CI, the federation module reads the job's OIDC token:
TokenSource::parse("env:SILICON_ID_TOKEN") (also @file or the token itself),
TokenSource::GithubActions { audience } or github_actions_id_token(audience) (from
ACTIONS_ID_TOKEN_REQUEST_URL), then TokenSource::read().await gives the token for
exchange_federated_token. See Run a Silicon in CI and the cloud.
AppClient
| Method | Endpoint | Returns |
|---|---|---|
exchange_code(code, redirect_uri, code_verifier) | token endpoint, authorization_code | TokenResponse |
exchange_slt(slt) | token endpoint, SLT grant | TokenResponse |
refresh(refresh_token) | token endpoint, refresh_token | TokenResponse (store the new refresh token) |
revoke(token) | POST /v1/oauth/revoke | () |
introspect(token) | POST /v1/oauth/introspect | Introspection |
userinfo(access_token) | GET /v1/userinfo | UserInfo (the AccountForApp plus OIDC claims) |
verify_access_token_locally(&jwks, token) | none | Claims (EdDSA signature, exp/nbf, aud == app_id; can't see revocation) |
app() | GET /v1/apps/{app_id} | AppDetails |
update_signin_config(&patch, expected_version, idempotency_key) | PATCH …/signin-config | AppDetails |
signin_config_history(&PageRequest) | GET …/signin-config/history | Page<ConfigHistoryEntry> |
users(&UsersQuery), user(uuid) | /v1/apps/{app_id}/users… | Page<AppUser> / AppUser (with history) |
start_import(&ImportInput, &ImportOptions, idempotency_key) | POST …/imports (5-minute timeout) | ImportJob |
imports(&PageRequest), import_job(job_id), import_rows(job_id, &ImportRowsQuery) | GET …/imports… | Page<ImportJob> / ImportJob / Page<ImportRowResult> |
wait_for_import(job_id, poll), wait_for_import_with(job_id, &WaitOptions, on_event) | polls the job | the finished ImportJob |
set_webhook(url, idempotency_key), remove_webhook(), rotate_webhook_secret(key), test_webhook(key) | …/webhook… | AppWebhook / () / WebhookSecret / WebhookTestResult |
deliveries(&DeliveriesQuery), delivery(id) | …/webhook/deliveries… | Page<WebhookDelivery> / DeliveryDetail |
replay(&ReplayRequest, idempotency_key) | …/webhook/replay | ReplayResult (replayed_count(), skipped_count()) |
issue_user_verification(&IssueUserVerification, key) | POST /v1/proofs/user-verification | IssuedProof |
issue_app_verification(&IssueAppVerification, key) | POST /v1/proofs/app-verification (author mode: /v1/apps/{app_id}/proofs/app-verification) | IssuedProof |
refresh_proof(refresh_token, access_ttl_seconds) | POST /v1/proofs/refresh | IssuedProof |
verify_proof(proof_token) | POST /v1/proofs/verify | ProofVerification::Valid(..) or ::Invalid |
revoke_proof(&ProofRef) | POST /v1/proofs/revoke (author mode with ProofRef::Id: DELETE …/proofs/{id}) | () |
proofs(&ProofsQuery) | GET /v1/apps/{app_id}/proofs | Page<AppProof> |
lookup(uuid), lookup_by_id(id), resolve(uuid_or_id) | /v1/accounts/… | AccountSummary |
ImportInput is Csv(Bytes), Rows(Vec<ImportRow>) or Json(Vec<serde_json::Value>).
ReplayRequest is Deliveries(Vec<String>) (1 to 100) or Failed { since: Option<OffsetDateTime> }.
ProofRef is Id, Token or RefreshToken. ProofVerification is #[non_exhaustive], so
match it with a wildcard arm.
Types
- Request types (
SiliconSelfCreate,CreateSilicon,UpdateSilicon,ProfileUpdate,IssueUserVerification,IssueAppVerification,ImportOptions,ImportRow, the*Querytypes,PageRequest,AuthorizeParams) implementDefault, so you can writeCreateSilicon { id: "si:scout".into(), display_name: "Scout".into(), ..Default::default() }. - Response types are
#[non_exhaustive]: read their fields. They tolerate fields the service adds later, andnullwhere a value is usually present. Page<T>:items,next_cursor(Noneon the last page),is_last(), iterable.Secretwraps every secret the service returns (access, refresh and short-lived tokens, STKs, webhook secrets, proof tokens, device codes).Debugprints only its prefix (Secret(sar_…)), the memory is zeroed on drop, and.expose()gives you the value where it has to leave your program.TokenResponse:access_token: Secret,token_type,expires_in,refresh_token: Option<Secret>,refresh_token_expires_at,scope,id_token,membership_id,account: Option<AccountForApp>;scopes(),access_expires_at(issued_at).Claims(local verification):iss,sub,aud: Vec<String>,exp,iat,nbf,jti,kind,id,mid,fid,scope;scopes(),has_scope(s).AccountKind:Carbon|Silicon;AccountKind::of_id("si:scout").
Errors
Every call fails with silicon_accounts_client::Error, which is #[non_exhaustive]:
| Variant | When |
|---|---|
Api(Box<ApiError>) | the service answered with its error body (or a non-JSON error: code http_<status>) |
OAuth(Box<OAuthError>) | an OAuth endpoint answered an RFC 6749 error |
Http { message, hint, source } | no response: DNS, TLS, refused connection, timeout |
Decode { message, hint } | a response this client doesn't understand |
InvalidInput { message, hint } | refused before sending |
Token(TokenError) | local access-token verification failed |
TimedOut { message, hint } | a waiting helper gave up (the work continues in the service) |
Helpers on Error: code(), message(), hint(), status(), request_id(), details(),
retry_after(), as_api(), as_oauth(), is_code(code), is_not_found(),
is_unauthenticated() (401, invalid_grant or invalid_client) and is_transport(). Display
prints the message, then Hint: and the hint. ApiError has public status, code,
message, hint, details, request_id and retry_after, plus field_errors() (the
details.fields pairs of a 422). Every code is listed in Errors.
match client.silicon_login("si:scout", "stk-000000000000", None).await {
Ok(tokens) => { /* … */ }
Err(err) if err.is_code("invalid_credentials") => eprintln!("{err}"),
Err(err) if err.is_code("login_locked") => {
tokio::time::sleep(err.retry_after().unwrap_or(std::time::Duration::from_secs(60))).await;
}
Err(err) => return Err(err),
}With a wrong STK, it printed:
Sign-in failed: no Silicon has this si:id, or the STK is wrong. Both cases get this same answer, so ids can't be probed. Hint: Check the si:id (use the current one; ids can change) and the STK (stk- followed by the hex characters shown once at creation or rotation). 10 wrong STKs in a row lock sign-in for 1 minute. A lost STK can be replaced by the Silicon's custodian (`silicon-accounts silicon rotate-stk`).Examples
Each example ran against a local stack (scripts/dev.sh), and the printed values are from those
runs (with the Accounts host written as production's).
An app signs a Silicon in and checks the token
let app = client.as_app("briefcase", app_secret);
let tokens = app.exchange_slt(&slt_from_the_silicon).await?;
let account = tokens.account.clone().expect("token responses carry the account");
println!("{} signed in as {} (membership {})", account.uuid, account.id, account.membership_id);
let jwks = client.jwks().await?; // cache it
let claims = app.verify_access_token_locally(&jwks, tokens.access_token.expose())?;
println!("aud={:?} scopes={:?}", claims.aud, claims.scopes());
let live = app.introspect(tokens.access_token.expose()).await?; // sees revocation
println!("active={}", live.active);
let rotated = app.refresh(tokens.refresh_token.as_ref().unwrap().expose()).await?;
// store rotated.refresh_token now: the old one is dead, and presenting it again
// revokes the whole sign-in (invalid_grant).K1E signed in as si:scout (membership briefcase:K1E)
aud=["briefcase"] scopes=["profile", "timezone"]
active=trueSend a browser to the hosted sign-in
use silicon_accounts_client::{AuthorizeParams, pkce_pair, random_state};
let pkce = pkce_pair(); // keep pkce.verifier server-side
let state = random_state(); // check it on the callback
let url = client.authorize_url(
&AuthorizeParams::new("briefcase", "https://briefcase.example/auth/callback")
.state(&state)
.pkce(&pkce)
.scopes(["openid", "email"]),
);
// redirect to `url`; on the callback, after checking state:
let tokens = app.exchange_code(&code, "https://briefcase.example/auth/callback", Some(&pkce.verifier)).await?;url is https://accounts.teamofsilicons.com/authorize?response_type=code&app_id=briefcase&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256&scope=openid+email.
AuthorizeParams also takes .nonce(), .prompt(), .intent() (signin or signup, for your
"Sign in" and "Sign up" buttons) and .method() (for a direct "Continue with …" button). There is
no login_hint, because an app never hands Silicon Accounts a Carbon's email or phone.
A Carbon signs in on a device
let device = client.device_authorize(Some("my tool on laptop")).await?;
println!("Open {} and enter {}", device.verification_uri, device.user_code);
let tokens = client.wait_for_device_tokens(&device, |_| {}).await?;
let me = client.with_token(tokens.access_token.expose()).me().await?;
println!("signed in as {} ({})", me.id, me.uuid);Open https://accounts.teamofsilicons.com/device and enter PJG8-55WW
signed in as c:ada (8HV)The Carbon approves on the account site. From Rust, a signed-in Carbon can approve with
session.approve_device(&device.user_code).
A Silicon creates its own account and waits for its custodian
use silicon_accounts_client::{SiliconSelfCreate, WaitEvent, WaitOptions};
let created = client
.silicon_self_create(
&SiliconSelfCreate {
id: "si:pilot".into(),
display_name: "Pilot".into(),
custodian: "c:ada".into(),
..Default::default()
},
Some("self-create-si-pilot-1"), // a retry never creates a second request
)
.await?;
let stk = created.stk.clone().expect("generated STKs are returned once"); // store it now
println!("{} is {}", created.silicon.id, created.silicon.status);
let decision = client
.wait_for_custodian_decision(
&created.request.id,
created.request_token.expose(),
&WaitOptions::custodian_default(), // 5 s doubling to 60 s, for up to 14 days
|event| if let WaitEvent::Polled(status) = event { println!("polled: {}", status.status) },
)
.await?;
if decision.is_accepted() {
let tokens = client.silicon_login("si:pilot", stk.expose(), None).await?;
}si:pilot is pending_custodian
polled: acceptedWaitOptions::fixed(d), WaitOptions::backoff(initial, max) and .with_timeout(Some(d)) shape
the polling. Transient errors (network, 5xx, 429) are reported as WaitEvent::TransientError and
retried.
A custodian manages its Silicons
use silicon_accounts_client::CreateSilicon;
let ada = client.with_token(carbon_access_token);
let made = ada
.create_silicon(&CreateSilicon { id: "si:copilot".into(), display_name: "Copilot".into(), ..Default::default() },
Some("create-si-copilot-1"))
.await?;
let rotated = ada.rotate_stk(&made.silicon.uuid, None).await?; // None = generate one
let check = ada.silicon_id_available(&made.silicon.uuid, "si:copilot-2").await?;An app manages its setup, imports and webhook
use silicon_accounts_client::{DeliveriesQuery, ImportInput, ImportOptions, ReplayRequest};
let app = client.as_app("remind", app_secret);
let details = app.app().await?;
let patched = app
.update_signin_config(&serde_json::json!({"copy": {"subtitle": "Reminders on your own clock."}}),
Some(details.config_version), Some("remind-cfg-1"))
.await?;
let job = app
.start_import(&ImportInput::Csv(csv_bytes.into()), &ImportOptions { dry_run: true, ..Default::default() }, Some("import-1"))
.await?;
let job = app.wait_for_import(&job.id, std::time::Duration::from_secs(1)).await?;
let hook = app.set_webhook("https://remind.example/hooks/accounts", Some("webhook-1")).await?;
let page = app.deliveries(&DeliveriesQuery { status: Some("failed".into()), ..Default::default() }).await?;
let result = app.replay(&ReplayRequest::Failed { since: None }, None).await?;In the run, config_version went from 2 to 3. Sending another patch with the old
expected_version failed with err.code() == "config_version_conflict" and err.details() =
{"current_version": 3, "expected_version": 2}. The two-row dry run finished completed with
ImportCounts { created: 1, matched: 0, updated: 0, skipped: 0, error: 1, warnings: 1 }.
Proofs
use silicon_accounts_client::{IssueAppVerification, IssueUserVerification, ProofRef, ProofVerification};
// App A, for an account that consented in A's own interface:
let proof = app_a
.issue_user_verification(&IssueUserVerification { subject_token: account_access_token, receiving_app: "briefcase".into(),
scopes: vec!["files.write".into()], access_ttl_seconds: Some(600) },
Some("user_verification-req-42"))
.await?;
// App B, receiving proof.proof_token:
match app_b.verify_proof(&proof_token).await? {
ProofVerification::Valid(p) => println!("from {} scopes={:?}", p.issuing_app.app_id, p.scopes),
_ => { /* not valid: refuse */ }
}
let app_verification = commit.issue_app_verification(&IssueAppVerification { receiving_app: "remind".into(), scopes: vec!["builds.read".into()], access_ttl_seconds: Some(600) }, Some("app_verification-1")).await?;
commit.revoke_proof(&ProofRef::Id(app_verification.proof_id.clone())).await?;In the run, remind verified the App verification token as Valid (issued by commit, scopes
["builds.read"]). After revoke_proof, the same token verified as Invalid.
Webhooks
use silicon_accounts_client::{verify_and_parse_webhook, WebhookPayload, DEFAULT_WEBHOOK_TOLERANCE};
let event = verify_and_parse_webhook(
&webhook_secret, // whsec_…, used as-is as the HMAC key
timestamp_header, // X-Accounts-Timestamp
signature_header, // X-Accounts-Signature
&raw_body, // the exact bytes received, before JSON parsing
DEFAULT_WEBHOOK_TOLERANCE, // 5 minutes
)?;
match event.payload {
WebhookPayload::AccountIdChanged(change) => { /* show change.new_id; keep keying on change.uuid */ }
WebhookPayload::AccountDeleted(gone) => { /* delete gone.uuid's data */ }
WebhookPayload::Ping => {}
_ => {}
}
// dedupe on event.event_id: retries and replays reuse itIn the run, a real ping delivery verified (event.payload was WebhookPayload::Ping), and the
same headers with another body were refused with WebhookError::SignatureMismatch, which
displays as:
The webhook signature does not match the body. Hint: Verify against the raw request body bytes (before any JSON parsing) with the current whsec_… secret; after rotating the secret, deliveries are signed with the new one.| Item | |
|---|---|
verify_webhook_signature(secret, ts, sig, body, tolerance) | checks the timestamp and any v1= signature (constant time) |
verify_webhook_signature_at(.., now_unix) | the same with an explicit clock, for tests |
verify_and_parse_webhook(..) | verify, then parse_webhook |
parse_webhook(body) | WebhookEvent { event_id, event_type, occurred_at, app_id, silicon, data, payload } |
sign_webhook(secret, ts, body) | the v1=… header value (tests, tools) |
WebhookPayload | AccountIdChanged, AccountUpdated, AccountDeleted, MembershipSignedOut, MembershipAccessRemoved, CustodianChanged, Ping, the Silicon events (SiliconCreated, SiliconCustodianAccepted, SiliconCustodianDeclined, SiliconCustodianExpired, SiliconUpdated, SiliconIdChanged, SiliconStkRotated, SiliconCustodianChanged, carrying the raw data), Unknown for types this version doesn't know |
WebhookError | EmptySecret, MissingHeader, InvalidTimestamp, TimestampOutOfTolerance, InvalidSignatureFormat, SignatureMismatch, InvalidBody: refuse the delivery (400 or 401) |
| Header constants | EVENT_ID_HEADER, EVENT_TYPE_HEADER, DELIVERY_ID_HEADER, TIMESTAMP_HEADER, SIGNATURE_HEADER |
The wire format is in Webhook deliveries and events.
Local token verification and PKCE
| Item | |
|---|---|
verify_access_token(&jwks, token, &VerifyOptions) | EdDSA (Ed25519) signature by a JWKS key (by kid), exp/nbf, aud, optionally iss → Claims; Error::Token(TokenError) otherwise |
VerifyOptions::for_app(app_id) | accept tokens issued to your app; .with_issuer(url); leeway_seconds 30. An empty audience list is refused (any app's token would pass) |
pkce_pair() | PkcePair { verifier (43 characters), challenge }, S256 |
pkce_challenge(verifier), random_state(), random_nonce(), random_token(bytes) | base64url values from the OS random generator |
Local verification can't see revocation (a sign-out, removed access), and access tokens live at
most 30 minutes. Call introspect when you need to know at once.
Constants
DEFAULT_BASE_URL (https://accounts.teamofsilicons.com), FIRST_PARTY_APP_ID (silicon-accounts),
DEVELOPER_APP_ID (developer),
VERSION, SLT_GRANT_TYPE (urn:silicon:params:oauth:grant-type:slt),
DEVICE_CODE_GRANT_TYPE (urn:ietf:params:oauth:grant-type:device_code), TELEMETRY_HEADER,
IDEMPOTENCY_HEADER, IDEMPOTENT_REPLAYED_HEADER, REQUEST_ID_HEADER.
Identifiers
Store the account uuid (permanent) or the membership id {app_id}:{uuid}. The c:/si: id is
for display only: it can change, and apps hear about it through account.id_changed.