utils/crdt_sync_flutter/test/google_pkce_test.dart
Krzysztof kuhy Rudnicki 0f332c9057
Some checks are pending
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt_sync_flutter 0.4.0: AccountManager sign-in
Android now uses google_account_token (no googleid or
play-services-auth). Desktop PKCE sends client_secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:42:52 +01:00

190 lines
6.2 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PkcePair', () {
test('derives the S256 challenge of the verifier', () {
final pair = PkcePair.fromVerifier('abc123', 'state-1');
final expected = base64Url
.encode(sha256.convert(ascii.encode('abc123')).bytes)
.replaceAll('=', '');
expect(pair.challenge, expected);
expect(pair.verifier, 'abc123');
expect(pair.state, 'state-1');
});
test('challenge carries no base64 padding', () {
// '=' in a query parameter survives, but RFC 7636 requires it stripped
// and Google rejects a padded challenge.
expect(PkcePair.fromVerifier('a', 's').challenge, isNot(contains('=')));
});
test('generate produces a 64-char verifier and 32-char state', () {
final pair = PkcePair.generate(random: Random(7));
expect(pair.verifier, hasLength(64));
expect(pair.state, hasLength(32));
expect(pair.challenge, isNotEmpty);
});
test('generate is seedable, and unseeded still yields a pair', () {
expect(
PkcePair.generate(random: Random(1)).verifier,
PkcePair.generate(random: Random(1)).verifier,
);
expect(PkcePair.generate().verifier, hasLength(64));
});
test('verifier uses only the unreserved alphabet', () {
final pair = PkcePair.generate(random: Random(3));
expect(RegExp(r'^[A-Za-z0-9\-._~]+$').hasMatch(pair.verifier), isTrue);
});
});
group('buildAuthUrl', () {
final pkce = PkcePair.fromVerifier('verifier', 'the-state');
final url = buildAuthUrl(clientId: 'cid', redirectPort: 4321, pkce: pkce);
test('targets Google and carries the PKCE challenge', () {
expect(url.toString(), startsWith(kGoogleAuthEndpoint));
expect(url.queryParameters['code_challenge'], pkce.challenge);
expect(url.queryParameters['code_challenge_method'], 'S256');
});
test('asks for a code against the bound loopback port', () {
expect(url.queryParameters['response_type'], 'code');
expect(url.queryParameters['redirect_uri'], 'http://127.0.0.1:4321');
expect(url.queryParameters['client_id'], 'cid');
expect(url.queryParameters['state'], 'the-state');
});
test('requests openid email, the minimum yielding an id_token', () {
expect(url.queryParameters['scope'], 'openid email');
});
});
test('redirectUriFor uses the literal loopback address', () {
// Not `localhost`: a host resolving it to ::1 would present a URI the
// OAuth console never matched.
expect(redirectUriFor(80), 'http://127.0.0.1:80');
});
group('parseRedirect', () {
Uri uri(Map<String, String> params) =>
Uri.parse('http://127.0.0.1:1/').replace(queryParameters: params);
test('returns the code when state matches', () {
final result = parseRedirect(
uri({'code': 'abc', 'state': 's'}),
expectedState: 's',
);
expect(result.code, 'abc');
expect(result.error, isNull);
});
test('rejects a mismatched state without reading the code', () {
final result = parseRedirect(
uri({'code': 'abc', 'state': 'other'}),
expectedState: 's',
);
expect(result.code, isNull);
expect(result.error, 'state mismatch');
});
test('rejects a redirect carrying no state at all', () {
final result = parseRedirect(uri({'code': 'abc'}), expectedState: 's');
expect(result.error, 'state mismatch');
});
test('surfaces Google\'s error parameter', () {
final result = parseRedirect(
uri({'error': 'access_denied', 'state': 's'}),
expectedState: 's',
);
expect(result.code, isNull);
expect(result.error, 'access_denied');
});
test('reports a redirect with neither code nor error', () {
final result = parseRedirect(uri({'state': 's'}), expectedState: 's');
expect(result.error, 'no authorization code in redirect');
});
test('treats an empty code as no code', () {
final result = parseRedirect(
uri({'code': '', 'state': 's'}),
expectedState: 's',
);
expect(result.error, 'no authorization code in redirect');
});
test('constructs directly with neither field', () {
const result = RedirectResult();
expect(result.code, isNull);
expect(result.error, isNull);
});
});
test('tokenExchangeBody sends the verifier, not the challenge', () {
final body = tokenExchangeBody(
clientId: 'cid',
code: 'the-code',
redirectPort: 9,
verifier: 'the-verifier',
);
expect(body['code_verifier'], 'the-verifier');
expect(body['grant_type'], 'authorization_code');
expect(body['code'], 'the-code');
expect(body['client_id'], 'cid');
expect(body['redirect_uri'], 'http://127.0.0.1:9');
});
test('tokenExchangeBody omits client_secret when none is configured', () {
final body = tokenExchangeBody(
clientId: 'cid',
code: 'c',
redirectPort: 9,
verifier: 'v',
);
expect(body.containsKey('client_secret'), isFalse);
});
test('tokenExchangeBody sends the Desktop client_secret when given', () {
// Google issues Desktop clients a secret and rejects the exchange
// without it, even with PKCE.
final body = tokenExchangeBody(
clientId: 'cid',
clientSecret: 'the-secret',
code: 'c',
redirectPort: 9,
verifier: 'v',
);
expect(body['client_secret'], 'the-secret');
});
group('idTokenFromResponse', () {
test('reads the id_token', () {
expect(idTokenFromResponse('{"id_token":"tok"}'), 'tok');
});
test('returns null for an error response', () {
expect(idTokenFromResponse('{"error":"invalid_grant"}'), isNull);
});
test('returns null for an empty id_token', () {
expect(idTokenFromResponse('{"id_token":""}'), isNull);
});
test('returns null for a non-string id_token', () {
expect(idTokenFromResponse('{"id_token":42}'), isNull);
});
test('returns null for a non-object body', () {
expect(idTokenFromResponse('[]'), isNull);
});
});
}