Skip to content

Commit d6d824c

Browse files
sea-snakeclaude
andcommitted
feat(be): register the browser a session was created from
Sessions are per account, so a user who wants to sign one browser out has nothing to name it by. Anchors gain a device registry: `{id, name, created_at, last_used}` per browser, capped at 20 because the anchor blob is read on nearly every authenticated path, with a monotonic per-anchor allocator so ids are never reused. There is no registration method. The client passes the name it would have registered with plus whatever id it has cached, and the canister resolves the rest, so an id the client does not own resolves to a fresh registration rather than to somebody else's device. At the cap the least recently used record is dropped rather than the registration failing, which costs that browser its name in the session list and never costs anyone a sign-in. Eviction orders on `last_used`, not on `created_at`. Clearing browser storage loses the cached id, so each wipe enrols a fresh record; ordering by enrolment would spend the cap evicting the browsers a user actually signs in from while the churn survives, and since eviction ends the dropped browser's sessions, that signs them out on a device they never touched. Ordering on use makes each wipe's throwaway records evict each other instead. `last_used` is also what the settings list wants to read: "last used" is the question someone deciding what to sign out is asking, and enrolment does not answer it. Devices live on the anchor, so they ride on `identity_info` alongside `mcp_config` rather than needing a call of their own. Implements docs/ongoing/revocable-app-sessions.md §9.1, §9.2 (S18, S19, S22). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dcee0af commit d6d824c

16 files changed

Lines changed: 457 additions & 1 deletion

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,12 @@ export const idlFactory = ({ IDL }) => {
559559
'address' : IDL.Text,
560560
'last_used' : IDL.Opt(Timestamp),
561561
});
562+
const SessionDeviceInfo = IDL.Record({
563+
'id' : IDL.Nat32,
564+
'name' : IDL.Text,
565+
'created_at' : Timestamp,
566+
'last_used' : Timestamp,
567+
});
562568
const McpConfig = IDL.Record({
563569
'url' : IDL.Opt(IDL.Text),
564570
'enabled' : IDL.Bool,
@@ -575,6 +581,7 @@ export const idlFactory = ({ IDL }) => {
575581
'name' : IDL.Opt(IDL.Text),
576582
'email_recovery' : IDL.Opt(IDL.Vec(EmailRecoveryCredential)),
577583
'created_at' : IDL.Opt(Timestamp),
584+
'session_devices' : IDL.Opt(IDL.Vec(SessionDeviceInfo)),
578585
'mcp_config' : IDL.Opt(McpConfig),
579586
'authn_method_registration' : IDL.Opt(AuthnMethodRegistrationInfo),
580587
'openid_credentials' : IDL.Opt(IDL.Vec(OpenIdCredential)),

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,12 @@ export interface IdentityInfo {
942942
* The timestamp at which the anchor was created
943943
*/
944944
'created_at' : [] | [Timestamp],
945+
/**
946+
* Browsers this anchor has signed in from (absent when it has never
947+
* created a session), so the Settings UI can offer "sign this browser
948+
* out" without a separate call.
949+
*/
950+
'session_devices' : [] | [Array<SessionDeviceInfo>],
945951
/**
946952
* The anchor's synced trusted-MCP-server config (absent when the
947953
* anchor never wrote one). Carried here rather than read from the
@@ -1528,6 +1534,21 @@ export type Salt = Uint8Array | number[];
15281534
export type SessionDelegationError = { 'NoSuchDelegation' : null } |
15291535
{ 'InternalCanisterError' : string } |
15301536
{ 'Unauthorized' : Principal };
1537+
/**
1538+
* A browser an anchor has signed in from. The name is self-reported by the
1539+
* client, so it is a label for the user rather than evidence about where a
1540+
* session came from.
1541+
*/
1542+
export interface SessionDeviceInfo {
1543+
'id' : number,
1544+
'name' : string,
1545+
'created_at' : Timestamp,
1546+
/**
1547+
* Advanced by a sign-in from this browser and by every session refresh it
1548+
* drives, so the Settings UI can show actual use rather than only enrolment.
1549+
*/
1550+
'last_used' : Timestamp,
1551+
}
15311552
export type SessionKey = PublicKey;
15321553
export type SetDefaultAccountError = {
15331554
'NoSuchOrigin' : { 'anchor_number' : UserNumber }

src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const baseIdentityInfo: IdentityInfo = {
4040
created_at: [],
4141
authn_method_registration: [],
4242
openid_credentials: [],
43+
session_devices: [],
4344
mcp_config: [],
4445
};
4546

src/internet_identity/internet_identity.did

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,18 @@ type IdentityAuthnInfo = record {
10061006
recovery_authn_methods : vec AuthnMethod;
10071007
};
10081008

1009+
// A browser an anchor has signed in from. The name is self-reported by the
1010+
// client, so it is a label for the user rather than evidence about where a
1011+
// session came from.
1012+
type SessionDeviceInfo = record {
1013+
id : nat32;
1014+
name : text;
1015+
created_at : Timestamp;
1016+
// Advanced by a sign-in from this browser and by every session refresh it
1017+
// drives, so the Settings UI can show actual use rather than only enrolment.
1018+
last_used : Timestamp;
1019+
};
1020+
10091021
type IdentityInfo = record {
10101022
authn_methods : vec AuthnMethodData;
10111023
authn_method_registration : opt AuthnMethodRegistrationInfo;
@@ -1026,6 +1038,10 @@ type IdentityInfo = record {
10261038
// shows a "limit reached" notice in the wizard when adding
10271039
// beyond the cap.
10281040
verified_emails : opt vec VerifiedEmail;
1041+
// Browsers this anchor has signed in from (absent when it has never
1042+
// created a session), so the Settings UI can offer "sign this browser
1043+
// out" without a separate call.
1044+
session_devices : opt vec SessionDeviceInfo;
10291045
// The anchor's synced trusted-MCP-server config (absent when the
10301046
// anchor never wrote one). Carried here rather than read from the
10311047
// mcp_get_config query so the Settings UI has a certified value to

src/internet_identity/src/email_recovery/remove.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ mod tests {
7575

7676
fn anchor_with(address: Option<&str>) -> Anchor {
7777
let mut a = Anchor {
78+
session_devices: vec![],
79+
next_session_device_id: 0,
7880
anchor_number: 1,
7981
devices: vec![],
8082
openid_credentials: vec![],

src/internet_identity/src/main.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,22 @@ mod v2_api {
11681168
Some(stored_verified_emails)
11691169
};
11701170

1171+
let stored_session_devices: Vec<SessionDeviceInfo> = state::anchor(identity_number)
1172+
.session_devices()
1173+
.iter()
1174+
.map(|device| SessionDeviceInfo {
1175+
id: device.id,
1176+
name: device.name.clone(),
1177+
created_at: device.created_at,
1178+
last_used: device.last_used,
1179+
})
1180+
.collect();
1181+
let session_devices = if stored_session_devices.is_empty() {
1182+
None
1183+
} else {
1184+
Some(stored_session_devices)
1185+
};
1186+
11711187
let identity_info = IdentityInfo {
11721188
authn_methods: anchor_info
11731189
.devices
@@ -1183,6 +1199,7 @@ mod v2_api {
11831199
created_at: anchor_info.created_at,
11841200
email_recovery,
11851201
verified_emails,
1202+
session_devices,
11861203
// The same config `mcp_get_config` serves, but certified: this is
11871204
// an update call, so the Settings UI can render the trusted server
11881205
// — and base the config it writes back — on a value no single node

src/internet_identity/src/storage.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,8 @@ impl<M: Memory + Clone> Storage<M> {
897897
created_at_ns: _,
898898
name: _,
899899
verified_emails: _,
900+
session_devices: _,
901+
next_session_device_id: _,
900902
}) = previous_anchor_maybe
901903
{
902904
(

src/internet_identity/src/storage/anchor.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCr
66
use crate::storage::storable::fixed_anchor::StorableFixedAnchor;
77
use crate::storage::storable::passkey_credential::StorablePasskeyCredential;
88
use crate::storage::storable::recovery_key::StorableRecoveryKey;
9+
use crate::storage::storable::session_device::StorableSessionDevice;
910
use crate::storage::storable::special_device_migration::SpecialDeviceMigration;
1011
use crate::storage::storable::verified_email::StorableVerifiedEmail;
1112
use crate::{IC0_APP_ORIGIN, ID_AI_ORIGIN, INTERNETCOMPUTER_ORG_ORIGIN};
@@ -38,11 +39,50 @@ pub struct Anchor {
3839
pub(crate) email_recovery: Vec<EmailRecoveryCredential>,
3940
/// Capped by `MAX_VERIFIED_EMAILS_PER_ANCHOR`.
4041
pub(crate) verified_emails: Vec<VerifiedEmail>,
42+
/// Capped by `MAX_SESSION_DEVICES`.
43+
pub(crate) session_devices: Vec<SessionDevice>,
44+
pub(crate) next_session_device_id: SessionDeviceId,
4145
pub(crate) metadata: Option<HashMap<String, MetadataEntry>>,
4246
pub(crate) name: Option<String>,
4347
pub(crate) created_at: Option<Timestamp>,
4448
}
4549

50+
/// Bounds the device list, because the anchor blob is read on nearly every
51+
/// authenticated path.
52+
pub const MAX_SESSION_DEVICES: usize = 20;
53+
54+
/// A browser this anchor has signed in from. The name is self-reported by the client,
55+
/// so it is a label for the user rather than evidence about where a session came from.
56+
#[derive(Clone, Debug, Eq, PartialEq)]
57+
pub struct SessionDevice {
58+
pub id: SessionDeviceId,
59+
pub name: String,
60+
pub created_at: Timestamp,
61+
pub last_used: Timestamp,
62+
}
63+
64+
impl From<StorableSessionDevice> for SessionDevice {
65+
fn from(value: StorableSessionDevice) -> Self {
66+
SessionDevice {
67+
id: value.id,
68+
name: value.name,
69+
created_at: value.created_at,
70+
last_used: value.last_used,
71+
}
72+
}
73+
}
74+
75+
impl From<SessionDevice> for StorableSessionDevice {
76+
fn from(value: SessionDevice) -> Self {
77+
StorableSessionDevice {
78+
id: value.id,
79+
name: value.name,
80+
created_at: value.created_at,
81+
last_used: value.last_used,
82+
}
83+
}
84+
}
85+
4686
impl Device {
4787
/// Applies the values of `device_data` to self while leaving the other fields intact.
4888
pub fn apply_device_data(&mut self, device_data: DeviceData) {
@@ -175,6 +215,8 @@ impl From<Anchor> for (StorableFixedAnchor, StorableAnchor) {
175215
openid_credentials,
176216
email_recovery,
177217
verified_emails,
218+
session_devices,
219+
next_session_device_id,
178220
metadata,
179221
name,
180222
created_at,
@@ -194,6 +236,13 @@ impl From<Anchor> for (StorableFixedAnchor, StorableAnchor) {
194236
.map(StorableVerifiedEmail::from)
195237
.collect(),
196238
);
239+
let next_session_device_id = Some(next_session_device_id);
240+
let session_devices = Some(
241+
session_devices
242+
.into_iter()
243+
.map(StorableSessionDevice::from)
244+
.collect(),
245+
);
197246

198247
let (mut passkey_credentials, mut recovery_keys, mut recovery_devices) =
199248
(vec![], vec![], vec![]);
@@ -433,6 +482,8 @@ impl From<Anchor> for (StorableFixedAnchor, StorableAnchor) {
433482
recovery_keys,
434483
email_recovery,
435484
verified_emails,
485+
session_devices,
486+
next_session_device_id,
436487
},
437488
)
438489
}
@@ -448,6 +499,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor {
448499
recovery_keys,
449500
email_recovery,
450501
verified_emails,
502+
session_devices,
503+
next_session_device_id,
451504
} = storable_anchor;
452505

453506
let name = name.clone();
@@ -466,6 +519,12 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor {
466519
.into_iter()
467520
.map(VerifiedEmail::from)
468521
.collect();
522+
let session_devices = session_devices
523+
.unwrap_or_default()
524+
.into_iter()
525+
.map(SessionDevice::from)
526+
.collect();
527+
let next_session_device_id = next_session_device_id.unwrap_or_default();
469528

470529
let mut devices = passkey_credentials
471530
.unwrap_or_default()
@@ -560,6 +619,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor {
560619
openid_credentials,
561620
email_recovery,
562621
verified_emails,
622+
session_devices,
623+
next_session_device_id,
563624
devices,
564625
metadata,
565626
}
@@ -586,6 +647,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option<StorableAnchor>)> for Ancho
586647
openid_credentials: vec![],
587648
email_recovery: vec![],
588649
verified_emails: vec![],
650+
session_devices: vec![],
651+
next_session_device_id: 0,
589652
anchor_number,
590653
devices,
591654
metadata,
@@ -612,13 +675,21 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option<StorableAnchor>)> for Ancho
612675
.into_iter()
613676
.map(VerifiedEmail::from)
614677
.collect();
678+
let session_devices = storable_anchor
679+
.session_devices
680+
.unwrap_or_default()
681+
.into_iter()
682+
.map(SessionDevice::from)
683+
.collect();
615684

616685
Anchor {
617686
anchor_number,
618687
devices,
619688
openid_credentials,
620689
email_recovery,
621690
verified_emails,
691+
session_devices,
692+
next_session_device_id: storable_anchor.next_session_device_id.unwrap_or_default(),
622693
metadata,
623694
name,
624695
created_at,
@@ -627,6 +698,63 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option<StorableAnchor>)> for Ancho
627698
}
628699

629700
impl Anchor {
701+
pub fn session_devices(&self) -> &[SessionDevice] {
702+
&self.session_devices
703+
}
704+
705+
/// Resolves the browser a sign-in came from, registering it when the client has no
706+
/// id yet or presents one this anchor does not know.
707+
///
708+
/// The client never chooses the id: an id it does not own resolves to a fresh
709+
/// registration rather than to somebody else's device. At the cap the least recently
710+
/// used records are dropped rather than the registration failing, which never costs
711+
/// anyone a sign-in. Their ids are returned so the caller can end their sessions too:
712+
/// a session whose device is no longer listed could not be signed out from settings.
713+
pub fn resolve_session_device(
714+
&mut self,
715+
device_id: Option<SessionDeviceId>,
716+
name: String,
717+
now: Timestamp,
718+
) -> (SessionDeviceId, Vec<SessionDeviceId>) {
719+
if let Some(device_id) = device_id {
720+
if let Some(device) = self
721+
.session_devices
722+
.iter_mut()
723+
.find(|device| device.id == device_id)
724+
{
725+
device.last_used = now;
726+
return (device_id, vec![]);
727+
}
728+
}
729+
730+
let id = self.next_session_device_id;
731+
self.next_session_device_id = self.next_session_device_id.saturating_add(1);
732+
self.session_devices.push(SessionDevice {
733+
id,
734+
name,
735+
created_at: now,
736+
last_used: now,
737+
});
738+
739+
let mut dropped = vec![];
740+
while self.session_devices.len() > MAX_SESSION_DEVICES {
741+
let least_recently_used = self
742+
.session_devices
743+
.iter()
744+
.enumerate()
745+
.min_by_key(|(_, device)| (device.last_used, device.id))
746+
.map(|(index, _)| index);
747+
match least_recently_used {
748+
Some(index) => {
749+
dropped.push(self.session_devices.remove(index).id);
750+
}
751+
None => break,
752+
}
753+
}
754+
755+
(id, dropped)
756+
}
757+
630758
/// Creation of new anchors is restricted in order to make sure that the device checks are
631759
/// not accidentally bypassed.
632760
pub fn new(anchor_number: AnchorNumber, created_at: Timestamp) -> Anchor {
@@ -637,6 +765,8 @@ impl Anchor {
637765
openid_credentials: vec![],
638766
email_recovery: vec![],
639767
verified_emails: vec![],
768+
session_devices: vec![],
769+
next_session_device_id: 0,
640770
metadata: None,
641771
name: None,
642772
}

0 commit comments

Comments
 (0)