Skip to content

Commit 1cc8bdd

Browse files
authored
feat: https auth token decoding (#30)
1 parent 008ed31 commit 1cc8bdd

File tree

12 files changed

+799
-239
lines changed

12 files changed

+799
-239
lines changed

example/basic/pubspec.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,20 @@ dependencies:
1414
dev_dependencies:
1515
build_runner: ^2.10.5
1616
lints: ^6.0.0
17+
18+
dependency_overrides:
19+
googleapis_firestore:
20+
git:
21+
url: https://github.com/invertase/dart_firebase_admin.git
22+
ref: next
23+
path: packages/googleapis_firestore
24+
googleapis_auth_utils:
25+
git:
26+
url: https://github.com/invertase/dart_firebase_admin.git
27+
ref: next
28+
path: packages/googleapis_auth_utils
29+
googleapis_storage:
30+
git:
31+
url: https://github.com/invertase/dart_firebase_admin.git
32+
ref: next
33+
path: packages/googleapis_storage

example/firestore_test/pubspec.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,20 @@ dev_dependencies:
1515
test: ^1.29.0
1616
http: ^1.6.0
1717
lints: ^6.0.0
18+
19+
dependency_overrides:
20+
googleapis_firestore:
21+
git:
22+
url: https://github.com/invertase/dart_firebase_admin.git
23+
ref: next
24+
path: packages/googleapis_firestore
25+
googleapis_auth_utils:
26+
git:
27+
url: https://github.com/invertase/dart_firebase_admin.git
28+
ref: next
29+
path: packages/googleapis_auth_utils
30+
googleapis_storage:
31+
git:
32+
url: https://github.com/invertase/dart_firebase_admin.git
33+
ref: next
34+
path: packages/googleapis_storage

example/with_options/pubspec.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,20 @@ dependencies:
1313
dev_dependencies:
1414
build_runner: ^2.10.5
1515
lints: ^6.0.0
16+
17+
dependency_overrides:
18+
googleapis_firestore:
19+
git:
20+
url: https://github.com/invertase/dart_firebase_admin.git
21+
ref: next
22+
path: packages/googleapis_firestore
23+
googleapis_auth_utils:
24+
git:
25+
url: https://github.com/invertase/dart_firebase_admin.git
26+
ref: next
27+
path: packages/googleapis_auth_utils
28+
googleapis_storage:
29+
git:
30+
url: https://github.com/invertase/dart_firebase_admin.git
31+
ref: next
32+
path: packages/googleapis_storage

lib/firebase_functions.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@
5353
/// - [onInit] for safe initialization with secrets
5454
library;
5555

56-
// Re-export dart_firebase_admin types for convenience
57-
export 'package:dart_firebase_admin/firestore.dart'
56+
// Re-export Firestore types for convenience
57+
export 'package:googleapis_firestore/googleapis_firestore.dart'
5858
show DocumentData, DocumentSnapshot, QueryDocumentSnapshot;
5959
// Re-export Shelf types for convenience
6060
export 'package:shelf/shelf.dart' show Request, Response;

lib/src/firebase.dart

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dart:async';
22
import 'dart:io';
33

44
import 'package:dart_firebase_admin/dart_firebase_admin.dart';
5-
import 'package:dart_firebase_admin/firestore.dart';
5+
import 'package:googleapis_firestore/googleapis_firestore.dart' as gfs;
66
import 'package:shelf/shelf.dart';
77

88
import 'alerts/alerts_namespace.dart';
@@ -21,8 +21,8 @@ class Firebase {
2121
_initializeAdminSDK();
2222
}
2323

24-
FirebaseAdminApp? _adminApp;
25-
Firestore? _firestoreInstance;
24+
FirebaseApp? _adminApp;
25+
gfs.Firestore? _firestoreInstance;
2626

2727
/// Initialize the Firebase Admin SDK
2828
void _initializeAdminSDK() {
@@ -52,13 +52,15 @@ class Firebase {
5252
print('Initializing Firebase Admin SDK (project: $projectId)');
5353

5454
// Initialize Admin SDK
55-
_adminApp = FirebaseAdminApp.initializeApp(
56-
projectId,
57-
Credential.fromApplicationDefaultCredentials(),
55+
_adminApp = FirebaseApp.initializeApp(
56+
options: AppOptions(
57+
credential: Credential.fromApplicationDefaultCredentials(),
58+
projectId: projectId,
59+
),
5860
);
5961

6062
// Create Firestore instance
61-
_firestoreInstance = Firestore(_adminApp!);
63+
_firestoreInstance = _adminApp!.firestore();
6264

6365
print('Firebase Admin SDK initialized successfully');
6466
} catch (e) {
@@ -68,7 +70,10 @@ class Firebase {
6870
}
6971

7072
/// Get the Firestore instance
71-
Firestore? get firestoreAdmin => _firestoreInstance;
73+
gfs.Firestore? get firestoreAdmin => _firestoreInstance;
74+
75+
/// Get the Firebase Admin App instance
76+
FirebaseApp? get adminApp => _adminApp;
7277

7378
/// HTTPS triggers namespace.
7479
HttpsNamespace get https => HttpsNamespace(this);

lib/src/firestore/document_snapshot.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import 'package:dart_firebase_admin/firestore.dart' show DocumentData;
1+
import 'package:googleapis_firestore/googleapis_firestore.dart'
2+
show DocumentData;
23

34
export '../common/change.dart';
45

lib/src/https/auth.dart

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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

Comments
 (0)