Main coves client
1import '../constants.dart';
2import '../types.dart';
3import 'client_auth.dart';
4
5/// Validates client metadata for OAuth compliance.
6///
7/// This function performs comprehensive validation of client metadata to ensure:
8/// 1. Client ID is valid (either discoverable HTTPS or loopback)
9/// 2. Required ATPROTO scope is present
10/// 3. Required response_types and grant_types are present
11/// 4. Authentication method is properly configured
12/// 5. For private_key_jwt, keyset and JWKS are properly configured
13///
14/// The validation enforces ATPROTO OAuth requirements on top of standard OAuth.
15///
16/// Returns the validated ClientMetadata.
17/// Throws TypeError if validation fails.
18ClientMetadata validateClientMetadata(
19 Map<String, dynamic> input,
20 Keyset? keyset,
21) {
22 // Allow passing a keyset and omitting jwks/jwks_uri
23 // The keyset will be serialized into the metadata
24 Map<String, dynamic> enrichedInput = input;
25 if (input['jwks'] == null &&
26 input['jwks_uri'] == null &&
27 keyset != null &&
28 keyset.size > 0) {
29 enrichedInput = {...input, 'jwks': keyset.toJSON()};
30 }
31
32 // Parse into ClientMetadata
33 final metadata = ClientMetadata.fromJson(enrichedInput);
34
35 // Validate client ID
36 final clientId = metadata.clientId;
37 if (clientId == null) {
38 throw FormatException('Client metadata must include client_id');
39 }
40
41 if (clientId.startsWith('http:')) {
42 // Loopback client ID (for development)
43 _assertOAuthLoopbackClientId(clientId);
44 } else {
45 // Discoverable client ID (production)
46 _assertOAuthDiscoverableClientId(clientId);
47 }
48
49 // Validate scope includes "atproto"
50 final scopes = metadata.scope?.split(' ') ?? [];
51 if (!scopes.contains('atproto')) {
52 throw FormatException('Client metadata must include the "atproto" scope');
53 }
54
55 // Validate response_types
56 if (!metadata.responseTypes.contains('code')) {
57 throw FormatException('"response_types" must include "code"');
58 }
59
60 // Validate grant_types
61 if (!metadata.grantTypes.contains('authorization_code')) {
62 throw FormatException('"grant_types" must include "authorization_code"');
63 }
64
65 // Validate authentication method
66 final method = metadata.tokenEndpointAuthMethod;
67 final methodAlg = metadata.tokenEndpointAuthSigningAlg;
68
69 switch (method) {
70 case 'none':
71 if (methodAlg != null) {
72 throw FormatException(
73 '"token_endpoint_auth_signing_alg" must not be provided when '
74 '"token_endpoint_auth_method" is "$method"',
75 );
76 }
77 break;
78
79 case 'private_key_jwt':
80 if (methodAlg == null) {
81 throw FormatException(
82 '"token_endpoint_auth_signing_alg" must be provided when '
83 '"token_endpoint_auth_method" is "$method"',
84 );
85 }
86
87 if (keyset == null) {
88 throw FormatException(
89 'Client authentication method "$method" requires a keyset',
90 );
91 }
92
93 // Validate signing keys
94 final signingKeys = keyset.keys.where((key) => key.kid != null).toList();
95
96 if (signingKeys.isEmpty) {
97 throw FormatException(
98 'Client authentication method "$method" requires at least one '
99 'active signing key with a "kid" property',
100 );
101 }
102
103 if (!signingKeys.any((key) => key.algorithms.contains(fallbackAlg))) {
104 throw FormatException(
105 'Client authentication method "$method" requires at least one '
106 'active "$fallbackAlg" signing key',
107 );
108 }
109
110 // Validate JWKS
111 if (metadata.jwks != null) {
112 // Ensure all signing keys are in the JWKS
113 final jwksKeys = (metadata.jwks!['keys'] as List?) ?? [];
114 for (final key in signingKeys) {
115 final found = jwksKeys.any((k) {
116 if (k is! Map<String, dynamic>) return false;
117 final revoked = k['revoked'] as bool?;
118 return k['kid'] == key.kid && revoked != true;
119 });
120
121 if (!found) {
122 throw FormatException(
123 'Missing or inactive key "${key.kid}" in jwks. '
124 'Make sure that every signing key of the Keyset is declared as '
125 'an active key in the Metadata\'s JWKS.',
126 );
127 }
128 }
129 } else if (metadata.jwksUri != null) {
130 // JWKS URI is acceptable, but we can't validate it here
131 // (we don't want to download the file during validation)
132 } else {
133 throw FormatException(
134 'Client authentication method "$method" requires a JWKS',
135 );
136 }
137 break;
138
139 default:
140 throw FormatException(
141 'Unsupported "token_endpoint_auth_method" value: $method',
142 );
143 }
144
145 return metadata;
146}
147
148/// Validates that a client ID is a valid discoverable client ID.
149///
150/// A discoverable client ID must be an HTTPS URL that can be dereferenced
151/// to get the client metadata document.
152///
153/// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
154void _assertOAuthDiscoverableClientId(String clientId) {
155 final uri = Uri.tryParse(clientId);
156
157 if (uri == null) {
158 throw FormatException('Invalid client_id URL: $clientId');
159 }
160
161 if (uri.scheme != 'https') {
162 throw FormatException('Discoverable client_id must use HTTPS: $clientId');
163 }
164
165 if (uri.hasFragment) {
166 throw FormatException(
167 'Discoverable client_id must not contain a fragment: $clientId',
168 );
169 }
170
171 // Validate it's a valid URL
172 if (!uri.hasAuthority) {
173 throw FormatException('Invalid discoverable client_id URL: $clientId');
174 }
175}
176
177/// Validates that a client ID is a valid loopback client ID.
178///
179/// A loopback client ID is used for development/testing and must be:
180/// - An HTTP URL (not HTTPS)
181/// - Using localhost or 127.0.0.1
182/// - Optionally with a port
183///
184/// See: https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
185void _assertOAuthLoopbackClientId(String clientId) {
186 final uri = Uri.tryParse(clientId);
187
188 if (uri == null) {
189 throw FormatException('Invalid client_id URL: $clientId');
190 }
191
192 if (uri.scheme != 'http') {
193 throw FormatException(
194 'Loopback client_id must use HTTP (not HTTPS): $clientId',
195 );
196 }
197
198 final host = uri.host.toLowerCase();
199 if (host != 'localhost' &&
200 host != '127.0.0.1' &&
201 host != '[::1]' &&
202 host != '::1') {
203 throw FormatException(
204 'Loopback client_id must use localhost or 127.0.0.1: $clientId',
205 );
206 }
207
208 if (uri.hasFragment) {
209 throw FormatException(
210 'Loopback client_id must not contain a fragment: $clientId',
211 );
212 }
213}