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 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()), ); }); 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()), reason: name, ); expect( () => OidcEndpoints.fromDiscovery( document({name: 42}), issuer: 'https://id.example', ), throwsA(isA()), 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()), ); } }); 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'), ); }, ); }); }