Skip to content

Commit 1205a12

Browse files
sea-snakeclaude
andcommitted
feat(be): revoke sessions from the user's own settings
The app can sign out only its own session, so "sign this browser out" has to come from somewhere the anchor is authenticated. These two are authenticated by an anchor access method rather than by a session chain, and name sessions by locator rather than by principal, so the principal index stays on the app-facing path. Signing a browser out is an eager sweep of that anchor's references in one message, so refresh never has to read the anchor to find out whether its device was revoked: refresh happens every few minutes per active session, this happens rarely. The device record survives the sweep, so a browser that has been signed out is still one the user recognises and signing back in from it reuses the same id. Implements docs/ongoing/revocable-app-sessions.md §8.2, §9.3 (S16, S20). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 418c0f2 commit 1205a12

10 files changed

Lines changed: 481 additions & 2 deletions

File tree

src/canister_tests/src/api/internet_identity/api_v2.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,3 +878,37 @@ pub fn app_revoke_session_with_bundle(
878878
)
879879
.map(|_| ())
880880
}
881+
882+
pub fn revoke_account_session(
883+
env: &PocketIc,
884+
canister_id: CanisterId,
885+
sender: Principal,
886+
request: RevokeAccountSessionRequest,
887+
) -> Result<Result<(), SessionRevokeError>, RejectResponse> {
888+
call_candid_as(
889+
env,
890+
canister_id,
891+
RawEffectivePrincipal::None,
892+
sender,
893+
"revoke_account_session",
894+
(request,),
895+
)
896+
.map(|(x,)| x)
897+
}
898+
899+
pub fn revoke_device_sessions(
900+
env: &PocketIc,
901+
canister_id: CanisterId,
902+
sender: Principal,
903+
request: RevokeDeviceSessionsRequest,
904+
) -> Result<Result<(), SessionRevokeError>, RejectResponse> {
905+
call_candid_as(
906+
env,
907+
canister_id,
908+
RawEffectivePrincipal::None,
909+
sender,
910+
"revoke_device_sessions",
911+
(request,),
912+
)
913+
.map(|(x,)| x)
914+
}

src/frontend/src/lib/generated/internet_identity_idl.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,20 @@ export const idlFactory = ({ IDL }) => {
807807
'canister_full' : IDL.Null,
808808
'registered' : IDL.Record({ 'user_number' : UserNumber }),
809809
});
810+
const RevokeAccountSessionRequest = IDL.Record({
811+
'origin' : IDL.Text,
812+
'created_at' : Timestamp,
813+
'account_number' : IDL.Opt(AccountNumber),
814+
'identity_number' : UserNumber,
815+
});
816+
const SessionRevokeError = IDL.Variant({
817+
'InternalCanisterError' : IDL.Text,
818+
'Unauthorized' : IDL.Principal,
819+
});
820+
const RevokeDeviceSessionsRequest = IDL.Record({
821+
'device_id' : IDL.Nat32,
822+
'identity_number' : UserNumber,
823+
});
810824
const SetDefaultAccountError = IDL.Variant({
811825
'NoSuchOrigin' : IDL.Record({ 'anchor_number' : UserNumber }),
812826
'NoSuchAnchor' : IDL.Null,
@@ -1440,6 +1454,16 @@ export const idlFactory = ({ IDL }) => {
14401454
),
14411455
'remove' : IDL.Func([UserNumber, DeviceKey], [], []),
14421456
'replace' : IDL.Func([UserNumber, DeviceKey, DeviceData], [], []),
1457+
'revoke_account_session' : IDL.Func(
1458+
[RevokeAccountSessionRequest],
1459+
[IDL.Variant({ 'Ok' : IDL.Null, 'Err' : SessionRevokeError })],
1460+
[],
1461+
),
1462+
'revoke_device_sessions' : IDL.Func(
1463+
[RevokeDeviceSessionsRequest],
1464+
[IDL.Variant({ 'Ok' : IDL.Null, 'Err' : SessionRevokeError })],
1465+
[],
1466+
),
14431467
'set_default_account' : IDL.Func(
14441468
[UserNumber, FrontendHostname, IDL.Opt(AccountNumber)],
14451469
[IDL.Variant({ 'Ok' : AccountInfo, 'Err' : SetDefaultAccountError })],

src/frontend/src/lib/generated/internet_identity_types.d.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,16 @@ export type RegistrationFlowNextStep = {
16141614
'Finish' : null
16151615
};
16161616
export type RegistrationId = string;
1617+
export interface RevokeAccountSessionRequest {
1618+
'origin' : string,
1619+
'created_at' : Timestamp,
1620+
'account_number' : [] | [AccountNumber],
1621+
'identity_number' : UserNumber,
1622+
}
1623+
export interface RevokeDeviceSessionsRequest {
1624+
'device_id' : number,
1625+
'identity_number' : UserNumber,
1626+
}
16171627
/**
16181628
* DNSSEC proof bundle and supporting types — see
16191629
* `internet_identity_interface::types::dnssec`.
@@ -1648,6 +1658,8 @@ export interface SessionDeviceInfo {
16481658
'last_used' : Timestamp,
16491659
}
16501660
export type SessionKey = PublicKey;
1661+
export type SessionRevokeError = { 'InternalCanisterError' : string } |
1662+
{ 'Unauthorized' : Principal };
16511663
export type SetDefaultAccountError = {
16521664
'NoSuchOrigin' : { 'anchor_number' : UserNumber }
16531665
} |
@@ -2544,6 +2556,21 @@ export interface _SERVICE {
25442556
* Atomically replace device matching the device key with the new device data
25452557
*/
25462558
'replace' : ActorMethod<[UserNumber, DeviceKey, DeviceData], undefined>,
2559+
/**
2560+
* Revocation from the user's own settings, authenticated by an anchor access method
2561+
* rather than by a session chain. Sessions are named by locator, never by principal,
2562+
* so these do not touch the principal index.
2563+
*/
2564+
'revoke_account_session' : ActorMethod<
2565+
[RevokeAccountSessionRequest],
2566+
{ 'Ok' : null } |
2567+
{ 'Err' : SessionRevokeError }
2568+
>,
2569+
'revoke_device_sessions' : ActorMethod<
2570+
[RevokeDeviceSessionsRequest],
2571+
{ 'Ok' : null } |
2572+
{ 'Err' : SessionRevokeError }
2573+
>,
25472574
'set_default_account' : ActorMethod<
25482575
[UserNumber, FrontendHostname, [] | [AccountNumber]],
25492576
{ 'Ok' : AccountInfo } |

src/internet_identity/internet_identity.did

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,23 @@ type AppGetDelegationRequest = record {
10871087
expiration : Timestamp;
10881088
};
10891089

1090+
type RevokeAccountSessionRequest = record {
1091+
identity_number : UserNumber;
1092+
origin : text;
1093+
account_number : opt AccountNumber;
1094+
created_at : Timestamp;
1095+
};
1096+
1097+
type RevokeDeviceSessionsRequest = record {
1098+
identity_number : UserNumber;
1099+
device_id : nat32;
1100+
};
1101+
1102+
type SessionRevokeError = variant {
1103+
Unauthorized : principal;
1104+
InternalCanisterError : text;
1105+
};
1106+
10901107
type AppSessionError = variant {
10911108
// No session bundle was attached, or one this canister did not sign.
10921109
NoSuchSession;
@@ -1969,6 +1986,12 @@ service : (opt InternetIdentityInit) -> {
19691986
// session was already gone. An app can revoke only its own session.
19701987
app_revoke_session : () -> ();
19711988

1989+
// Revocation from the user's own settings, authenticated by an anchor access method
1990+
// rather than by a session chain. Sessions are named by locator, never by principal,
1991+
// so these do not touch the principal index.
1992+
revoke_account_session : (RevokeAccountSessionRequest) -> (variant { Ok; Err : SessionRevokeError });
1993+
revoke_device_sessions : (RevokeDeviceSessionsRequest) -> (variant { Ok; Err : SessionRevokeError });
1994+
19721995
prepare_account_delegation : (
19731996
anchor_number : UserNumber,
19741997
origin : FrontendHostname,

src/internet_identity/src/main.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,16 @@ fn app_prepare_delegation(
515515
sessions::app_prepare_delegation(request)
516516
}
517517

518+
#[update]
519+
fn revoke_account_session(request: RevokeAccountSessionRequest) -> Result<(), SessionRevokeError> {
520+
sessions::revoke_account_session(request)
521+
}
522+
523+
#[update]
524+
fn revoke_device_sessions(request: RevokeDeviceSessionsRequest) -> Result<(), SessionRevokeError> {
525+
sessions::revoke_device_sessions(request)
526+
}
527+
518528
#[update]
519529
fn app_revoke_session() {
520530
sessions::app_revoke_session()

src/internet_identity/src/sessions.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ use internet_identity_interface::internet_identity::types::{
2727
AccountNumber, AccountSessionError, AnchorNumber, AppGetDelegationRequest,
2828
AppPrepareDelegationRequest, AppPrepareDelegationResponse, AppSessionError, ApplicationNumber,
2929
Delegation, FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse,
30-
PrepareAccountSessionRequest, PrepareAccountSessionResponse, SignedDelegation, Timestamp,
30+
PrepareAccountSessionRequest, PrepareAccountSessionResponse, RevokeAccountSessionRequest,
31+
RevokeDeviceSessionsRequest, SessionRevokeError, SignedDelegation, Timestamp,
3132
};
3233
use serde_bytes::ByteBuf;
3334

@@ -438,3 +439,35 @@ fn account_seed(account: &Account) -> Result<Hash, AppSessionError> {
438439
})?;
439440
Ok(account.calculate_seed_with_salt(&salt))
440441
}
442+
443+
pub fn revoke_account_session(
444+
request: RevokeAccountSessionRequest,
445+
) -> Result<(), SessionRevokeError> {
446+
check_authorization(request.identity_number)
447+
.map_err(|err| SessionRevokeError::Unauthorized(err.principal))?;
448+
check_frontend_length(&request.origin);
449+
450+
storage_borrow_mut(|storage| {
451+
storage.revoke_account_sessions(
452+
request.identity_number,
453+
&request.origin,
454+
request.account_number,
455+
request.created_at,
456+
)
457+
})
458+
.map(|_| ())
459+
.map_err(|err| SessionRevokeError::InternalCanisterError(err.to_string()))
460+
}
461+
462+
pub fn revoke_device_sessions(
463+
request: RevokeDeviceSessionsRequest,
464+
) -> Result<(), SessionRevokeError> {
465+
check_authorization(request.identity_number)
466+
.map_err(|err| SessionRevokeError::Unauthorized(err.principal))?;
467+
468+
storage_borrow_mut(|storage| {
469+
storage.revoke_device_sessions(request.identity_number, request.device_id)
470+
})
471+
.map(|_| ())
472+
.map_err(|err| SessionRevokeError::InternalCanisterError(err.to_string()))
473+
}

src/internet_identity/src/storage.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1817,6 +1817,45 @@ impl<M: Memory + Clone> Storage<M> {
18171817
self.lookup_account_with_principal_memory.get(&principal)
18181818
}
18191819

1820+
/// Removes the sessions an anchor names by locator and creation time. Two browsers
1821+
/// signing in during the same round share a `created_at`, so this can match both.
1822+
pub fn revoke_account_sessions(
1823+
&mut self,
1824+
anchor_number: AnchorNumber,
1825+
origin: &FrontendHostname,
1826+
account_number: Option<AccountNumber>,
1827+
created_at: Timestamp,
1828+
) -> Result<u64, StorageError> {
1829+
let Some(application_number) = self.lookup_application_number_with_origin(origin) else {
1830+
return Ok(0);
1831+
};
1832+
let Some(references) = self.lookup_account_references(anchor_number, application_number)
1833+
else {
1834+
return Ok(0);
1835+
};
1836+
let mut references: Vec<AccountReference> =
1837+
references.into_iter().map(Into::into).collect();
1838+
1839+
let Some(reference) = references
1840+
.iter_mut()
1841+
.find(|reference| reference.account_number == account_number)
1842+
else {
1843+
return Ok(0);
1844+
};
1845+
1846+
let before = reference.sessions.len();
1847+
reference
1848+
.sessions
1849+
.retain(|session| session.created_at != created_at);
1850+
let removed = (before - reference.sessions.len()) as u64;
1851+
if removed == 0 {
1852+
return Ok(0);
1853+
}
1854+
1855+
self.write_reference_list(anchor_number, application_number, references)?;
1856+
Ok(removed)
1857+
}
1858+
18201859
/// Removes one session. Returns whether anything was removed.
18211860
pub fn remove_session(
18221861
&mut self,

0 commit comments

Comments
 (0)