feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023)

halkyon_oidc_server and its generated client halkyon_oidc_client: any
provider following OpenID Connect's code flow, beside Serverpod's own
providers, with a preset for SimpleLogin (no PKCE, no `typ`). Nothing
of Halkyon's is in it; BSD-3-Clause, `publish_to: none` for now.

- Attempts in `halkyon_oidc_attempt` (ADR 0020's D13): the ID
  PostgreSQL's gen_random_uuid(), a state and a nonce of 32 random
  bytes, PKCE's verifier where used, the caller if signed in; ten
  minutes, taken once. A host may begin the state with a marker.
- The code exchanged by the module itself, so a provider out of reach
  (a timeout, a 5xx) is told apart from a refused code; the ID token
  checked with dart_jsonwebtoken's algorithms (ADR 0020's D12): the
  provider's algorithms alone, the key its `kid` names, of the right
  type; issuer, audience and `azp`, expiry, time issued, `nbf`, a
  minute's leeway; the attempt's nonce; a `sub`.
- Keys kept an hour, fetched again for an unknown `kid` at most once
  a minute; discovery checked against the issuer; every request with
  a timeout.
- Identities in `halkyon_oidc_identity`: the provider's key and the
  `sub`, one per person and provider, deleted with the person.
- The stock endpoint (`OidcIdpBaseEndpoint`), and the parts for a host
  with rules of its own: begin, verify, keep and takeKept, link,
  accountOf, unlink; merging people; a limit per network.
- The forwarding page, moved here from halkyon_base in the next commit.

The package's main library is the code generator's, as halkyon_base's;
a host imports `package:halkyon_oidc_server/oidc.dart`. ADR 0023's
point 5 no longer names OAuth2PkceUtil, which the module doesn't use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 20:28:37 +02:00
commit f549fa59df
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 15870 additions and 2 deletions

View file

@ -0,0 +1,248 @@
import 'package:halkyon_oidc_server/src/callback_forwarding.dart';
import 'package:test/test.dart';
// The forwarding page (ADR 0023, 7), with a host's table of two apps.
final _returns = {
'app-desktop': Uri.parse('http://127.0.0.1:43823/oidc-callback'),
'app-phone': Uri.parse('app.example.app://oidc-callback'),
};
OidcCallbackAnswer _forward(
OidcCallbackRequest request, {
OidcRefusalPage refusalPage = defaultOidcRefusalPage,
}) => oidcCallbackForwarding(
request,
returns: _returns,
refusalPage: refusalPage,
);
/// A GET of the page with [parameters] as the query, encoded as a browser
/// sends them.
OidcCallbackRequest _get(
Map<String, String> parameters, {
String? acceptLanguage,
}) => (
method: 'GET',
query: Uri(queryParameters: parameters).query,
acceptLanguage: acceptLanguage,
);
/// A GET of the page with [query] exactly as written.
OidcCallbackRequest _raw(String query) =>
(method: 'GET', query: query, acceptLanguage: null);
void expectRefused(OidcCallbackAnswer answer, {int status = 400}) {
expect(answer.status, status);
expect(answer.location, isNull);
expect(answer.html, contains('noindex'));
expect(answer.html, isNot(contains('<script')));
}
void main() {
group('a sign-in coming back from the provider', () {
test('goes to exactly the address its marker has, with its code and '
'state, whichever it is', () {
for (final MapEntry(key: marker, value: own) in _returns.entries) {
final state = '$marker.Az09_-attempt';
final answer = _forward(
_get({'code': 'qwertyuiop', 'state': state}),
);
expect(answer.status, 303, reason: marker);
final location = answer.location!;
expect(location.scheme, own.scheme, reason: marker);
expect(location.host, own.host, reason: marker);
expect(location.port, own.port, reason: marker);
expect(location.path, own.path, reason: marker);
expect(location.userInfo, isEmpty, reason: marker);
expect(location.fragment, isEmpty, reason: marker);
expect(location.queryParametersAll, {
'code': ['qwertyuiop'],
'state': [state],
}, reason: marker);
}
});
test("takes the provider's refusal back as well, its error and state", () {
final answer = _forward(
_get({'error': 'deny', 'state': 'app-phone.abc'}),
);
expect(answer.status, 303);
expect(
answer.location,
Uri.parse('app.example.app://oidc-callback').replace(
queryParameters: {'error': 'deny', 'state': 'app-phone.abc'},
),
);
});
test('takes what the provider writes as it writes it, and leaves out '
'what the app needs not know', () {
// SimpleLogin writes code, state and scope with Python's quote().
final answer = _forward(
_raw(
'state=app-desktop.r4nd0m-_x&scope=openid%20email&code=abcdefghij',
),
);
expect(answer.status, 303);
expect(answer.location!.queryParametersAll, {
'code': ['abcdefghij'],
'state': ['app-desktop.r4nd0m-_x'],
});
});
test('is kept by nobody on the way', () {
final answer = _forward(
_get({'code': 'abc', 'state': 'app-desktop.abc'}),
);
expect(answer.headers, {
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
});
});
});
group('the page refuses, sending nobody anywhere,', () {
test('a state naming no app it knows', () {
for (final state in [
'abc', // no marker
'evil-desktop.abc', // a marker of no app
'App-desktop.abc', // not as written
'app-desktop', // no rest
'app-desktop.', // an empty rest
'.abc',
'app-desktop..abc',
]) {
expectRefused(
_forward(_get({'code': 'abc', 'state': state})),
);
}
});
test('a state holding anything but base64url, so nothing in it can '
'reach the address', () {
for (final state in [
'app-desktop.x@evil.example',
'app-desktop.x/../../evil',
'//evil.example/app-desktop.abc',
'app-desktop.abc\r\nSet-Cookie: a=b',
'app-desktop.abc#fragment',
'app-desktop.abc?code=other',
'app-desktop.abc&code=other',
'app-desktop.a b',
'app-desktop.ab.c',
'app-desktop.${'a' * 513}',
]) {
expectRefused(
_forward(_get({'code': 'abc', 'state': state})),
);
}
});
test('a missing state', () {
expectRefused(_forward(_get({'code': 'abc'})));
expectRefused(_forward(_raw('')));
});
test('neither a code nor an error, or both', () {
expectRefused(_forward(_get({'state': 'app-desktop.abc'})));
expectRefused(
_forward(
_get({
'code': 'abc',
'error': 'deny',
'state': 'app-desktop.abc',
}),
),
);
});
test('a code or an error that is no such thing', () {
for (final code in [
'',
'a b',
'//evil.example',
'<script>',
'abc\r\nLocation: x',
'a' * 513,
]) {
expectRefused(
_forward(
_get({'code': code, 'state': 'app-desktop.abc'}),
),
);
}
for (final error in ['', 'access denied', '"deny"', 'a' * 65]) {
expectRefused(
_forward(
_get({'error': error, 'state': 'app-desktop.abc'}),
),
);
}
});
test('a code, an error or a state given twice', () {
for (final query in [
'code=a&code=b&state=app-desktop.abc',
'error=deny&error=deny&state=app-desktop.abc',
'code=a&state=app-desktop.abc&state=app-phone.abc',
]) {
expectRefused(_forward(_raw(query)));
}
});
test("a query it can't read, or one longer than any sign-in sends", () {
expectRefused(
_forward(_raw('code=%zz&state=app-desktop.abc')),
);
expectRefused(
_forward(
_raw('code=abc&state=app-desktop.abc&x=${'a' * 4096}'),
),
);
});
test('every method but GET', () {
for (final method in ['POST', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']) {
final answer = _forward((
method: method,
query: 'code=abc&state=app-desktop.abc',
acceptLanguage: null,
));
expectRefused(answer, status: 405);
expect(answer.headers['allow'], 'GET', reason: method);
}
});
test('keeping its page from being kept, indexed or framed, and saying '
'nothing of what it was given', () {
final answer = _forward(
_get({'code': 'zzsecretcodezz', 'state': 'zzsecretstatezz'}),
);
expectRefused(answer);
expect(answer.html, isNot(contains('zzsecret')));
expect(answer.headers, {
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'content-security-policy':
"default-src 'none'; style-src 'unsafe-inline'; "
"frame-ancestors 'none'",
});
});
test("with the host's page, given the visitor's languages", () {
final asked = <String?>[];
final answer = _forward(
_get({'code': 'abc'}, acceptLanguage: 'de-DE,de;q=0.9'),
refusalPage: (acceptLanguage) {
asked.add(acceptLanguage);
return '<p>host</p>';
},
);
expect(asked, ['de-DE,de;q=0.9']);
expect(answer.html, '<p>host</p>');
});
});
}

View file

@ -0,0 +1,264 @@
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:halkyon_oidc_server/src/id_token.dart';
import 'package:test/test.dart';
import '../support/test_provider.dart';
// An ID token proves who someone is only when everything in it checks out
// (ADR 0023, 5): signed with one of the provider's keys, the one its `kid`
// names, by an algorithm the provider uses; for this client, from this
// issuer, for this attempt (the nonce), not expired. Each test spoils one
// thing of a token that otherwise passes.
const issuer = 'https://id.example';
const clientId = 'client-1';
const nonce = 'nonce-1';
void main() {
late TestKey key;
late TestKey other;
final now = DateTime.utc(2026, 10, 23, 18);
setUpAll(() {
key = TestKey.generate('a', seed: 1);
other = TestKey.generate('b', seed: 2);
});
Map<String, Object?> claims([Map<String, Object?> changes = const {}]) => {
'iss': issuer,
'sub': '42',
'aud': clientId,
'exp': secondsOf(now.add(const Duration(hours: 1))),
'iat': secondsOf(now),
'nonce': nonce,
'name': 'Ada',
...changes,
}..removeWhere((_, value) => value == null);
Future<Map<String, Object?>> check(
String token, {
Set<String> algorithms = const {'RS256'},
Map<String?, JWTKey>? keys,
}) => withClock(
Clock.fixed(now),
() => checkIdToken(
token,
issuer: issuer,
clientId: clientId,
algorithms: algorithms,
nonce: nonce,
keyFor: (kid) async => (keys ?? {'a': RSAPublicKey.raw(key.public)})[kid],
),
);
Matcher rejected() => throwsA(isA<IdTokenRejected>());
group('a token that checks out', () {
test(
'is accepted without typ in its header, as SimpleLogin signs it',
() async {
final checked = await check(key.sign(claims()));
expect(checked['sub'], '42');
expect(checked['name'], 'Ada');
},
);
test('is accepted with typ JWT too', () async {
final token = key.sign(
claims(),
header: {'alg': 'RS256', 'kid': 'a', 'typ': 'JWT'},
);
expect((await check(token))['sub'], '42');
});
test(
'is accepted with the client among several audiences, named as azp',
() async {
final token = key.sign(
claims({
'aud': [clientId, 'another'],
'azp': clientId,
}),
);
expect((await check(token))['sub'], '42');
},
);
test(
'is accepted up to a minute after it expired, for the clocks',
() async {
final token = key.sign(
claims({'exp': secondsOf(now.subtract(const Duration(seconds: 30)))}),
);
expect((await check(token))['sub'], '42');
},
);
test(
'without a kid, is checked with the key when the provider has one',
() async {
final token = key.sign(claims(), header: {'alg': 'RS256'});
final checked = await check(
token,
keys: {null: RSAPublicKey.raw(key.public)},
);
expect(checked['sub'], '42');
},
);
});
group('the signature', () {
test('by another key under the same kid is refused', () {
final token = other.sign(
claims(),
header: {'alg': 'RS256', 'kid': 'a'},
);
expect(check(token), rejected());
});
test('of a kid the provider has no key for is refused', () {
expect(check(other.sign(claims())), rejected());
});
test('over a payload changed afterwards is refused', () {
final parts = key.sign(claims()).split('.');
final changed = base64Url
.encode(utf8.encode(jsonEncode(claims({'sub': '43'}))))
.replaceAll('=', '');
expect(check('${parts[0]}.$changed.${parts[2]}'), rejected());
});
test('by an algorithm the provider does not use is refused', () {
expect(check(key.sign(claims(), algorithm: 'RS512')), rejected());
});
test('as an HMAC keyed with the public key is refused', () {
// The classic confusion: the public key, which anyone has, as the
// secret of an HMAC, which a check trusting the header would accept.
final token = signToken(
claims(),
header: {'alg': 'HS256', 'kid': 'a'},
algorithm: JWTAlgorithm.HS256,
key: SecretKey(jsonEncode(key.jwk)),
);
expect(
check(token, algorithms: {'RS256', 'HS256'}),
rejected(),
);
});
test('that is none is refused', () {
final parts = key.sign(claims()).split('.');
final header = base64Url
.encode(utf8.encode(jsonEncode({'alg': 'none', 'kid': 'a'})))
.replaceAll('=', '');
expect(check('$header.${parts[1]}.'), rejected());
expect(check('$header.${parts[1]}'), rejected());
});
test('without a kid is refused where the provider has several keys', () {
final token = key.sign(claims(), header: {'alg': 'RS256'});
expect(
check(
token,
keys: {
'a': RSAPublicKey.raw(key.public),
'b': RSAPublicKey.raw(other.public),
},
),
rejected(),
);
});
});
group('the claims', () {
test('from another issuer, or none, are refused', () {
expect(
check(key.sign(claims({'iss': 'https://evil.example'}))),
rejected(),
);
expect(check(key.sign(claims({'iss': '$issuer/'}))), rejected());
expect(check(key.sign(claims({'iss': null}))), rejected());
});
test('for another client, or none, are refused', () {
expect(check(key.sign(claims({'aud': 'client-2'}))), rejected());
expect(check(key.sign(claims({'aud': null}))), rejected());
expect(check(key.sign(claims({'aud': <String>[]}))), rejected());
});
test('for several audiences without this client as azp are refused', () {
expect(
check(
key.sign(
claims({
'aud': [clientId, 'another'],
}),
),
),
rejected(),
);
expect(
check(
key.sign(
claims({
'aud': [clientId, 'another'],
'azp': 'another',
}),
),
),
rejected(),
);
});
test('expired more than a minute ago, or never, are refused', () {
expect(
check(
key.sign(
claims({
'exp': secondsOf(now.subtract(const Duration(minutes: 2))),
}),
),
),
rejected(),
);
expect(check(key.sign(claims({'exp': null}))), rejected());
expect(check(key.sign(claims({'exp': 'tomorrow'}))), rejected());
});
test('issued, or valid from, more than a minute ahead are refused', () {
final ahead = secondsOf(now.add(const Duration(minutes: 2)));
expect(check(key.sign(claims({'iat': ahead}))), rejected());
expect(check(key.sign(claims({'iat': null}))), rejected());
expect(check(key.sign(claims({'nbf': ahead}))), rejected());
});
test('for another attempt, or none, are refused (the nonce)', () {
expect(check(key.sign(claims({'nonce': 'nonce-2'}))), rejected());
expect(check(key.sign(claims({'nonce': null}))), rejected());
});
test('naming nobody are refused', () {
expect(check(key.sign(claims({'sub': null}))), rejected());
expect(check(key.sign(claims({'sub': ''}))), rejected());
expect(check(key.sign(claims({'sub': 42}))), rejected());
});
});
group('what is no token', () {
test('is refused', () {
final good = key.sign(claims()).split('.');
expect(check(''), rejected());
expect(check('${good[0]}.${good[1]}'), rejected());
expect(check('x.${good[1]}.${good[2]}'), rejected());
final notAnObject = base64Url
.encode(utf8.encode('[1]'))
.replaceAll('=', '');
expect(check('$notAnObject.${good[1]}.${good[2]}'), rejected());
expect(check('${good[0]}.$notAnObject.${good[2]}'), rejected());
});
});
}

View file

@ -0,0 +1,189 @@
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:halkyon_oidc_server/src/oidc_provider.dart';
import 'package:halkyon_oidc_server/src/provider_keys.dart';
import 'package:test/test.dart';
import '../support/test_provider.dart';
// A provider's keys, as the server keeps them (ADR 0023, 5): an hour, then
// fetched again; a `kid` it doesn't know fetched again at once, as the
// provider may have a new key, but at most once a minute, so tokens with made
// up `kid`s can't have it asked again and again. A fetch that fails keeps
// the keys it had.
void main() {
late TestKey a;
late TestKey b;
setUpAll(() {
a = TestKey.generate('a', seed: 1);
b = TestKey.generate('b', seed: 2);
});
final start = DateTime.utc(2026, 10, 23, 18);
late DateTime now;
late List<String> documents;
late int fetches;
late ProviderKeys keys;
setUp(() {
now = start;
fetches = 0;
documents = [];
keys = ProviderKeys(() async {
fetches++;
if (documents.isEmpty) {
throw ProviderUnavailable('down');
}
return documents.length == 1 ? documents.single : documents.removeAt(0);
});
});
Future<JWTKey?> keyFor(String? kid) =>
withClock(Clock(() => now), () => keys.keyFor(kid));
test('are fetched once, and kept an hour', () async {
documents = [
jwksOf([a]),
];
expect(await keyFor('a'), isA<RSAPublicKey>());
now = now.add(const Duration(minutes: 59));
expect(await keyFor('a'), isA<RSAPublicKey>());
expect(fetches, 1);
now = now.add(const Duration(minutes: 2));
expect(await keyFor('a'), isA<RSAPublicKey>());
expect(fetches, 2);
});
test(
"are fetched again for a kid they don't know, which a new key may be",
() async {
documents = [
jwksOf([a]),
jwksOf([a, b]),
];
expect(await keyFor('a'), isNotNull);
now = now.add(const Duration(minutes: 2));
expect(await keyFor('b'), isA<RSAPublicKey>());
expect(fetches, 2);
},
);
test('are fetched again for an unknown kid at most once a minute', () async {
documents = [
jwksOf([a]),
];
expect(await keyFor('a'), isNotNull);
now = now.add(const Duration(minutes: 1));
expect(await keyFor('x'), isNull);
expect(fetches, 2);
now = now.add(const Duration(seconds: 30));
expect(await keyFor('y'), isNull);
expect(await keyFor('z'), isNull);
expect(fetches, 2);
now = now.add(const Duration(seconds: 31));
expect(await keyFor('x'), isNull);
expect(fetches, 3);
});
test('fetched for a token without a kid give the one key there is', () async {
documents = [
jwksOf([a]),
];
expect(await keyFor(null), isA<RSAPublicKey>());
});
test(
'fetched for a token without a kid give none where there are several',
() async {
documents = [
jwksOf([a, b]),
];
expect(await keyFor(null), isNull);
},
);
test('leave out what no ID token is checked with', () async {
documents = [
jsonEncode({
'keys': [
{...a.jwk, 'use': 'enc'},
{'kty': 'oct', 'kid': 'secret', 'k': 'c2VjcmV0'},
{...b.jwk, 'd': 'AQAB'},
{'kty': 'RSA', 'kid': 'broken'},
'not a key',
{...b.jwk, 'kid': 'c', 'use': 'sig'},
],
}),
];
expect(await keyFor('a'), isNull);
now = now.add(const Duration(minutes: 1));
expect(await keyFor('secret'), isNull);
now = now.add(const Duration(minutes: 1));
expect(await keyFor('b'), isNull);
now = now.add(const Duration(minutes: 1));
expect(await keyFor('broken'), isNull);
expect(await keyFor('c'), isA<RSAPublicKey>());
});
test('that never came make the provider unavailable', () {
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
});
test('that are no JWKS make the provider unavailable', () {
for (final document in ['', '{}', '{"keys": 1}', '[]', '{"keys": []}']) {
documents = [document];
keys = ProviderKeys(() async => documents.single);
expect(
() => keyFor('a'),
throwsA(isA<ProviderUnavailable>()),
reason: document,
);
}
});
test(
'stay as they were where fetching them again fails, tried once a minute',
() async {
documents = [
jwksOf([a]),
];
expect(await keyFor('a'), isNotNull);
documents = [];
now = now.add(const Duration(hours: 2));
expect(await keyFor('a'), isA<RSAPublicKey>());
expect(await keyFor('a'), isA<RSAPublicKey>());
expect(fetches, 2);
now = now.add(const Duration(minutes: 1));
expect(await keyFor('a'), isA<RSAPublicKey>());
expect(fetches, 3);
},
);
test('that never came are tried again a minute later', () async {
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
await pumpEventQueue();
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
await pumpEventQueue();
expect(fetches, 1);
documents = [
jwksOf([a]),
];
now = now.add(const Duration(minutes: 1));
expect(await keyFor('a'), isA<RSAPublicKey>());
expect(fetches, 2);
});
test('are fetched once for lookups at the same time', () async {
documents = [
jwksOf([a]),
];
final found = await Future.wait([keyFor('a'), keyFor('a'), keyFor('a')]);
expect(found, everyElement(isNotNull));
expect(fetches, 1);
});
}

View file

@ -0,0 +1,202 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:halkyon_oidc_server/src/oidc_provider.dart';
import 'package:test/test.dart';
// A provider as the host names it (ADR 0023, 2): where its endpoints are,
// read from its discovery document unless given, and the address a person is
// sent to, which carries everything the provider needs and nothing else.
void main() {
final callback = Uri.parse('https://app.example/oidc-callback');
OidcProvider provider({bool pkce = true}) => OidcProvider(
key: 'test',
issuer: 'https://id.example',
clientId: 'client-1',
clientSecret: 'secret-1',
callbackUrl: callback,
scopes: const ['openid', 'profile'],
pkce: pkce,
);
final endpoints = OidcEndpoints(
authorization: Uri.parse('https://id.example/authorize?tenant=t'),
token: Uri.parse('https://id.example/token'),
jwks: Uri.parse('https://id.example/jwks'),
);
group('the authorization address', () {
test('asks for a code for the client, to the callback, with the scopes, state and nonce', () {
final url = authorizationUrl(
provider(pkce: false),
endpoints,
state: 'learn-desktop.s',
nonce: 'n',
);
expect(url.host, 'id.example');
expect(url.path, '/authorize');
expect(url.queryParameters, {
'tenant': 't',
'response_type': 'code',
'client_id': 'client-1',
'redirect_uri': '$callback',
'scope': 'openid profile',
'state': 'learn-desktop.s',
'nonce': 'n',
});
});
test("with PKCE, carries the verifier's S256 challenge", () {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
final url = authorizationUrl(
provider(),
endpoints,
state: 's',
nonce: 'n',
codeVerifier: verifier,
);
// RFC 7636, appendix B.
expect(
url.queryParameters['code_challenge'],
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
);
expect(url.queryParameters['code_challenge_method'], 'S256');
expect(
pkceChallenge(verifier),
base64Url
.encode(sha256.convert(ascii.encode(verifier)).bytes)
.replaceAll('=', ''),
);
});
test('never carries the secret', () {
final url = authorizationUrl(
provider(),
endpoints,
state: 's',
nonce: 'n',
codeVerifier: newRandomValue(),
);
expect('$url', isNot(contains('secret-1')));
});
});
group('random values', () {
test('are 32 bytes of base64url, each new', () {
final values = {for (var i = 0; i < 100; i++) newRandomValue()};
expect(values, hasLength(100));
for (final value in values) {
expect(value, matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
}
});
});
group('the discovery document', () {
String document([Map<String, Object?> changes = const {}]) => jsonEncode({
'issuer': 'https://id.example',
'authorization_endpoint': 'https://id.example/authorize',
'token_endpoint': 'https://id.example/token',
'jwks_uri': 'https://id.example/jwks',
...changes,
});
test("gives the provider's endpoints", () {
final read = OidcEndpoints.fromDiscovery(
document(),
issuer: 'https://id.example',
);
expect(read.authorization, Uri.parse('https://id.example/authorize'));
expect(read.token, Uri.parse('https://id.example/token'));
expect(read.jwks, Uri.parse('https://id.example/jwks'));
});
test('naming another issuer is refused', () {
expect(
() => OidcEndpoints.fromDiscovery(
document({'issuer': 'https://evil.example'}),
issuer: 'https://id.example',
),
throwsA(isA<ProviderUnavailable>()),
);
});
test('without an endpoint, or with one that is no address, is refused', () {
for (final name in [
'authorization_endpoint',
'token_endpoint',
'jwks_uri',
]) {
expect(
() => OidcEndpoints.fromDiscovery(
document({name: null}),
issuer: 'https://id.example',
),
throwsA(isA<ProviderUnavailable>()),
reason: name,
);
expect(
() => OidcEndpoints.fromDiscovery(
document({name: 42}),
issuer: 'https://id.example',
),
throwsA(isA<ProviderUnavailable>()),
reason: name,
);
}
});
test('that is no JSON object is refused', () {
for (final body in ['', '[]', 'nonsense']) {
expect(
() => OidcEndpoints.fromDiscovery(body, issuer: 'https://id.example'),
throwsA(isA<ProviderUnavailable>()),
);
}
});
test("is where OpenID Connect's discovery puts it", () {
expect(
discoveryUrl('https://id.example'),
Uri.parse('https://id.example/.well-known/openid-configuration'),
);
expect(
discoveryUrl('https://id.example/realms/r/'),
Uri.parse(
'https://id.example/realms/r/.well-known/openid-configuration',
),
);
});
});
group("SimpleLogin's preset", () {
test(
"names SimpleLogin's issuer and endpoints, without PKCE, RS256 alone",
() {
final simpleLogin = OidcProvider.simpleLogin(
clientId: 'c',
clientSecret: 's',
callbackUrl: callback,
);
expect(simpleLogin.key, 'simplelogin');
expect(simpleLogin.issuer, 'https://app.simplelogin.io');
expect(simpleLogin.pkce, isFalse);
expect(simpleLogin.algorithms, {'RS256'});
expect(simpleLogin.scopes, ['openid']);
expect(
simpleLogin.endpoints?.authorization,
Uri.parse('https://app.simplelogin.io/oauth2/authorize'),
);
expect(
simpleLogin.endpoints?.token,
Uri.parse('https://app.simplelogin.io/oauth2/token'),
);
expect(
simpleLogin.endpoints?.jwks,
Uri.parse('https://app.simplelogin.io/jwks'),
);
},
);
});
}