Skip to content

Commit 95afa5f

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}` 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 oldest 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. 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 da0fd37 commit 95afa5f

16 files changed

Lines changed: 384 additions & 1 deletion

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,11 @@ 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+
});
562567
const McpConfig = IDL.Record({
563568
'url' : IDL.Opt(IDL.Text),
564569
'enabled' : IDL.Bool,
@@ -575,6 +580,7 @@ export const idlFactory = ({ IDL }) => {
575580
'name' : IDL.Opt(IDL.Text),
576581
'email_recovery' : IDL.Opt(IDL.Vec(EmailRecoveryCredential)),
577582
'created_at' : IDL.Opt(Timestamp),
583+
'session_devices' : IDL.Opt(IDL.Vec(SessionDeviceInfo)),
578584
'mcp_config' : IDL.Opt(McpConfig),
579585
'authn_method_registration' : IDL.Opt(AuthnMethodRegistrationInfo),
580586
'openid_credentials' : IDL.Opt(IDL.Vec(OpenIdCredential)),

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

Lines changed: 16 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,16 @@ 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+
}
15311547
export type SessionKey = PublicKey;
15321548
export type SetDefaultAccountError = {
15331549
'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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,15 @@ 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+
};
1017+
10091018
type IdentityInfo = record {
10101019
authn_methods : vec AuthnMethodData;
10111020
authn_method_registration : opt AuthnMethodRegistrationInfo;
@@ -1026,6 +1035,10 @@ type IdentityInfo = record {
10261035
// shows a "limit reached" notice in the wizard when adding
10271036
// beyond the cap.
10281037
verified_emails : opt vec VerifiedEmail;
1038+
// Browsers this anchor has signed in from (absent when it has never
1039+
// created a session), so the Settings UI can offer "sign this browser
1040+
// out" without a separate call.
1041+
session_devices : opt vec SessionDeviceInfo;
10291042
// The anchor's synced trusted-MCP-server config (absent when the
10301043
// anchor never wrote one). Carried here rather than read from the
10311044
// 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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,21 @@ 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+
})
1179+
.collect();
1180+
let session_devices = if stored_session_devices.is_empty() {
1181+
None
1182+
} else {
1183+
Some(stored_session_devices)
1184+
};
1185+
11711186
let identity_info = IdentityInfo {
11721187
authn_methods: anchor_info
11731188
.devices
@@ -1183,6 +1198,7 @@ mod v2_api {
11831198
created_at: anchor_info.created_at,
11841199
email_recovery,
11851200
verified_emails,
1201+
session_devices,
11861202
// The same config `mcp_get_config` serves, but certified: this is
11871203
// an update call, so the Settings UI can render the trusted server
11881204
// — 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: 125 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,47 @@ 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+
}
62+
63+
impl From<StorableSessionDevice> for SessionDevice {
64+
fn from(value: StorableSessionDevice) -> Self {
65+
SessionDevice {
66+
id: value.id,
67+
name: value.name,
68+
created_at: value.created_at,
69+
}
70+
}
71+
}
72+
73+
impl From<SessionDevice> for StorableSessionDevice {
74+
fn from(value: SessionDevice) -> Self {
75+
StorableSessionDevice {
76+
id: value.id,
77+
name: value.name,
78+
created_at: value.created_at,
79+
}
80+
}
81+
}
82+
4683
impl Device {
4784
/// Applies the values of `device_data` to self while leaving the other fields intact.
4885
pub fn apply_device_data(&mut self, device_data: DeviceData) {
@@ -175,6 +212,8 @@ impl From<Anchor> for (StorableFixedAnchor, StorableAnchor) {
175212
openid_credentials,
176213
email_recovery,
177214
verified_emails,
215+
session_devices,
216+
next_session_device_id,
178217
metadata,
179218
name,
180219
created_at,
@@ -194,6 +233,13 @@ impl From<Anchor> for (StorableFixedAnchor, StorableAnchor) {
194233
.map(StorableVerifiedEmail::from)
195234
.collect(),
196235
);
236+
let next_session_device_id = Some(next_session_device_id);
237+
let session_devices = Some(
238+
session_devices
239+
.into_iter()
240+
.map(StorableSessionDevice::from)
241+
.collect(),
242+
);
197243

198244
let (mut passkey_credentials, mut recovery_keys, mut recovery_devices) =
199245
(vec![], vec![], vec![]);
@@ -433,6 +479,8 @@ impl From<Anchor> for (StorableFixedAnchor, StorableAnchor) {
433479
recovery_keys,
434480
email_recovery,
435481
verified_emails,
482+
session_devices,
483+
next_session_device_id,
436484
},
437485
)
438486
}
@@ -448,6 +496,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor {
448496
recovery_keys,
449497
email_recovery,
450498
verified_emails,
499+
session_devices,
500+
next_session_device_id,
451501
} = storable_anchor;
452502

453503
let name = name.clone();
@@ -466,6 +516,12 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor {
466516
.into_iter()
467517
.map(VerifiedEmail::from)
468518
.collect();
519+
let session_devices = session_devices
520+
.unwrap_or_default()
521+
.into_iter()
522+
.map(SessionDevice::from)
523+
.collect();
524+
let next_session_device_id = next_session_device_id.unwrap_or_default();
469525

470526
let mut devices = passkey_credentials
471527
.unwrap_or_default()
@@ -560,6 +616,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor {
560616
openid_credentials,
561617
email_recovery,
562618
verified_emails,
619+
session_devices,
620+
next_session_device_id,
563621
devices,
564622
metadata,
565623
}
@@ -586,6 +644,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option<StorableAnchor>)> for Ancho
586644
openid_credentials: vec![],
587645
email_recovery: vec![],
588646
verified_emails: vec![],
647+
session_devices: vec![],
648+
next_session_device_id: 0,
589649
anchor_number,
590650
devices,
591651
metadata,
@@ -612,13 +672,21 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option<StorableAnchor>)> for Ancho
612672
.into_iter()
613673
.map(VerifiedEmail::from)
614674
.collect();
675+
let session_devices = storable_anchor
676+
.session_devices
677+
.unwrap_or_default()
678+
.into_iter()
679+
.map(SessionDevice::from)
680+
.collect();
615681

616682
Anchor {
617683
anchor_number,
618684
devices,
619685
openid_credentials,
620686
email_recovery,
621687
verified_emails,
688+
session_devices,
689+
next_session_device_id: storable_anchor.next_session_device_id.unwrap_or_default(),
622690
metadata,
623691
name,
624692
created_at,
@@ -627,6 +695,61 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option<StorableAnchor>)> for Ancho
627695
}
628696

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

0 commit comments

Comments
 (0)