UNPKG

19.2 kBJavaScriptView Raw
1"use strict";
2// The MIT License (MIT)
3//
4// Copyright (c) 2022 Firebase
5//
6// Permission is hereby granted, free of charge, to any person obtaining a copy
7// of this software and associated documentation files (the "Software"), to deal
8// in the Software without restriction, including without limitation the rights
9// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10// copies of the Software, and to permit persons to whom the Software is
11// furnished to do so, subject to the following conditions:
12//
13// The above copyright notice and this permission notice shall be included in all
14// copies or substantial portions of the Software.
15//
16// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22// SOFTWARE.
23Object.defineProperty(exports, "__esModule", { value: true });
24exports.wrapHandler = exports.getUpdateMask = exports.validateAuthResponse = exports.parseAuthEventContext = exports.generateResponsePayload = exports.parseAuthUserRecord = exports.parseMultiFactor = exports.parseDate = exports.parseProviderData = exports.parseMetadata = exports.isValidRequest = exports.userRecordConstructor = exports.UserRecordMetadata = exports.HttpsError = void 0;
25const auth = require("firebase-admin/auth");
26const logger = require("../../logger");
27const app_1 = require("../app");
28const debug_1 = require("../debug");
29const https_1 = require("./https");
30Object.defineProperty(exports, "HttpsError", { enumerable: true, get: function () { return https_1.HttpsError; } });
31const DISALLOWED_CUSTOM_CLAIMS = [
32 "acr",
33 "amr",
34 "at_hash",
35 "aud",
36 "auth_time",
37 "azp",
38 "cnf",
39 "c_hash",
40 "exp",
41 "iat",
42 "iss",
43 "jti",
44 "nbf",
45 "nonce",
46 "firebase",
47];
48const CLAIMS_MAX_PAYLOAD_SIZE = 1000;
49const EVENT_MAPPING = {
50 beforeCreate: "providers/cloud.auth/eventTypes/user.beforeCreate",
51 beforeSignIn: "providers/cloud.auth/eventTypes/user.beforeSignIn",
52 beforeSendEmail: "providers/cloud.auth/eventTypes/user.beforeSendEmail",
53 beforeSendSms: "providers/cloud.auth/eventTypes/user.beforeSendSms",
54};
55/**
56 * Helper class to create the user metadata in a `UserRecord` object.
57 */
58class UserRecordMetadata {
59 constructor(creationTime, lastSignInTime) {
60 this.creationTime = creationTime;
61 this.lastSignInTime = lastSignInTime;
62 }
63 /** Returns a plain JavaScript object with the properties of UserRecordMetadata. */
64 toJSON() {
65 return {
66 creationTime: this.creationTime,
67 lastSignInTime: this.lastSignInTime,
68 };
69 }
70}
71exports.UserRecordMetadata = UserRecordMetadata;
72/**
73 * Helper function that creates a `UserRecord` class from data sent over the wire.
74 * @param wireData data sent over the wire
75 * @returns an instance of `UserRecord` with correct toJSON functions
76 */
77function userRecordConstructor(wireData) {
78 // Falsey values from the wire format proto get lost when converted to JSON, this adds them back.
79 const falseyValues = {
80 email: null,
81 emailVerified: false,
82 displayName: null,
83 photoURL: null,
84 phoneNumber: null,
85 disabled: false,
86 providerData: [],
87 customClaims: {},
88 passwordSalt: null,
89 passwordHash: null,
90 tokensValidAfterTime: null,
91 };
92 const record = { ...falseyValues, ...wireData };
93 const meta = record.metadata;
94 if (meta) {
95 record.metadata = new UserRecordMetadata(meta.createdAt || meta.creationTime, meta.lastSignedInAt || meta.lastSignInTime);
96 }
97 else {
98 record.metadata = new UserRecordMetadata(null, null);
99 }
100 record.toJSON = () => {
101 const { uid, email, emailVerified, displayName, photoURL, phoneNumber, disabled, passwordHash, passwordSalt, tokensValidAfterTime, } = record;
102 const json = {
103 uid,
104 email,
105 emailVerified,
106 displayName,
107 photoURL,
108 phoneNumber,
109 disabled,
110 passwordHash,
111 passwordSalt,
112 tokensValidAfterTime,
113 };
114 json.metadata = record.metadata.toJSON();
115 json.customClaims = JSON.parse(JSON.stringify(record.customClaims));
116 json.providerData = record.providerData.map((entry) => {
117 const newEntry = { ...entry };
118 newEntry.toJSON = () => entry;
119 return newEntry;
120 });
121 return json;
122 };
123 return record;
124}
125exports.userRecordConstructor = userRecordConstructor;
126/**
127 * Checks for a valid identity platform web request, otherwise throws an HttpsError.
128 * @internal
129 */
130function isValidRequest(req) {
131 var _a, _b;
132 if (req.method !== "POST") {
133 logger.warn(`Request has invalid method "${req.method}".`);
134 return false;
135 }
136 const contentType = (req.header("Content-Type") || "").toLowerCase();
137 if (!contentType.includes("application/json")) {
138 logger.warn("Request has invalid header Content-Type.");
139 return false;
140 }
141 if (!((_b = (_a = req.body) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.jwt)) {
142 logger.warn("Request has an invalid body.");
143 return false;
144 }
145 return true;
146}
147exports.isValidRequest = isValidRequest;
148/**
149 * Decode, but not verify, an Auth Blocking token.
150 *
151 * Do not use in production. Token should always be verified using the Admin SDK.
152 *
153 * This is exposed only for testing.
154 */
155function unsafeDecodeAuthBlockingToken(token) {
156 const decoded = (0, https_1.unsafeDecodeToken)(token);
157 decoded.uid = decoded.sub;
158 return decoded;
159}
160/**
161 * Helper function to parse the decoded metadata object into a `UserMetaData` object
162 * @internal
163 */
164function parseMetadata(metadata) {
165 const creationTime = (metadata === null || metadata === void 0 ? void 0 : metadata.creation_time)
166 ? new Date(metadata.creation_time).toUTCString()
167 : null;
168 const lastSignInTime = (metadata === null || metadata === void 0 ? void 0 : metadata.last_sign_in_time)
169 ? new Date(metadata.last_sign_in_time).toUTCString()
170 : null;
171 return {
172 creationTime,
173 lastSignInTime,
174 };
175}
176exports.parseMetadata = parseMetadata;
177/**
178 * Helper function to parse the decoded user info array into an `AuthUserInfo` array.
179 * @internal
180 */
181function parseProviderData(providerData) {
182 const providers = [];
183 for (const provider of providerData) {
184 providers.push({
185 uid: provider.uid,
186 displayName: provider.display_name,
187 email: provider.email,
188 photoURL: provider.photo_url,
189 providerId: provider.provider_id,
190 phoneNumber: provider.phone_number,
191 });
192 }
193 return providers;
194}
195exports.parseProviderData = parseProviderData;
196/**
197 * Helper function to parse the date into a UTC string.
198 * @internal
199 */
200function parseDate(tokensValidAfterTime) {
201 if (!tokensValidAfterTime) {
202 return null;
203 }
204 tokensValidAfterTime = tokensValidAfterTime * 1000;
205 try {
206 const date = new Date(tokensValidAfterTime);
207 if (!isNaN(date.getTime())) {
208 return date.toUTCString();
209 }
210 }
211 catch {
212 // ignore error
213 }
214 return null;
215}
216exports.parseDate = parseDate;
217/**
218 * Helper function to parse the decoded enrolled factors into a valid MultiFactorSettings
219 * @internal
220 */
221function parseMultiFactor(multiFactor) {
222 if (!multiFactor) {
223 return null;
224 }
225 const parsedEnrolledFactors = [];
226 for (const factor of multiFactor.enrolled_factors || []) {
227 if (!factor.uid) {
228 throw new https_1.HttpsError("internal", "INTERNAL ASSERT FAILED: Invalid multi-factor info response");
229 }
230 const enrollmentTime = factor.enrollment_time
231 ? new Date(factor.enrollment_time).toUTCString()
232 : null;
233 parsedEnrolledFactors.push({
234 uid: factor.uid,
235 factorId: factor.phone_number ? factor.factor_id || "phone" : factor.factor_id,
236 displayName: factor.display_name,
237 enrollmentTime,
238 phoneNumber: factor.phone_number,
239 });
240 }
241 if (parsedEnrolledFactors.length > 0) {
242 return {
243 enrolledFactors: parsedEnrolledFactors,
244 };
245 }
246 return null;
247}
248exports.parseMultiFactor = parseMultiFactor;
249/**
250 * Parses the decoded user record into a valid UserRecord for use in the handler
251 * @internal
252 */
253function parseAuthUserRecord(decodedJWTUserRecord) {
254 if (!decodedJWTUserRecord.uid) {
255 throw new https_1.HttpsError("internal", "INTERNAL ASSERT FAILED: Invalid user response");
256 }
257 const disabled = decodedJWTUserRecord.disabled || false;
258 const metadata = parseMetadata(decodedJWTUserRecord.metadata);
259 const providerData = parseProviderData(decodedJWTUserRecord.provider_data);
260 const tokensValidAfterTime = parseDate(decodedJWTUserRecord.tokens_valid_after_time);
261 const multiFactor = parseMultiFactor(decodedJWTUserRecord.multi_factor);
262 return {
263 uid: decodedJWTUserRecord.uid,
264 email: decodedJWTUserRecord.email,
265 emailVerified: decodedJWTUserRecord.email_verified,
266 displayName: decodedJWTUserRecord.display_name,
267 photoURL: decodedJWTUserRecord.photo_url,
268 phoneNumber: decodedJWTUserRecord.phone_number,
269 disabled,
270 metadata,
271 providerData,
272 passwordHash: decodedJWTUserRecord.password_hash,
273 passwordSalt: decodedJWTUserRecord.password_salt,
274 customClaims: decodedJWTUserRecord.custom_claims,
275 tenantId: decodedJWTUserRecord.tenant_id,
276 tokensValidAfterTime,
277 multiFactor,
278 };
279}
280exports.parseAuthUserRecord = parseAuthUserRecord;
281/** Helper to get the `AdditionalUserInfo` from the decoded JWT */
282function parseAdditionalUserInfo(decodedJWT) {
283 let profile;
284 let username;
285 if (decodedJWT.raw_user_info) {
286 try {
287 profile = JSON.parse(decodedJWT.raw_user_info);
288 }
289 catch (err) {
290 logger.debug(`Parse Error: ${err.message}`);
291 }
292 }
293 if (profile) {
294 if (decodedJWT.sign_in_method === "github.com") {
295 username = profile.login;
296 }
297 if (decodedJWT.sign_in_method === "twitter.com") {
298 username = profile.screen_name;
299 }
300 }
301 return {
302 providerId: decodedJWT.sign_in_method === "emailLink" ? "password" : decodedJWT.sign_in_method,
303 profile,
304 username,
305 isNewUser: decodedJWT.event_type === "beforeCreate" ? true : false,
306 recaptchaScore: decodedJWT.recaptcha_score,
307 email: decodedJWT.email,
308 phoneNumber: decodedJWT.phone_number,
309 };
310}
311/**
312 * Helper to generate a response from the blocking function to the Firebase Auth backend.
313 * @internal
314 */
315function generateResponsePayload(authResponse) {
316 if (!authResponse) {
317 return {};
318 }
319 const { recaptchaActionOverride, ...formattedAuthResponse } = authResponse;
320 const result = {};
321 const updateMask = getUpdateMask(formattedAuthResponse);
322 if (updateMask.length !== 0) {
323 result.userRecord = {
324 ...formattedAuthResponse,
325 updateMask,
326 };
327 }
328 if (recaptchaActionOverride !== undefined) {
329 result.recaptchaActionOverride = recaptchaActionOverride;
330 }
331 return result;
332}
333exports.generateResponsePayload = generateResponsePayload;
334/** Helper to get the Credential from the decoded JWT */
335function parseAuthCredential(decodedJWT, time) {
336 if (!decodedJWT.sign_in_attributes &&
337 !decodedJWT.oauth_id_token &&
338 !decodedJWT.oauth_access_token &&
339 !decodedJWT.oauth_refresh_token) {
340 return null;
341 }
342 return {
343 claims: decodedJWT.sign_in_attributes,
344 idToken: decodedJWT.oauth_id_token,
345 accessToken: decodedJWT.oauth_access_token,
346 refreshToken: decodedJWT.oauth_refresh_token,
347 expirationTime: decodedJWT.oauth_expires_in
348 ? new Date(time + decodedJWT.oauth_expires_in * 1000).toUTCString()
349 : undefined,
350 secret: decodedJWT.oauth_token_secret,
351 providerId: decodedJWT.sign_in_method === "emailLink" ? "password" : decodedJWT.sign_in_method,
352 signInMethod: decodedJWT.sign_in_method,
353 };
354}
355/**
356 * Parses the decoded jwt into a valid AuthEventContext for use in the handler
357 * @internal
358 */
359function parseAuthEventContext(decodedJWT, projectId, time = new Date().getTime()) {
360 const eventType = (EVENT_MAPPING[decodedJWT.event_type] || decodedJWT.event_type) +
361 (decodedJWT.sign_in_method ? `:${decodedJWT.sign_in_method}` : "");
362 return {
363 locale: decodedJWT.locale,
364 ipAddress: decodedJWT.ip_address,
365 userAgent: decodedJWT.user_agent,
366 eventId: decodedJWT.event_id,
367 eventType,
368 authType: decodedJWT.user_record ? "USER" : "UNAUTHENTICATED",
369 resource: {
370 // TODO(colerogers): figure out the correct service
371 service: "identitytoolkit.googleapis.com",
372 name: decodedJWT.tenant_id
373 ? `projects/${projectId}/tenants/${decodedJWT.tenant_id}`
374 : `projects/${projectId}`,
375 },
376 timestamp: new Date(decodedJWT.iat * 1000).toUTCString(),
377 additionalUserInfo: parseAdditionalUserInfo(decodedJWT),
378 credential: parseAuthCredential(decodedJWT, time),
379 emailType: decodedJWT.email_type,
380 smsType: decodedJWT.sms_type,
381 params: {},
382 };
383}
384exports.parseAuthEventContext = parseAuthEventContext;
385/**
386 * Checks the handler response for invalid customClaims & sessionClaims objects
387 * @internal
388 */
389function validateAuthResponse(eventType, authRequest) {
390 if (!authRequest) {
391 authRequest = {};
392 }
393 if (authRequest.customClaims) {
394 const invalidClaims = DISALLOWED_CUSTOM_CLAIMS.filter((claim) => authRequest.customClaims.hasOwnProperty(claim));
395 if (invalidClaims.length > 0) {
396 throw new https_1.HttpsError("invalid-argument", `The customClaims claims "${invalidClaims.join(",")}" are reserved and cannot be specified.`);
397 }
398 if (JSON.stringify(authRequest.customClaims).length > CLAIMS_MAX_PAYLOAD_SIZE) {
399 throw new https_1.HttpsError("invalid-argument", `The customClaims payload should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters.`);
400 }
401 }
402 if (eventType === "beforeSignIn" && authRequest.sessionClaims) {
403 const invalidClaims = DISALLOWED_CUSTOM_CLAIMS.filter((claim) => authRequest.sessionClaims.hasOwnProperty(claim));
404 if (invalidClaims.length > 0) {
405 throw new https_1.HttpsError("invalid-argument", `The sessionClaims claims "${invalidClaims.join(",")}" are reserved and cannot be specified.`);
406 }
407 if (JSON.stringify(authRequest.sessionClaims).length >
408 CLAIMS_MAX_PAYLOAD_SIZE) {
409 throw new https_1.HttpsError("invalid-argument", `The sessionClaims payload should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters.`);
410 }
411 const combinedClaims = {
412 ...authRequest.customClaims,
413 ...authRequest.sessionClaims,
414 };
415 if (JSON.stringify(combinedClaims).length > CLAIMS_MAX_PAYLOAD_SIZE) {
416 throw new https_1.HttpsError("invalid-argument", `The customClaims and sessionClaims payloads should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters combined.`);
417 }
418 }
419}
420exports.validateAuthResponse = validateAuthResponse;
421/**
422 * Helper function to generate the update mask for the identity platform changed values
423 * @internal
424 */
425function getUpdateMask(authResponse) {
426 if (!authResponse) {
427 return "";
428 }
429 const updateMask = [];
430 for (const key in authResponse) {
431 if (authResponse.hasOwnProperty(key) && typeof authResponse[key] !== "undefined") {
432 updateMask.push(key);
433 }
434 }
435 return updateMask.join(",");
436}
437exports.getUpdateMask = getUpdateMask;
438/** @internal */
439function wrapHandler(eventType, handler) {
440 return async (req, res) => {
441 try {
442 const projectId = process.env.GCLOUD_PROJECT;
443 if (!isValidRequest(req)) {
444 logger.error("Invalid request, unable to process");
445 throw new https_1.HttpsError("invalid-argument", "Bad Request");
446 }
447 if (!auth.getAuth((0, app_1.getApp)())._verifyAuthBlockingToken) {
448 throw new Error("Cannot validate Auth Blocking token. Please update Firebase Admin SDK to >= v10.1.0");
449 }
450 const decodedPayload = (0, debug_1.isDebugFeatureEnabled)("skipTokenVerification")
451 ? unsafeDecodeAuthBlockingToken(req.body.data.jwt)
452 : handler.platform === "gcfv1"
453 ? await auth.getAuth((0, app_1.getApp)())._verifyAuthBlockingToken(req.body.data.jwt)
454 : await auth.getAuth((0, app_1.getApp)())._verifyAuthBlockingToken(req.body.data.jwt, "run.app");
455 let authUserRecord;
456 if (decodedPayload.event_type === "beforeCreate" ||
457 decodedPayload.event_type === "beforeSignIn") {
458 authUserRecord = parseAuthUserRecord(decodedPayload.user_record);
459 }
460 const authEventContext = parseAuthEventContext(decodedPayload, projectId);
461 let authResponse;
462 if (handler.platform === "gcfv1") {
463 authResponse = authUserRecord
464 ? (await handler(authUserRecord, authEventContext)) || undefined
465 : (await handler(authEventContext)) || undefined;
466 }
467 else {
468 authResponse =
469 (await handler({
470 ...authEventContext,
471 data: authUserRecord,
472 })) || undefined;
473 }
474 validateAuthResponse(eventType, authResponse);
475 const result = generateResponsePayload(authResponse);
476 res.status(200);
477 res.setHeader("Content-Type", "application/json");
478 res.send(JSON.stringify(result));
479 }
480 catch (err) {
481 let httpErr = err;
482 if (!(httpErr instanceof https_1.HttpsError)) {
483 // This doesn't count as an 'explicit' error.
484 logger.error("Unhandled error", err);
485 httpErr = new https_1.HttpsError("internal", "An unexpected error occurred.");
486 }
487 const { status } = httpErr.httpErrorCode;
488 const body = { error: httpErr.toJSON() };
489 res.setHeader("Content-Type", "application/json");
490 res.status(status).send(body);
491 }
492 };
493}
494exports.wrapHandler = wrapHandler;