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>
202 lines
6 KiB
Dart
202 lines
6 KiB
Dart
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'),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|