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}