feat(server): anonymous sign-in

People can sign in without an account: AnonymousIdpEndpoint exposes Serverpod's anonymous identity provider. lib/src/auth/auth_config.dart holds the token managers and identity providers, shared by server.dart and the tests, so the tests check what production runs. At most 30 new anonymous accounts per IP address and hour (Serverpod's default is 100): a school class behind one address can still start together, while one abuser gets at most 720 a day. No migration: the tables for anonymous accounts and rate limiting already exist.

Tests: signing in creates a person; the 31st account from one IP address within an hour is refused. They run without database rollback, since signing in uses concurrent transactions that the rollback can't wrap, and delete what they create instead.

The server switches off the lint rules for Dart v3.13's constructor syntax: Serverpod v4.0.2's code generator can't parse it yet.

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:03:52 +02:00
commit df99def5bc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 235 additions and 16 deletions

View file

@ -63,7 +63,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- Every decision carries its reason (in `NEXT.md` or an ADR under `docs/adr/`). - Every decision carries its reason (in `NEXT.md` or an ADR under `docs/adr/`).
- **Commits** are GPG-signed and carry a DCO `Signed-off-by` trailer (`git commit -s`; a local `prepare-commit-msg` hook adds it automatically). - **Commits** are GPG-signed and carry a DCO `Signed-off-by` trailer (`git commit -s`; a local `prepare-commit-msg` hook adds it automatically).
- **Pushes** pass `tool/check.sh` first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/check.sh .git/hooks/pre-push`). - **Pushes** pass `tool/check.sh` first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/check.sh .git/hooks/pre-push`).
- **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file, so the server and the app include it; the generated client doesn't. Every `// ignore:` gets a comment saying why (`document_ignores`). - **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file, so the server and the app include it; the generated client doesn't. Every `// ignore:` gets a comment saying why (`document_ignores`). The server switches off the four rules for Dart v3.13's constructor syntax (`use_primary_constructors`, `use_declaring_parameters`, `empty_container_bodies`, `unnecessary_type_name_in_constructor`): Serverpod v4.0.2's code generator can't parse that syntax yet.
## App architecture ## App architecture

View file

@ -10,6 +10,7 @@
// ignore_for_file: invalid_use_of_internal_member // ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida;
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;
@ -18,6 +19,28 @@ import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
import 'package:serverpod_client/serverpod_client.dart' as _isc; import 'package:serverpod_client/serverpod_client.dart' as _isc;
import 'protocol.dart' as _il2as5qe; import 'protocol.dart' as _il2as5qe;
/// Signs people in anonymously; the app calls it on first launch. Creating an
/// account is rate-limited per IP address (see `auth_config.dart`).
/// {@category Endpoint}
class EndpointAnonymousIdp extends _iaic.EndpointAnonymousIdpBase {
EndpointAnonymousIdp(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'anonymousIdp';
/// Creates a new anonymous account and returns its session.
///
/// Invokes the [AnonymousIdp.beforeAnonymousAccount] callback if configured,
/// which may prevent account creation if the endpoint is protected.
@override
_ida.Future<_iacc.AuthSuccess> login({String? token}) =>
caller.callServerEndpoint<_iacc.AuthSuccess>(
'anonymousIdp',
'login',
{'token': token},
);
}
class Modules { class Modules {
Modules(Client client) { Modules(Client client) {
serverpod_auth_idp = _iaic.Caller(client); serverpod_auth_idp = _iaic.Caller(client);
@ -56,13 +79,18 @@ class Client extends _isc.ServerpodClientShared {
disconnectStreamsOnLostInternetConnection, disconnectStreamsOnLostInternetConnection,
httpClientOverride: httpClientOverride, httpClientOverride: httpClientOverride,
) { ) {
anonymousIdp = EndpointAnonymousIdp(this);
modules = Modules(this); modules = Modules(this);
} }
late final EndpointAnonymousIdp anonymousIdp;
late final Modules modules; late final Modules modules;
@override @override
Map<String, _isc.EndpointRef> get endpointRefLookup => {}; Map<String, _isc.EndpointRef> get endpointRefLookup => {
'anonymousIdp': anonymousIdp,
};
@override @override
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => { Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {

View file

@ -6,5 +6,16 @@ analyzer:
- lib/src/generated/** - lib/src/generated/**
- test/integration/test_tools/serverpod_test_tools.dart - test/integration/test_tools/serverpod_test_tools.dart
# Serverpod v4.0.2's code generator reads this package with an analyzer that doesn't
# know Dart v3.13's primary constructors yet, and fails on them ("requires the
# 'primary-constructors' language feature"). So the server keeps the classic
# constructor and class-body syntax; switch these back on once Serverpod parses it.
linter:
rules:
empty_container_bodies: false
unnecessary_type_name_in_constructor: false
use_declaring_parameters: false
use_primary_constructors: false
formatter: formatter:
trailing_commas: preserve trailing_commas: preserve

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
import 'package:halkyon_learn_server/src/generated/serverpod.dart'; import 'package:halkyon_learn_server/src/generated/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
@ -6,19 +7,11 @@ Future<void> run(List<String> args) async {
// Initialize Serverpod. The generated Serverpod class is already connected // Initialize Serverpod. The generated Serverpod class is already connected
// with your project's generated code. // with your project's generated code.
final pod = Serverpod(args) final pod = Serverpod(args)
// Initialize authentication services for the server. // Token managers validate and issue authentication keys; identity
// Token managers will be used to validate and issue authentication keys, // providers are the ways people can sign in (see auth_config.dart).
// and the identity providers will be the authentication options available for users.
..initializeAuthServices( ..initializeAuthServices(
tokenManagerBuilders: [ tokenManagerBuilders: tokenManagerBuilders,
// Server-side sessions: revocable immediately, scopes live on the session row. identityProviderBuilders: identityProviderBuilders,
// No absolute lifetime (anonymous session cannot be restored). The inactivity
// timeout matches the pruning policy for abandoned accounts.
ServerSideSessionsConfigFromPasswords(
defaultSessionInactivityTimeout: const Duration(days: 90),
),
],
identityProviderBuilders: [],
) )
// Files live in Postgres until the self-hosted S3-compatible storage arrives later. // Files live in Postgres until the self-hosted S3-compatible storage arrives later.
..addCloudStorage(DatabaseCloudStorage('private')) ..addCloudStorage(DatabaseCloudStorage('private'))

View file

@ -0,0 +1,5 @@
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
/// Signs people in anonymously; the app calls it on first launch. Creating an
/// account is rate-limited per IP address (see `auth_config.dart`).
class AnonymousIdpEndpoint extends AnonymousIdpBaseEndpoint {}

View file

@ -0,0 +1,26 @@
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
// How people authenticate. The server and its tests use this same
// configuration, so the tests check what production runs.
/// Server-side sessions: revocable immediately, scopes live on the session
/// row. No absolute lifetime (an anonymous session cannot be restored). The
/// inactivity timeout matches the pruning policy for abandoned accounts.
final List<TokenManagerBuilder> tokenManagerBuilders = [
ServerSideSessionsConfigFromPasswords(
defaultSessionInactivityTimeout: const Duration(days: 90),
),
];
/// Anonymous accounts, the trial mode for solo use. At most 30 new ones per IP
/// address and hour (Serverpod's default is 100): a school class behind one
/// address can still start together, while one abuser gets at most 720 a day.
const List<IdentityProviderBuilder> identityProviderBuilders = [
AnonymousIdpConfig(
perIpAddressRateLimit: RateLimit(
maxAttempts: 30,
timeframe: Duration(hours: 1),
),
),
];

View file

@ -15,10 +15,45 @@ 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/anonymous_idp_endpoint.dart' as _in0zita6;
class Endpoints extends _is.EndpointDispatch { class Endpoints extends _is.EndpointDispatch {
@override @override
void initializeEndpoints(_is.Server server) { void initializeEndpoints(_is.Server server) {
var endpoints = <String, _is.Endpoint>{
'anonymousIdp': _in0zita6.AnonymousIdpEndpoint()
..initialize(
server,
'anonymousIdp',
null,
),
};
connectors['anonymousIdp'] = _is.EndpointConnector(
name: 'anonymousIdp',
endpoint: endpoints['anonymousIdp']!,
methodConnectors: {
'login': _is.MethodConnector(
name: 'login',
params: {
'token': _is.ParameterDescription(
name: 'token',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['anonymousIdp'] as _in0zita6.AnonymousIdpEndpoint)
.login(
session,
token: params['token'],
),
),
},
);
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

@ -0,0 +1,2 @@
anonymousIdp:
- login:

View file

@ -0,0 +1,67 @@
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 '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod(
'Anonymous sign-in',
// Signing in runs concurrent transactions (the rate limit and the new
// account), which the test's database rollback can't wrap. So these tests
// don't roll back; they delete what they create instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
setUp(() async {
// The same configuration the server runs with (lib/server.dart).
AuthServices.set(
tokenManagerBuilders: tokenManagerBuilders,
identityProviderBuilders: identityProviderBuilders,
);
await _deleteAll(sessionBuilder.build());
});
tearDown(() => _deleteAll(sessionBuilder.build()));
test('creates a person', () async {
final authSuccess = await endpoints.anonymousIdp.login(sessionBuilder);
final authUser = await AuthServices.instance.authUsers.get(
sessionBuilder.build(),
authUserId: authSuccess.authUserId,
);
expect(authUser.id, authSuccess.authUserId);
});
test(
'refuses the 31st account from one IP address within an hour',
() async {
for (var i = 0; i < 30; i++) {
await endpoints.anonymousIdp.login(sessionBuilder);
}
await expectLater(
endpoints.anonymousIdp.login(sessionBuilder),
throwsA(
isA<AnonymousAccountBlockedException>().having(
(e) => e.reason,
'reason',
AnonymousAccountBlockedExceptionReason.tooManyAttempts,
),
),
);
},
);
},
);
}
/// Deletes every rate-limit record and every person; their anonymous accounts
/// and sessions go with them (`ON DELETE CASCADE`).
Future<void> _deleteAll(Session session) async {
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}

View file

@ -11,8 +11,11 @@
// ignore_for_file: no_leading_underscores_for_local_identifiers // ignore_for_file: no_leading_underscores_for_local_identifiers
// 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:io' as _idi; import 'dart:io' as _idi;
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'
as _iacs;
import 'package:serverpod_test/serverpod_test.dart' as _ist; import 'package:serverpod_test/serverpod_test.dart' as _ist;
import 'package:halkyon_learn_server/src/generated/protocol.dart'; import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/generated/endpoints.dart'; import 'package:halkyon_learn_server/src/generated/endpoints.dart';
@ -141,7 +144,9 @@ void withServerpod(
)(testClosure); )(testClosure);
} }
class TestEndpoints {} class TestEndpoints {
late final _AnonymousIdpEndpoint anonymousIdp;
}
class _InternalTestEndpoints extends TestEndpoints class _InternalTestEndpoints extends TestEndpoints
implements _ist.InternalTestEndpoints { implements _ist.InternalTestEndpoints {
@ -149,5 +154,52 @@ class _InternalTestEndpoints extends TestEndpoints
void initialize( void initialize(
_is.SerializationManager serializationManager, _is.SerializationManager serializationManager,
_is.EndpointDispatch endpoints, _is.EndpointDispatch endpoints,
) {} ) {
anonymousIdp = _AnonymousIdpEndpoint(
endpoints,
serializationManager,
);
}
}
class _AnonymousIdpEndpoint {
_AnonymousIdpEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<_iacs.AuthSuccess> login(
_ist.TestSessionBuilder sessionBuilder, {
String? token,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'anonymousIdp',
method: 'login',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'anonymousIdp',
methodName: 'login',
parameters: _ist.testObjectToJson({'token': token}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
} }