forked from firebase/firebase-functions-dart
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtoken_verifier.dart
More file actions
246 lines (206 loc) · 7.52 KB
/
token_verifier.dart
File metadata and controls
246 lines (206 loc) · 7.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
// Copyright 2026, the Firebase project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// MIT-style license that can be found in the LICENSE file.
/// JWT token verification for Auth Blocking functions.
library;
import 'dart:convert';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:http/http.dart' as http;
import '../https/error.dart';
/// URL to fetch Google's public keys in JWK format for JWT verification.
/// This endpoint returns keys directly as JWKs, no certificate parsing needed.
const _googleJwksUrl = 'https://www.googleapis.com/oauth2/v3/certs';
/// Cache duration for Google keys (1 hour default).
const _keysCacheDuration = Duration(hours: 1);
/// Verifier for Auth Blocking JWT tokens.
///
/// This class handles JWT signature verification and claims validation
/// for Identity Platform blocking functions.
///
/// In production, tokens are verified against Google's public keys.
/// In emulator/debug mode, verification can be skipped.
class AuthBlockingTokenVerifier {
AuthBlockingTokenVerifier({
required this.projectId,
this.isEmulator = false,
http.Client? httpClient,
}) : _httpClient = httpClient ?? http.Client();
final String projectId;
final bool isEmulator;
final http.Client _httpClient;
/// Cached public keys.
static Map<String, JWTKey>? _cachedKeys;
/// When the cached keys expire.
static DateTime? _keysExpireAt;
/// Expected issuer for Auth Blocking tokens.
String get _expectedIssuer => 'https://securetoken.google.com/$projectId';
/// Default audience for GCF v1.
String get _defaultAudience => '$projectId.cloudfunctions.net/';
/// Verifies an Auth Blocking JWT token and returns the decoded payload.
///
/// If [audience] is provided, it's used for audience validation.
/// For Cloud Run (GCF v2), pass `"run.app"` as the audience.
///
/// Throws [UnauthenticatedError] if verification fails.
Future<Map<String, dynamic>> verifyToken(
String token, {
String? audience,
}) async {
// In emulator mode, just decode without verification
if (isEmulator) {
return _unsafeDecode(token);
}
// Decode the token first to get the header
JWT decoded;
try {
decoded = JWT.decode(token);
} catch (e) {
throw UnauthenticatedError('Invalid JWT format: $e');
}
final kid = decoded.header?['kid'] as String?;
if (kid == null) {
throw UnauthenticatedError('Invalid JWT: missing "kid" header');
}
// Get the keys from Google
final keys = await _getGoogleKeys();
final key = keys[kid];
if (key == null) {
throw UnauthenticatedError('Invalid JWT: unknown "kid"');
}
// Verify the token using dart_jsonwebtoken
try {
JWT.verify(token, key);
} on JWTException catch (e) {
throw UnauthenticatedError('Invalid JWT: ${e.message}');
} catch (e) {
throw UnauthenticatedError('Invalid JWT: $e');
}
// Extract the payload as a map
final payload = decoded.payload as Map<String, dynamic>;
// Validate Firebase-specific claims
_validateClaims(payload, audience);
return payload;
}
/// Decodes a JWT without verification (for emulator mode only).
Map<String, dynamic> _unsafeDecode(String token) {
try {
final decoded = JWT.decode(token);
return decoded.payload as Map<String, dynamic>;
} catch (e) {
throw InvalidArgumentError('Invalid JWT format');
}
}
/// Fetches Google's public keys and returns a map of kid to JWTKey.
///
/// Uses the JWK endpoint which returns keys directly in JSON Web Key format,
/// so no manual certificate parsing is needed.
Future<Map<String, JWTKey>> _getGoogleKeys() async {
// Return cached keys if still valid
if (_cachedKeys != null &&
_keysExpireAt != null &&
DateTime.now().isBefore(_keysExpireAt!)) {
return _cachedKeys!;
}
// Fetch keys from Google's JWK endpoint
final response = await _httpClient.get(Uri.parse(_googleJwksUrl));
if (response.statusCode != 200) {
throw StateError(
'Failed to fetch Google public keys from $_googleJwksUrl: ${response.statusCode}',
);
}
// Parse the JWK Set response
final jwksJson = jsonDecode(response.body) as Map<String, dynamic>;
final newKeys = <String, JWTKey>{};
// The response contains a "keys" array with JWK objects
final keys = jwksJson['keys'] as List<dynamic>?;
if (keys != null) {
for (final keyJson in keys) {
try {
final jwk = keyJson as Map<String, dynamic>;
final kid = jwk['kid'] as String?;
if (kid != null) {
newKeys[kid] = JWTKey.fromJWK(jwk);
}
} catch (e) {
// Skip keys that fail to parse
continue;
}
}
}
// Cache with expiration from Cache-Control header or default
final cacheControl = response.headers['cache-control'];
var cacheDuration = _keysCacheDuration;
if (cacheControl != null) {
final maxAgeMatch = RegExp(r'max-age=(\d+)').firstMatch(cacheControl);
if (maxAgeMatch != null) {
cacheDuration = Duration(seconds: int.parse(maxAgeMatch.group(1)!));
}
}
_cachedKeys = newKeys;
_keysExpireAt = DateTime.now().add(cacheDuration);
return newKeys;
}
/// Validates JWT claims.
void _validateClaims(Map<String, dynamic> payload, String? audience) {
// Validate issuer
final iss = payload['iss'] as String?;
if (iss != _expectedIssuer) {
throw UnauthenticatedError(
'Invalid token issuer. Expected $_expectedIssuer, got $iss',
);
}
// Validate audience
final aud = payload['aud'];
final expectedAudience = audience ?? _defaultAudience;
bool audienceValid;
if (aud is String) {
audienceValid = aud == expectedAudience || aud.contains(expectedAudience);
} else if (aud is List) {
audienceValid = aud.any(
(a) =>
a == expectedAudience || (a as String).contains(expectedAudience),
);
} else {
audienceValid = false;
}
if (!audienceValid) {
throw UnauthenticatedError(
'Invalid token audience. Expected $expectedAudience, got $aud',
);
}
// Validate expiration
final exp = payload['exp'] as int?;
if (exp == null) {
throw UnauthenticatedError('Token missing expiration claim');
}
final expiration = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
if (DateTime.now().isAfter(expiration)) {
throw UnauthenticatedError('Token has expired');
}
// Validate issued-at (not in the future)
final iat = payload['iat'] as int?;
if (iat != null) {
final issuedAt = DateTime.fromMillisecondsSinceEpoch(iat * 1000);
// Allow 5 minutes of clock skew
if (issuedAt.isAfter(DateTime.now().add(const Duration(minutes: 5)))) {
throw UnauthenticatedError('Token issued in the future');
}
}
// Validate subject (except for beforeSendEmail/beforeSendSms)
final eventType = payload['event_type'] as String?;
if (eventType != 'beforeSendEmail' && eventType != 'beforeSendSms') {
final sub = payload['sub'] as String?;
if (sub == null || sub.isEmpty) {
throw UnauthenticatedError('Token missing subject claim');
}
if (sub.length > 128) {
throw UnauthenticatedError('Token subject exceeds 128 characters');
}
}
}
/// Clears the key cache (useful for testing).
static void clearCertificateCache() {
_cachedKeys = null;
_keysExpireAt = null;
}
}