feat(server): the authorization pattern, with "Your devices"

Serverpod has no row-level security, so every endpoint enforces access itself, by three rules established here once: the person comes from the session, never from the client, and every query filters by them; someone else's row answers exactly like a missing one (NotFoundException, shared in lib/src/common); endpoints for people with an identity declare requiredScopes: {identified} instead of checking it themselves.

DevicesEndpoint is the first such endpoint: it lists your sessions and signs one of them out, for a lost phone. It uses the existing session table, so no migration. Serverpod's own helpers are unguarded (revokeSession deletes by id alone; listSessions without authUserId returns everyone's sessions), so the endpoint puts the owner check into the same query and then notifies the revoked device as revokeSession does. Each query carries its SQL as a comment.

The identified scope lives in lib/src/auth/scopes.dart. grantIdentified gives it to a person and to their existing session rows, because sessions keep the copy of the scopes they were started with; step 9 uses it.

Tests assert the refusals first: anonymous people and callers who aren't signed in are refused; the list shows only your own devices; signing out someone else's device fails exactly like an unknown id and leaves it alone; grantIdentified reaches existing sessions. The shared fixtures live in test/integration/support. AGENTS.md names the endpoint as the reference and warns about the unguarded helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 09:36:04 +02:00
commit 6921498542
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 966 additions and 12 deletions

View file

@ -0,0 +1,139 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A device someone is signed in on, i.e. one of their sessions.
abstract class Device
implements _isc.SerializableModel, _isc.ProtocolSerialization {
Device._({
required this.sessionId,
required this.method,
required this.createdAt,
required this.lastUsedAt,
required this.isThisDevice,
});
factory Device({
required _isc.UuidValue sessionId,
required String method,
required DateTime createdAt,
required DateTime lastUsedAt,
required bool isThisDevice,
}) = _DeviceImpl;
factory Device.fromJson(Map<String, dynamic> jsonSerialization) {
return Device(
sessionId: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['sessionId'],
),
method: jsonSerialization['method'] as String,
createdAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
lastUsedAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['lastUsedAt'],
),
isThisDevice: _isc.BoolJsonExtension.fromJson(
jsonSerialization['isThisDevice'],
),
);
}
/// The session's id; signing the device out takes it.
_isc.UuidValue sessionId;
/// How the session was started, such as "anonymous" or "github".
String method;
DateTime createdAt;
DateTime lastUsedAt;
/// Whether this is the device that is asking.
bool isThisDevice;
/// Returns a shallow copy of this [Device]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
Device copyWith({
_isc.UuidValue? sessionId,
String? method,
DateTime? createdAt,
DateTime? lastUsedAt,
bool? isThisDevice,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Device',
'sessionId': sessionId.toJson(),
'method': method,
'createdAt': createdAt.toJson(),
'lastUsedAt': lastUsedAt.toJson(),
'isThisDevice': isThisDevice,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Device',
'sessionId': sessionId.toJson(),
'method': method,
'createdAt': createdAt.toJson(),
'lastUsedAt': lastUsedAt.toJson(),
'isThisDevice': isThisDevice,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _DeviceImpl extends Device {
_DeviceImpl({
required _isc.UuidValue sessionId,
required String method,
required DateTime createdAt,
required DateTime lastUsedAt,
required bool isThisDevice,
}) : super._(
sessionId: sessionId,
method: method,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
isThisDevice: isThisDevice,
);
/// Returns a shallow copy of this [Device]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
Device copyWith({
_isc.UuidValue? sessionId,
String? method,
DateTime? createdAt,
DateTime? lastUsedAt,
bool? isThisDevice,
}) {
return Device(
sessionId: sessionId ?? this.sessionId,
method: method ?? this.method,
createdAt: createdAt ?? this.createdAt,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
isThisDevice: isThisDevice ?? this.isThisDevice,
);
}
}

View file

@ -11,6 +11,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida;
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9;
import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -41,6 +43,38 @@ class EndpointAnonymousIdp extends _iaic.EndpointAnonymousIdpBase {
);
}
/// Your devices: the sessions you're signed in with, and signing one of them
/// out, for example a lost phone.
///
/// Serverpod has no row-level security, so this endpoint enforces access
/// itself, like every endpoint: the person always comes from the session,
/// never from the client; every query filters by them; and someone else's
/// device answers exactly like a missing one.
/// {@category Endpoint}
class EndpointDevices extends _isc.EndpointRef {
EndpointDevices(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'devices';
/// Your devices, the most recently used first.
_ida.Future<List<_ieqh4yr9.Device>> list() =>
caller.callServerEndpoint<List<_ieqh4yr9.Device>>(
'devices',
'list',
{},
);
/// Signs out one of your devices. Throws [NotFoundException] when it isn't
/// yours, exactly as when it doesn't exist.
_ida.Future<void> signOut(_isc.UuidValue sessionId) =>
caller.callServerEndpoint<void>(
'devices',
'signOut',
{'sessionId': sessionId},
);
}
class Modules {
Modules(Client client) {
serverpod_auth_idp = _iaic.Caller(client);
@ -80,16 +114,20 @@ class Client extends _isc.ServerpodClientShared {
httpClientOverride: httpClientOverride,
) {
anonymousIdp = EndpointAnonymousIdp(this);
devices = EndpointDevices(this);
modules = Modules(this);
}
late final EndpointAnonymousIdp anonymousIdp;
late final EndpointDevices devices;
late final Modules modules;
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {
'anonymousIdp': anonymousIdp,
'devices': devices,
};
@override

View file

@ -0,0 +1,69 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Something doesn't exist, or doesn't belong to the caller. Both look exactly
/// the same, so nobody can find out which ids exist.
abstract class NotFoundException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
NotFoundException._({required this.what});
factory NotFoundException({required String what}) = _NotFoundExceptionImpl;
factory NotFoundException.fromJson(Map<String, dynamic> jsonSerialization) {
return NotFoundException(what: jsonSerialization['what'] as String);
}
/// What wasn't found, such as "device".
String what;
/// Returns a shallow copy of this [NotFoundException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
NotFoundException copyWith({String? what});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'NotFoundException',
'what': what,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'NotFoundException',
'what': what,
};
}
@override
String toString() {
return 'NotFoundException(what: $what)';
}
}
class _NotFoundExceptionImpl extends NotFoundException {
_NotFoundExceptionImpl({required String what}) : super._(what: what);
/// Returns a shallow copy of this [NotFoundException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
NotFoundException copyWith({String? what}) {
return NotFoundException(what: what ?? this.what);
}
}

View file

@ -11,11 +11,17 @@
// ignore_for_file: dead_code, unnecessary_type_check
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
as _iaic;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import 'auth/device.dart' as _i6c5ny7n;
import 'common/not_found_exception.dart' as _i8oee73b;
export 'auth/device.dart';
export 'common/not_found_exception.dart';
export 'client.dart';
class Protocol extends _isc.SerializationManager {
@ -52,6 +58,25 @@ class Protocol extends _isc.SerializationManager {
}
}
if (t == _i6c5ny7n.Device) {
return _i6c5ny7n.Device.fromJson(data) as T;
}
if (t == _i8oee73b.NotFoundException) {
return _i8oee73b.NotFoundException.fromJson(data) as T;
}
if (t == _isc.getType<_i6c5ny7n.Device?>()) {
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i8oee73b.NotFoundException?>()) {
return (data != null ? _i8oee73b.NotFoundException.fromJson(data) : null)
as T;
}
if (t == List<_ieqh4yr9.Device>) {
return (data as List)
.map((e) => deserialize<_ieqh4yr9.Device>(e))
.toList()
as T;
}
try {
return _iaic.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -63,6 +88,8 @@ class Protocol extends _isc.SerializationManager {
static String? getClassNameForType(Type type) {
return switch (type) {
_i6c5ny7n.Device => 'Device',
_i8oee73b.NotFoundException => 'NotFoundException',
_ => null,
};
}
@ -79,6 +106,12 @@ class Protocol extends _isc.SerializationManager {
);
}
switch (data) {
case _i6c5ny7n.Device():
return 'Device';
case _i8oee73b.NotFoundException():
return 'NotFoundException';
}
className = _iaic.Protocol().getClassNameForObject(data);
if (className != null) {
return className.contains('.')
@ -100,6 +133,12 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName is! String) {
return super.deserializeByClassName(data);
}
if (dataClassName == 'Device') {
return deserialize<_i6c5ny7n.Device>(data['data']);
}
if (dataClassName == 'NotFoundException') {
return deserialize<_i8oee73b.NotFoundException>(data['data']);
}
if (dataClassName.startsWith('serverpod_auth_idp.')) {
data['className'] = dataClassName.substring(19);
return _iaic.Protocol().deserializeByClassName(data);