|
| 1 | +/// Authentication and App Check token extraction for callable functions. |
| 2 | +library; |
| 3 | + |
| 4 | +import 'dart:convert'; |
| 5 | + |
| 6 | +import 'package:dart_firebase_admin/dart_firebase_admin.dart'; |
| 7 | +import 'package:shelf/shelf.dart'; |
| 8 | + |
| 9 | +import 'callable.dart'; |
| 10 | + |
| 11 | +/// Status of token validation. |
| 12 | +enum TokenStatus { |
| 13 | + /// Token is missing from request. |
| 14 | + missing, |
| 15 | + |
| 16 | + /// Token is present but invalid. |
| 17 | + invalid, |
| 18 | + |
| 19 | + /// Token is present and valid. |
| 20 | + valid, |
| 21 | +} |
| 22 | + |
| 23 | +/// Result of checking auth and app check tokens. |
| 24 | +class TokenVerificationResult { |
| 25 | + const TokenVerificationResult({required this.auth, required this.app}); |
| 26 | + |
| 27 | + final TokenStatus auth; |
| 28 | + final TokenStatus app; |
| 29 | +} |
| 30 | + |
| 31 | +/// Regular expression for validating JWT format. |
| 32 | +final _jwtRegex = RegExp( |
| 33 | + r'^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$', |
| 34 | +); |
| 35 | + |
| 36 | +/// Extracts and validates auth token from request. |
| 37 | +/// |
| 38 | +/// In emulator mode (when [skipTokenVerification] is true), tokens are decoded |
| 39 | +/// but not verified. In production, tokens are verified using the Firebase |
| 40 | +/// Admin SDK. |
| 41 | +/// |
| 42 | +/// The [adminApp] is required for production token verification. |
| 43 | +/// |
| 44 | +/// Returns a tuple of (TokenStatus, AuthData?). |
| 45 | +Future<(TokenStatus, AuthData?)> extractAuthToken( |
| 46 | + Request request, { |
| 47 | + required bool skipTokenVerification, |
| 48 | + FirebaseApp? adminApp, |
| 49 | +}) async { |
| 50 | + final authorization = request.headers['authorization']; |
| 51 | + if (authorization == null || authorization.isEmpty) { |
| 52 | + return (TokenStatus.missing, null); |
| 53 | + } |
| 54 | + |
| 55 | + // Parse "Bearer <token>" format |
| 56 | + final match = RegExp( |
| 57 | + r'^Bearer\s+(.*)$', |
| 58 | + caseSensitive: false, |
| 59 | + ).firstMatch(authorization); |
| 60 | + if (match == null) { |
| 61 | + return (TokenStatus.invalid, null); |
| 62 | + } |
| 63 | + |
| 64 | + final idToken = match.group(1)!; |
| 65 | + |
| 66 | + try { |
| 67 | + String uid; |
| 68 | + Map<String, dynamic>? decodedToken; |
| 69 | + |
| 70 | + if (skipTokenVerification) { |
| 71 | + // In emulator mode, just decode without verification |
| 72 | + decodedToken = _unsafeDecodeIdToken(idToken); |
| 73 | + |
| 74 | + uid = |
| 75 | + decodedToken['uid'] as String? ?? |
| 76 | + decodedToken['sub'] as String? ?? |
| 77 | + decodedToken['user_id'] as String? ?? |
| 78 | + ''; |
| 79 | + } else { |
| 80 | + // In production, verify the token using Firebase Admin SDK |
| 81 | + if (adminApp == null) { |
| 82 | + // Can't verify without admin app |
| 83 | + return (TokenStatus.invalid, null); |
| 84 | + } |
| 85 | + |
| 86 | + final auth = adminApp.auth(); |
| 87 | + final decoded = await auth.verifyIdToken(idToken); |
| 88 | + uid = decoded.uid; |
| 89 | + decodedToken = { |
| 90 | + 'uid': decoded.uid, |
| 91 | + 'sub': decoded.sub, |
| 92 | + 'aud': decoded.aud, |
| 93 | + 'iss': decoded.iss, |
| 94 | + 'iat': decoded.iat, |
| 95 | + 'exp': decoded.exp, |
| 96 | + if (decoded.email != null) 'email': decoded.email, |
| 97 | + if (decoded.emailVerified != null) |
| 98 | + 'email_verified': decoded.emailVerified, |
| 99 | + if (decoded.phoneNumber != null) 'phone_number': decoded.phoneNumber, |
| 100 | + if (decoded.picture != null) 'picture': decoded.picture, |
| 101 | + }; |
| 102 | + } |
| 103 | + |
| 104 | + if (uid.isEmpty) { |
| 105 | + return (TokenStatus.invalid, null); |
| 106 | + } |
| 107 | + |
| 108 | + return ( |
| 109 | + TokenStatus.valid, |
| 110 | + AuthData(uid: uid, token: decodedToken, rawToken: idToken), |
| 111 | + ); |
| 112 | + } catch (e) { |
| 113 | + return (TokenStatus.invalid, null); |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +/// Extracts and validates App Check token from request. |
| 118 | +/// |
| 119 | +/// In emulator mode (when [skipTokenVerification] is true), tokens are decoded |
| 120 | +/// but not verified. In production, tokens are verified using the Firebase |
| 121 | +/// Admin SDK. |
| 122 | +/// |
| 123 | +/// The [adminApp] is required for production token verification. |
| 124 | +/// |
| 125 | +/// Returns a tuple of (TokenStatus, AppCheckData?). |
| 126 | +Future<(TokenStatus, AppCheckData?)> extractAppCheckToken( |
| 127 | + Request request, { |
| 128 | + required bool skipTokenVerification, |
| 129 | + FirebaseApp? adminApp, |
| 130 | +}) async { |
| 131 | + final appCheckToken = request.headers['x-firebase-appcheck']; |
| 132 | + if (appCheckToken == null || appCheckToken.isEmpty) { |
| 133 | + return (TokenStatus.missing, null); |
| 134 | + } |
| 135 | + |
| 136 | + try { |
| 137 | + String appId; |
| 138 | + |
| 139 | + if (skipTokenVerification) { |
| 140 | + // In emulator mode, just decode without verification |
| 141 | + final decodedToken = _unsafeDecodeAppCheckToken(appCheckToken); |
| 142 | + |
| 143 | + appId = |
| 144 | + decodedToken['app_id'] as String? ?? |
| 145 | + decodedToken['sub'] as String? ?? |
| 146 | + ''; |
| 147 | + } else { |
| 148 | + // In production, verify the token using Firebase Admin SDK |
| 149 | + if (adminApp == null) { |
| 150 | + // Can't verify without admin app |
| 151 | + return (TokenStatus.invalid, null); |
| 152 | + } |
| 153 | + |
| 154 | + final appCheck = adminApp.appCheck(); |
| 155 | + final decoded = await appCheck.verifyToken(appCheckToken); |
| 156 | + appId = decoded.appId; |
| 157 | + } |
| 158 | + |
| 159 | + if (appId.isEmpty) { |
| 160 | + return (TokenStatus.invalid, null); |
| 161 | + } |
| 162 | + |
| 163 | + return ( |
| 164 | + TokenStatus.valid, |
| 165 | + AppCheckData(appId: appId, token: appCheckToken), |
| 166 | + ); |
| 167 | + } catch (e) { |
| 168 | + return (TokenStatus.invalid, null); |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +/// Checks both auth and app check tokens on a request. |
| 173 | +/// |
| 174 | +/// Returns a record containing the verification result and extracted data. |
| 175 | +Future< |
| 176 | + ({ |
| 177 | + TokenVerificationResult result, |
| 178 | + AuthData? authData, |
| 179 | + AppCheckData? appCheckData, |
| 180 | + }) |
| 181 | +> |
| 182 | +checkTokens( |
| 183 | + Request request, { |
| 184 | + required bool skipTokenVerification, |
| 185 | + FirebaseApp? adminApp, |
| 186 | +}) async { |
| 187 | + final (authStatus, authData) = await extractAuthToken( |
| 188 | + request, |
| 189 | + skipTokenVerification: skipTokenVerification, |
| 190 | + adminApp: adminApp, |
| 191 | + ); |
| 192 | + |
| 193 | + final (appStatus, appCheckData) = await extractAppCheckToken( |
| 194 | + request, |
| 195 | + skipTokenVerification: skipTokenVerification, |
| 196 | + adminApp: adminApp, |
| 197 | + ); |
| 198 | + |
| 199 | + return ( |
| 200 | + result: TokenVerificationResult(auth: authStatus, app: appStatus), |
| 201 | + authData: authData, |
| 202 | + appCheckData: appCheckData, |
| 203 | + ); |
| 204 | +} |
| 205 | + |
| 206 | +// --- Private unsafe decode functions (for emulator mode only) --- |
| 207 | + |
| 208 | +/// Decodes a JWT token without verification. |
| 209 | +/// |
| 210 | +/// **WARNING**: Only use in emulator mode. |
| 211 | +Map<String, dynamic> _unsafeDecodeToken(String token) { |
| 212 | + if (!_jwtRegex.hasMatch(token)) { |
| 213 | + return {}; |
| 214 | + } |
| 215 | + |
| 216 | + final parts = token.split('.'); |
| 217 | + if (parts.length != 3) { |
| 218 | + return {}; |
| 219 | + } |
| 220 | + |
| 221 | + try { |
| 222 | + final payloadBase64 = parts[1]; |
| 223 | + final normalized = base64Url.normalize(payloadBase64); |
| 224 | + final payloadJson = utf8.decode(base64Url.decode(normalized)); |
| 225 | + final payload = jsonDecode(payloadJson); |
| 226 | + |
| 227 | + if (payload is Map<String, dynamic>) { |
| 228 | + return payload; |
| 229 | + } |
| 230 | + return {}; |
| 231 | + } catch (e) { |
| 232 | + return {}; |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +/// Decodes an ID token without verification. |
| 237 | +/// |
| 238 | +/// **WARNING**: Only use in emulator mode. |
| 239 | +Map<String, dynamic> _unsafeDecodeIdToken(String token) { |
| 240 | + final decoded = _unsafeDecodeToken(token); |
| 241 | + // Set uid from sub claim if not already present |
| 242 | + if (!decoded.containsKey('uid') && decoded.containsKey('sub')) { |
| 243 | + decoded['uid'] = decoded['sub']; |
| 244 | + } |
| 245 | + return decoded; |
| 246 | +} |
| 247 | + |
| 248 | +/// Decodes an App Check token without verification. |
| 249 | +/// |
| 250 | +/// **WARNING**: Only use in emulator mode. |
| 251 | +Map<String, dynamic> _unsafeDecodeAppCheckToken(String token) { |
| 252 | + final decoded = _unsafeDecodeToken(token); |
| 253 | + // Set app_id from sub claim if not already present |
| 254 | + if (!decoded.containsKey('app_id') && decoded.containsKey('sub')) { |
| 255 | + decoded['app_id'] = decoded['sub']; |
| 256 | + } |
| 257 | + return decoded; |
| 258 | +} |
0 commit comments