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
15 changed files with 966 additions and 12 deletions
11
halkyon_learn_server/lib/src/auth/device.spy.yaml
Normal file
11
halkyon_learn_server/lib/src/auth/device.spy.yaml
Normal 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
|
||||
64
halkyon_learn_server/lib/src/auth/devices_endpoint.dart
Normal file
64
halkyon_learn_server/lib/src/auth/devices_endpoint.dart
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
halkyon_learn_server/lib/src/auth/identified.dart
Normal file
43
halkyon_learn_server/lib/src/auth/identified.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
8
halkyon_learn_server/lib/src/auth/scopes.dart
Normal file
8
halkyon_learn_server/lib/src/auth/scopes.dart
Normal 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');
|
||||
|
|
@ -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
|
||||
139
halkyon_learn_server/lib/src/generated/auth/device.dart
Normal file
139
halkyon_learn_server/lib/src/generated/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/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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
as _iais;
|
||||
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
|
||||
import '../auth/devices_endpoint.dart' as _iduct7ue;
|
||||
|
||||
class Endpoints extends _is.EndpointDispatch {
|
||||
@override
|
||||
|
|
@ -27,6 +28,12 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
'anonymousIdp',
|
||||
null,
|
||||
),
|
||||
'devices': _iduct7ue.DevicesEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'devices',
|
||||
null,
|
||||
),
|
||||
};
|
||||
connectors['anonymousIdp'] = _is.EndpointConnector(
|
||||
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()
|
||||
..initializeEndpoints(server);
|
||||
modules['serverpod_auth_core'] = _iacs.Endpoints()
|
||||
|
|
|
|||
|
|
@ -11,12 +11,18 @@
|
|||
// ignore_for_file: dead_code, unnecessary_type_check
|
||||
|
||||
// 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/serverpod.dart' as _is;
|
||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||
as _iacs;
|
||||
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
|
||||
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 {
|
||||
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 {
|
||||
return _iais.Protocol().deserialize<T>(data, t);
|
||||
} on _is.DeserializationTypeNotFoundException catch (_) {}
|
||||
|
|
@ -72,6 +97,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
|
||||
static String? getClassNameForType(Type type) {
|
||||
return switch (type) {
|
||||
_i6c5ny7n.Device => 'Device',
|
||||
_i8oee73b.NotFoundException => 'NotFoundException',
|
||||
_ => 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);
|
||||
if (className != null) {
|
||||
return className.contains('.')
|
||||
|
|
@ -113,6 +146,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
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 _iais.Protocol().deserializeByClassName(data);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,5 @@
|
|||
anonymousIdp:
|
||||
- login:
|
||||
devices:
|
||||
- list:
|
||||
- signOut:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:halkyon_learn_server/src/auth/auth_config.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() {
|
||||
|
|
@ -14,11 +14,7 @@ void main() {
|
|||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
setUp(() async {
|
||||
// The same configuration the server runs with (lib/server.dart).
|
||||
AuthServices.set(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders,
|
||||
);
|
||||
useServerAuth();
|
||||
await _deleteAll(sessionBuilder.build());
|
||||
});
|
||||
tearDown(() => _deleteAll(sessionBuilder.build()));
|
||||
|
|
|
|||
111
halkyon_learn_server/test/integration/auth/devices_test.dart
Normal file
111
halkyon_learn_server/test/integration/auth/devices_test.dart
Normal 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]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@
|
|||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _ida;
|
||||
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_auth_core_server/serverpod_auth_core_server.dart'
|
||||
as _iacs;
|
||||
|
|
@ -146,6 +149,8 @@ void withServerpod(
|
|||
|
||||
class TestEndpoints {
|
||||
late final _AnonymousIdpEndpoint anonymousIdp;
|
||||
|
||||
late final _DevicesEndpoint devices;
|
||||
}
|
||||
|
||||
class _InternalTestEndpoints extends TestEndpoints
|
||||
|
|
@ -159,6 +164,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
devices = _DevicesEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,12 +199,78 @@ class _AnonymousIdpEndpoint {
|
|||
parameters: _ist.testObjectToJson({'token': token}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iacs.AuthSuccess>);
|
||||
var _localReturnValue = await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
) as _ida.Future<_iacs.AuthSuccess>);
|
||||
return _localReturnValue;
|
||||
} 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;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
|
|
|
|||
Loading…
Reference in a new issue