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:
parent
d6b4c46b57
commit
6921498542
4 changed files with 966 additions and 12 deletions
139
halkyon_learn_client/lib/src/protocol/auth/device.dart
Normal file
139
halkyon_learn_client/lib/src/protocol/auth/device.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue