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}