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
20 changed files with 966 additions and 12 deletions

View file

@ -95,6 +95,9 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too. - **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too.
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub) attach to it. Content references `AuthUser`, never a provider account. - **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub) attach to it. Content references `AuthUser`, never a provider account.
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc. - **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
- **Content envelope**: content tables carry `audience`, `keyId` (null = not yet encrypted), `payload` (`ByteData`) and `nonce`. Content fields live inside `payload`, so the server can't sort or search by them. - **Content envelope**: content tables carry `audience`, `keyId` (null = not yet encrypted), `payload` (`ByteData`) and `nonce`. Content fields live inside `payload`, so the server can't sort or search by them.
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them. - **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
- **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in. - **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in.

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 // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida; 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:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart' import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc; 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 { class Modules {
Modules(Client client) { Modules(Client client) {
serverpod_auth_idp = _iaic.Caller(client); serverpod_auth_idp = _iaic.Caller(client);
@ -80,16 +114,20 @@ class Client extends _isc.ServerpodClientShared {
httpClientOverride: httpClientOverride, httpClientOverride: httpClientOverride,
) { ) {
anonymousIdp = EndpointAnonymousIdp(this); anonymousIdp = EndpointAnonymousIdp(this);
devices = EndpointDevices(this);
modules = Modules(this); modules = Modules(this);
} }
late final EndpointAnonymousIdp anonymousIdp; late final EndpointAnonymousIdp anonymousIdp;
late final EndpointDevices devices;
late final Modules modules; late final Modules modules;
@override @override
Map<String, _isc.EndpointRef> get endpointRefLookup => { Map<String, _isc.EndpointRef> get endpointRefLookup => {
'anonymousIdp': anonymousIdp, 'anonymousIdp': anonymousIdp,
'devices': devices,
}; };
@override @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: dead_code, unnecessary_type_check
// ignore_for_file: no_leading_underscores_for_library_prefixes // 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' import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc; as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart' import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
as _iaic; as _iaic;
import 'package:serverpod_client/serverpod_client.dart' as _isc; 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'; export 'client.dart';
class Protocol extends _isc.SerializationManager { 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 { try {
return _iaic.Protocol().deserialize<T>(data, t); return _iaic.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {} } on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -63,6 +88,8 @@ class Protocol extends _isc.SerializationManager {
static String? getClassNameForType(Type type) { static String? getClassNameForType(Type type) {
return switch (type) { return switch (type) {
_i6c5ny7n.Device => 'Device',
_i8oee73b.NotFoundException => 'NotFoundException',
_ => null, _ => 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); className = _iaic.Protocol().getClassNameForObject(data);
if (className != null) { if (className != null) {
return className.contains('.') return className.contains('.')
@ -100,6 +133,12 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName is! String) { if (dataClassName is! String) {
return super.deserializeByClassName(data); 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.')) { if (dataClassName.startsWith('serverpod_auth_idp.')) {
data['className'] = dataClassName.substring(19); data['className'] = dataClassName.substring(19);
return _iaic.Protocol().deserializeByClassName(data); return _iaic.Protocol().deserializeByClassName(data);

View file

@ -0,0 +1,11 @@
### A device someone is signed in on, i.e. one of their sessions.
class: Device
fields:
### The session's id; signing the device out takes it.
sessionId: UuidValue
### How the session was started, such as "anonymous" or "github".
method: String
createdAt: DateTime
lastUsedAt: DateTime
### Whether this is the device that is asking.
isThisDevice: bool

View file

@ -0,0 +1,64 @@
import 'package:halkyon_learn_server/src/auth/scopes.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// 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.
class DevicesEndpoint extends Endpoint {
/// An anonymous account only ever has one device.
@override
Set<Scope> get requiredScopes => {identified};
/// Your devices, the most recently used first.
Future<List<Device>> list(Session session) async {
final auth = session.authenticated!;
// SELECT … FROM "serverpod_auth_core_session"
// WHERE "authUserId" = $1 ORDER BY "lastUsedAt" DESC
// Serverpod's own listSessions returns everyone's sessions when its
// authUserId filter is left out; the filter here is not optional.
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(auth.authUserId),
orderBy: (t) => t.lastUsedAt.desc(),
);
return [
for (final serverSideSession in sessions)
Device(
sessionId: serverSideSession.id!,
method: serverSideSession.method,
createdAt: serverSideSession.createdAt,
lastUsedAt: serverSideSession.lastUsedAt,
isThisDevice: serverSideSession.id.toString() == auth.authId,
),
];
}
/// Signs out one of your devices. Throws [NotFoundException] when it isn't
/// yours, exactly as when it doesn't exist.
Future<void> signOut(Session session, UuidValue sessionId) async {
final person = session.authenticated!.authUserId;
// DELETE FROM "serverpod_auth_core_session"
// WHERE "id" = $1 AND "authUserId" = $2
// Serverpod's own revokeSession deletes by id alone, so it would let
// anyone sign out anyone; the owner check here is part of the same query.
final deleted = await ServerSideSession.db.deleteWhere(
session,
where: (t) => t.id.equals(sessionId) & t.authUserId.equals(person),
);
if (deleted.isEmpty) {
throw NotFoundException(what: 'device');
}
// What revokeSession does after deleting: closes the device's open
// connections, so it notices at once.
await session.messages.authenticationRevoked(
person.uuid,
RevokedAuthenticationAuthId(authId: sessionId.toString()),
);
}
}

View file

@ -0,0 +1,43 @@
import 'package:halkyon_learn_server/src/auth/scopes.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Gives a person the [identified] scope, once they have linked an identity.
///
/// Sessions keep the copy of the scopes they were started with, so the
/// person's existing sessions get the scope too; otherwise it would only reach
/// sessions started later.
Future<void> grantIdentified(Session session, UuidValue authUserId) =>
session.db.transaction((transaction) async {
final authUsers = AuthServices.instance.authUsers;
final person = await authUsers.get(
session,
authUserId: authUserId,
transaction: transaction,
);
await authUsers.update(
session,
authUserId: authUserId,
scopes: {...person.scopeNames.map(Scope.new), identified},
transaction: transaction,
);
// UPDATE "serverpod_auth_core_session" SET "scopeNames" = …
// WHERE "id" = $1 (one row per session of the person)
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
await ServerSideSession.db.update(
session,
[
for (final serverSideSession in sessions)
serverSideSession.copyWith(
scopeNames: {...serverSideSession.scopeNames, identified.name!},
),
],
columns: (t) => [t.scopeNames],
transaction: transaction,
);
});

View file

@ -0,0 +1,8 @@
import 'package:serverpod/serverpod.dart';
/// People with an identity, such as GitHub, rather than only an anonymous
/// account. Granted when they link one (see `grantIdentified`).
///
/// Endpoints that involve other people or several devices require it through
/// `requiredScopes`, never through checks of their own.
const identified = Scope('identified');

View file

@ -0,0 +1,6 @@
### Something doesn't exist, or doesn't belong to the caller. Both look exactly
### the same, so nobody can find out which ids exist.
exception: NotFoundException
fields:
### What wasn't found, such as "device".
what: String

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/serverpod.dart' as _is;
/// A device someone is signed in on, i.e. one of their sessions.
abstract class Device
implements _is.SerializableModel, _is.ProtocolSerialization {
Device._({
required this.sessionId,
required this.method,
required this.createdAt,
required this.lastUsedAt,
required this.isThisDevice,
});
factory Device({
required _is.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: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['sessionId'],
),
method: jsonSerialization['method'] as String,
createdAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
lastUsedAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['lastUsedAt'],
),
isThisDevice: _is.BoolJsonExtension.fromJson(
jsonSerialization['isThisDevice'],
),
);
}
/// The session's id; signing the device out takes it.
_is.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.
@_is.useResult
Device copyWith({
_is.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 _is.SerializationManager.encode(this);
}
}
class _DeviceImpl extends Device {
_DeviceImpl({
required _is.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.
@_is.useResult
@override
Device copyWith({
_is.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

@ -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/serverpod.dart' as _is;
/// 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
_is.SerializableException,
_is.SerializableModel,
_is.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.
@_is.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.
@_is.useResult
@override
NotFoundException copyWith({String? what}) {
return NotFoundException(what: what ?? this.what);
}
}

View file

@ -16,6 +16,7 @@ import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart' import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais; as _iais;
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6; import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
import '../auth/devices_endpoint.dart' as _iduct7ue;
class Endpoints extends _is.EndpointDispatch { class Endpoints extends _is.EndpointDispatch {
@override @override
@ -27,6 +28,12 @@ class Endpoints extends _is.EndpointDispatch {
'anonymousIdp', 'anonymousIdp',
null, null,
), ),
'devices': _iduct7ue.DevicesEndpoint()
..initialize(
server,
'devices',
null,
),
}; };
connectors['anonymousIdp'] = _is.EndpointConnector( connectors['anonymousIdp'] = _is.EndpointConnector(
name: 'anonymousIdp', name: 'anonymousIdp',
@ -54,6 +61,41 @@ class Endpoints extends _is.EndpointDispatch {
), ),
}, },
); );
connectors['devices'] = _is.EndpointConnector(
name: 'devices',
endpoint: endpoints['devices']!,
methodConnectors: {
'list': _is.MethodConnector(
name: 'list',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['devices'] as _iduct7ue.DevicesEndpoint)
.list(session),
),
'signOut': _is.MethodConnector(
name: 'signOut',
params: {
'sessionId': _is.ParameterDescription(
name: 'sessionId',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['devices'] as _iduct7ue.DevicesEndpoint).signOut(
session,
params['sessionId'],
),
),
},
);
modules['serverpod_auth_idp'] = _iais.Endpoints() modules['serverpod_auth_idp'] = _iais.Endpoints()
..initializeEndpoints(server); ..initializeEndpoints(server);
modules['serverpod_auth_core'] = _iacs.Endpoints() modules['serverpod_auth_core'] = _iacs.Endpoints()

View file

@ -11,12 +11,18 @@
// ignore_for_file: dead_code, unnecessary_type_check // ignore_for_file: dead_code, unnecessary_type_check
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_server/src/generated/auth/device.dart'
as _ipdmo8cq;
import 'package:serverpod/protocol.dart' as _isp; import 'package:serverpod/protocol.dart' as _isp;
import 'package:serverpod/serverpod.dart' as _is; import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart' import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs; as _iacs;
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart' import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais; as _iais;
import 'auth/device.dart' as _i6c5ny7n;
import 'common/not_found_exception.dart' as _i8oee73b;
export 'auth/device.dart';
export 'common/not_found_exception.dart';
class Protocol extends _is.DatabaseSerializationManager { class Protocol extends _is.DatabaseSerializationManager {
Protocol._(); Protocol._();
@ -58,6 +64,25 @@ class Protocol extends _is.DatabaseSerializationManager {
} }
} }
if (t == _i6c5ny7n.Device) {
return _i6c5ny7n.Device.fromJson(data) as T;
}
if (t == _i8oee73b.NotFoundException) {
return _i8oee73b.NotFoundException.fromJson(data) as T;
}
if (t == _is.getType<_i6c5ny7n.Device?>()) {
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
}
if (t == _is.getType<_i8oee73b.NotFoundException?>()) {
return (data != null ? _i8oee73b.NotFoundException.fromJson(data) : null)
as T;
}
if (t == List<_ipdmo8cq.Device>) {
return (data as List)
.map((e) => deserialize<_ipdmo8cq.Device>(e))
.toList()
as T;
}
try { try {
return _iais.Protocol().deserialize<T>(data, t); return _iais.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {} } on _is.DeserializationTypeNotFoundException catch (_) {}
@ -72,6 +97,8 @@ class Protocol extends _is.DatabaseSerializationManager {
static String? getClassNameForType(Type type) { static String? getClassNameForType(Type type) {
return switch (type) { return switch (type) {
_i6c5ny7n.Device => 'Device',
_i8oee73b.NotFoundException => 'NotFoundException',
_ => null, _ => null,
}; };
} }
@ -88,6 +115,12 @@ class Protocol extends _is.DatabaseSerializationManager {
); );
} }
switch (data) {
case _i6c5ny7n.Device():
return 'Device';
case _i8oee73b.NotFoundException():
return 'NotFoundException';
}
className = _iais.Protocol().getClassNameForObject(data); className = _iais.Protocol().getClassNameForObject(data);
if (className != null) { if (className != null) {
return className.contains('.') return className.contains('.')
@ -113,6 +146,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName is! String) { if (dataClassName is! String) {
return super.deserializeByClassName(data); 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.')) { if (dataClassName.startsWith('serverpod_auth_idp.')) {
data['className'] = dataClassName.substring(19); data['className'] = dataClassName.substring(19);
return _iais.Protocol().deserializeByClassName(data); return _iais.Protocol().deserializeByClassName(data);

View file

@ -1,2 +1,5 @@
anonymousIdp: anonymousIdp:
- login: - login:
devices:
- list:
- signOut:

View file

@ -1,8 +1,8 @@
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../test_tools/serverpod_test_tools.dart'; import '../test_tools/serverpod_test_tools.dart';
void main() { void main() {
@ -14,11 +14,7 @@ void main() {
rollbackDatabase: RollbackDatabase.disabled, rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) { (sessionBuilder, endpoints) {
setUp(() async { setUp(() async {
// The same configuration the server runs with (lib/server.dart). useServerAuth();
AuthServices.set(
tokenManagerBuilders: tokenManagerBuilders,
identityProviderBuilders: identityProviderBuilders,
);
await _deleteAll(sessionBuilder.build()); await _deleteAll(sessionBuilder.build());
}); });
tearDown(() => _deleteAll(sessionBuilder.build())); tearDown(() => _deleteAll(sessionBuilder.build()));

View file

@ -0,0 +1,111 @@
import 'package:halkyon_learn_server/src/auth/scopes.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('Your devices', (sessionBuilder, endpoints) {
late UuidValue alice;
late UuidValue aliceLaptop;
late UuidValue alicePhone;
late UuidValue bob;
late UuidValue bobPhone;
setUp(() async {
useServerAuth();
final session = sessionBuilder.build();
final authUsers = AuthServices.instance.authUsers;
alice = (await authUsers.create(session, scopes: {identified})).id;
bob = (await authUsers.create(session, scopes: {identified})).id;
aliceLaptop = await signInNewDevice(session, alice, scopes: {identified});
alicePhone = await signInNewDevice(session, alice, scopes: {identified});
bobPhone = await signInNewDevice(session, bob, scopes: {identified});
});
/// A caller signed in as [person] on [device], identified unless [scopes]
/// says otherwise.
TestSessionBuilder signedIn(
UuidValue person,
UuidValue device, {
Set<Scope>? scopes,
}) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
scopes ?? {identified},
authId: device.uuid,
),
);
test('refuses anonymous people', () async {
await expectLater(
endpoints.devices.list(signedIn(alice, aliceLaptop, scopes: {})),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
});
test('refuses callers who are not signed in', () async {
await expectLater(
endpoints.devices.list(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('lists only your own devices, and marks the one asking', () async {
final devices = await endpoints.devices.list(
signedIn(alice, aliceLaptop),
);
expect(
devices.map((device) => device.sessionId),
unorderedEquals([aliceLaptop, alicePhone]),
);
expect(
devices.singleWhere((device) => device.isThisDevice).sessionId,
aliceLaptop,
);
});
test('signs out one of your devices', () async {
await endpoints.devices.signOut(signedIn(alice, aliceLaptop), alicePhone);
final devices = await endpoints.devices.list(
signedIn(alice, aliceLaptop),
);
expect(devices.map((device) => device.sessionId), [aliceLaptop]);
});
test(
"refuses to sign out someone else's device, exactly like a missing one",
() async {
final notFound = throwsA(
isA<NotFoundException>().having((e) => e.what, 'what', 'device'),
);
await expectLater(
endpoints.devices.signOut(signedIn(alice, aliceLaptop), bobPhone),
notFound,
);
await expectLater(
endpoints.devices.signOut(
signedIn(alice, aliceLaptop),
const Uuid().v4obj(),
),
notFound,
);
final bobsDevices = await endpoints.devices.list(
signedIn(bob, bobPhone),
);
expect(bobsDevices.map((device) => device.sessionId), [bobPhone]);
},
);
});
}

View file

@ -0,0 +1,27 @@
import 'package:halkyon_learn_server/src/auth/identified.dart';
import 'package:halkyon_learn_server/src/auth/scopes.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('grantIdentified', (sessionBuilder, endpoints) {
setUp(useServerAuth);
test("reaches the person's existing sessions, not only new ones", () async {
final session = sessionBuilder.build();
final authUsers = AuthServices.instance.authUsers;
final person = (await authUsers.create(session)).id;
final device = await signInNewDevice(session, person);
await grantIdentified(session, person);
final personAfter = await authUsers.get(session, authUserId: person);
expect(personAfter.scopeNames, contains(identified.name));
final deviceAfter = await ServerSideSession.db.findById(session, device);
expect(deviceAfter!.scopeNames, contains(identified.name));
});
});
}

View file

@ -0,0 +1,33 @@
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Sets up authentication exactly as the server runs it (lib/server.dart).
void useServerAuth() => AuthServices.set(
tokenManagerBuilders: tokenManagerBuilders,
identityProviderBuilders: identityProviderBuilders,
);
/// Signs [person] in on a new device and returns the new session's id.
Future<UuidValue> signInNewDevice(
Session session,
UuidValue person, {
Set<Scope> scopes = const {},
}) async {
Future<Set<UuidValue>> sessionIds() async => {
for (final serverSideSession in await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(person),
))
serverSideSession.id!,
};
final before = await sessionIds();
await AuthServices.instance.tokenManager.createToken(
session,
authUserId: person,
method: 'test',
scopes: scopes,
);
return (await sessionIds()).difference(before).single;
}

View file

@ -13,6 +13,9 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida; import 'dart:async' as _ida;
import 'dart:io' as _idi; import 'dart:io' as _idi;
import 'package:halkyon_learn_server/src/generated/auth/device.dart'
as _ipdmo8cq;
import 'package:serverpod/serverpod.dart' as _is; import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart' import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs; as _iacs;
@ -146,6 +149,8 @@ void withServerpod(
class TestEndpoints { class TestEndpoints {
late final _AnonymousIdpEndpoint anonymousIdp; late final _AnonymousIdpEndpoint anonymousIdp;
late final _DevicesEndpoint devices;
} }
class _InternalTestEndpoints extends TestEndpoints class _InternalTestEndpoints extends TestEndpoints
@ -159,6 +164,10 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints, endpoints,
serializationManager, serializationManager,
); );
devices = _DevicesEndpoint(
endpoints,
serializationManager,
);
} }
} }
@ -190,12 +199,78 @@ class _AnonymousIdpEndpoint {
parameters: _ist.testObjectToJson({'token': token}), parameters: _ist.testObjectToJson({'token': token}),
serializationManager: _serializationManager, serializationManager: _serializationManager,
); );
var _localReturnValue = var _localReturnValue = await (_localCallContext.method.call(
await (_localCallContext.method.call( _localUniqueSession,
_localUniqueSession, _localCallContext.arguments,
_localCallContext.arguments, ) as _ida.Future<_iacs.AuthSuccess>);
) return _localReturnValue;
as _ida.Future<_iacs.AuthSuccess>); } finally {
await _localUniqueSession.close();
}
});
}
}
class _DevicesEndpoint {
_DevicesEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<List<_ipdmo8cq.Device>> list(
_ist.TestSessionBuilder sessionBuilder,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'devices',
method: 'list',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'devices',
methodName: 'list',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<List<_ipdmo8cq.Device>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> signOut(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue sessionId,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'devices',
method: 'signOut',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'devices',
methodName: 'signOut',
parameters: _ist.testObjectToJson({'sessionId': sessionId}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
return _localReturnValue; return _localReturnValue;
} finally { } finally {
await _localUniqueSession.close(); await _localUniqueSession.close();