Main coves client
1import '../constants.dart';
2import '../errors/auth_method_unsatisfiable_error.dart';
3import '../runtime/runtime.dart';
4import '../runtime/runtime_implementation.dart';
5import '../types.dart';
6
7/// Represents a client authentication method.
8///
9/// OAuth supports different ways for clients to authenticate with the
10/// authorization server:
11/// - 'none': Public client (no secret), only client_id
12/// - 'private_key_jwt': Confidential client using JWT signed with private key
13class ClientAuthMethod {
14 final String method;
15 final String? kid; // Key ID for private_key_jwt method
16
17 const ClientAuthMethod.none() : method = 'none', kid = null;
18
19 const ClientAuthMethod.privateKeyJwt(this.kid) : method = 'private_key_jwt';
20
21 @override
22 bool operator ==(Object other) {
23 if (identical(this, other)) return true;
24 return other is ClientAuthMethod &&
25 other.method == method &&
26 other.kid == kid;
27 }
28
29 @override
30 int get hashCode => method.hashCode ^ kid.hashCode;
31
32 Map<String, dynamic> toJson() {
33 return {'method': method, if (kid != null) 'kid': kid};
34 }
35
36 factory ClientAuthMethod.fromJson(Map<String, dynamic> json) {
37 final method = json['method'] as String;
38 if (method == 'none') {
39 return const ClientAuthMethod.none();
40 } else if (method == 'private_key_jwt') {
41 return ClientAuthMethod.privateKeyJwt(json['kid'] as String);
42 }
43 throw FormatException('Unknown auth method: $method');
44 }
45}
46
47/// Credential payload to include in OAuth requests.
48class OAuthClientCredentials {
49 /// Client identifier
50 final String clientId;
51
52 /// Client assertion type (for private_key_jwt)
53 final String? clientAssertionType;
54
55 /// Client assertion JWT (for private_key_jwt)
56 final String? clientAssertion;
57
58 const OAuthClientCredentials({
59 required this.clientId,
60 this.clientAssertionType,
61 this.clientAssertion,
62 });
63
64 Map<String, dynamic> toJson() {
65 final map = <String, dynamic>{'client_id': clientId};
66 if (clientAssertionType != null) {
67 map['client_assertion_type'] = clientAssertionType;
68 }
69 if (clientAssertion != null) {
70 map['client_assertion'] = clientAssertion;
71 }
72 return map;
73 }
74}
75
76/// Result of creating client credentials.
77class ClientCredentialsResult {
78 /// Optional HTTP headers (e.g., Authorization header for client_secret_basic)
79 final Map<String, String>? headers;
80
81 /// Payload to include in the request body
82 final OAuthClientCredentials payload;
83
84 const ClientCredentialsResult({this.headers, required this.payload});
85}
86
87/// Factory function that creates client credentials.
88typedef ClientCredentialsFactory = Future<ClientCredentialsResult> Function();
89
90/// Negotiates the client authentication method to use.
91///
92/// This function:
93/// 1. Checks that the server supports the client's auth method
94/// 2. For private_key_jwt, finds a suitable key from the keyset
95/// 3. Returns the negotiated auth method
96///
97/// The ATPROTO spec requires that authorization servers support both
98/// "none" and "private_key_jwt", and clients use one or the other.
99///
100/// Throws:
101/// - Error if server doesn't support client's auth method
102/// - Error if private_key_jwt is used but no suitable key is found
103ClientAuthMethod negotiateClientAuthMethod(
104 Map<String, dynamic> serverMetadata,
105 ClientMetadata clientMetadata,
106 Keyset? keyset,
107) {
108 final method = clientMetadata.tokenEndpointAuthMethod;
109
110 // Check that the server supports this method
111 final methods = _supportedMethods(serverMetadata);
112 if (!methods.contains(method)) {
113 throw StateError(
114 'The server does not support "$method" authentication. '
115 'Supported methods are: ${methods.join(', ')}.',
116 );
117 }
118
119 if (method == 'private_key_jwt') {
120 // Invalid client configuration
121 if (keyset == null) {
122 throw StateError('A keyset is required for private_key_jwt');
123 }
124
125 final algs = _supportedAlgs(serverMetadata);
126
127 // Find a suitable key
128 // We can't use keyset.findPrivateKey here because we need to ensure
129 // the key has a "kid" property (required for JWT headers)
130 for (final key in keyset.keys) {
131 if (key.kid != null &&
132 key.usage == 'sign' &&
133 key.algorithms.any((a) => algs.contains(a))) {
134 return ClientAuthMethod.privateKeyJwt(key.kid!);
135 }
136 }
137
138 throw StateError(
139 algs.contains(fallbackAlg)
140 ? 'Client authentication method "$method" requires at least one "$fallbackAlg" signing key with a "kid" property'
141 : 'Authorization server requires "$method" authentication method, but does not support "$fallbackAlg" algorithm.',
142 );
143 }
144
145 if (method == 'none') {
146 return const ClientAuthMethod.none();
147 }
148
149 throw StateError(
150 'The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.' +
151 (method == 'client_secret_basic'
152 ? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.'
153 : ' You set "$method" which is not allowed.'),
154 );
155}
156
157/// Creates a factory that generates client credentials.
158///
159/// The factory can be called multiple times to generate fresh credentials
160/// (important for private_key_jwt which includes timestamps).
161///
162/// Throws [AuthMethodUnsatisfiableError] if:
163/// - Server no longer supports the auth method
164/// - Key is no longer available in the keyset
165ClientCredentialsFactory createClientCredentialsFactory(
166 ClientAuthMethod authMethod,
167 Map<String, dynamic> serverMetadata,
168 ClientMetadata clientMetadata,
169 Runtime runtime,
170 Keyset? keyset,
171) {
172 // Ensure the AS still supports the auth method
173 if (!_supportedMethods(serverMetadata).contains(authMethod.method)) {
174 throw AuthMethodUnsatisfiableError(
175 'Client authentication method "${authMethod.method}" no longer supported',
176 );
177 }
178
179 if (authMethod.method == 'none') {
180 return () async => ClientCredentialsResult(
181 payload: OAuthClientCredentials(clientId: clientMetadata.clientId!),
182 );
183 }
184
185 if (authMethod.method == 'private_key_jwt') {
186 try {
187 // Find the key
188 if (keyset == null) {
189 throw StateError('A keyset is required for private_key_jwt');
190 }
191
192 final key = keyset.keys.firstWhere(
193 (k) =>
194 k.kid == authMethod.kid &&
195 k.usage == 'sign' &&
196 k.algorithms.any((a) => _supportedAlgs(serverMetadata).contains(a)),
197 orElse: () => throw StateError('Key not found: ${authMethod.kid}'),
198 );
199
200 final alg = key.algorithms.firstWhere(
201 (a) => _supportedAlgs(serverMetadata).contains(a),
202 orElse: () => throw StateError('No supported algorithm found'),
203 );
204
205 // https://www.rfc-editor.org/rfc/rfc7523.html#section-3
206 return () async {
207 final jti = await runtime.generateNonce();
208 final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
209
210 final jwt = await key.createJwt(
211 {'alg': alg},
212 {
213 // Issuer: the client_id
214 'iss': clientMetadata.clientId,
215 // Subject: the client_id
216 'sub': clientMetadata.clientId,
217 // Audience: the authorization server
218 'aud': serverMetadata['issuer'],
219 // JWT ID: unique identifier
220 'jti': jti,
221 // Issued at
222 'iat': now,
223 // Expiration: 1 minute from now
224 'exp': now + 60,
225 },
226 );
227
228 return ClientCredentialsResult(
229 payload: OAuthClientCredentials(
230 clientId: clientMetadata.clientId!,
231 clientAssertionType:
232 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
233 clientAssertion: jwt,
234 ),
235 );
236 };
237 } catch (cause) {
238 throw AuthMethodUnsatisfiableError('Failed to load private key: $cause');
239 }
240 }
241
242 throw AuthMethodUnsatisfiableError(
243 'Unsupported auth method: ${authMethod.method}',
244 );
245}
246
247/// Gets the list of supported authentication methods from server metadata.
248List<String> _supportedMethods(Map<String, dynamic> serverMetadata) {
249 final methods = serverMetadata['token_endpoint_auth_methods_supported'];
250 if (methods is List) {
251 return methods.map((m) => m.toString()).toList();
252 }
253 return [];
254}
255
256/// Gets the list of supported signing algorithms from server metadata.
257List<String> _supportedAlgs(Map<String, dynamic> serverMetadata) {
258 final algs =
259 serverMetadata['token_endpoint_auth_signing_alg_values_supported'];
260 if (algs is List) {
261 return algs.map((a) => a.toString()).toList();
262 }
263
264 // Default to ES256 as prescribed by the ATProto spec:
265 // > Clients and Authorization Servers currently must support the ES256
266 // > cryptographic system [for client authentication].
267 // https://atproto.com/specs/oauth#confidential-client-authentication
268 return [fallbackAlg];
269}
270
271/// Placeholder for Keyset class.
272///
273/// In the full implementation, this would come from @atproto/jwk package.
274/// For now, we use a simple implementation.
275class Keyset {
276 final List<Key> keys;
277
278 const Keyset(this.keys);
279
280 int get size => keys.length;
281
282 Map<String, dynamic> toJSON() {
283 return {'keys': keys.map((k) => k.bareJwk).toList()};
284 }
285}