438 lines
14 KiB
JavaScript
438 lines
14 KiB
JavaScript
import { debug, error, warn } from "../../logger/index.mjs";
|
|
import { getApp } from "../app.mjs";
|
|
import { isDebugFeatureEnabled } from "../debug.mjs";
|
|
import { HttpsError, unsafeDecodeToken } from "./https.mjs";
|
|
import * as auth from "firebase-admin/auth";
|
|
|
|
//#region src/common/providers/identity.ts
|
|
const DISALLOWED_CUSTOM_CLAIMS = [
|
|
"acr",
|
|
"amr",
|
|
"at_hash",
|
|
"aud",
|
|
"auth_time",
|
|
"azp",
|
|
"cnf",
|
|
"c_hash",
|
|
"exp",
|
|
"iat",
|
|
"iss",
|
|
"jti",
|
|
"nbf",
|
|
"nonce",
|
|
"firebase"
|
|
];
|
|
const CLAIMS_MAX_PAYLOAD_SIZE = 1e3;
|
|
const EVENT_MAPPING = {
|
|
beforeCreate: "providers/cloud.auth/eventTypes/user.beforeCreate",
|
|
beforeSignIn: "providers/cloud.auth/eventTypes/user.beforeSignIn",
|
|
beforeSendEmail: "providers/cloud.auth/eventTypes/user.beforeSendEmail",
|
|
beforeSendSms: "providers/cloud.auth/eventTypes/user.beforeSendSms"
|
|
};
|
|
/**
|
|
* Helper class to create the user metadata in a `UserRecord` object.
|
|
*/
|
|
var UserRecordMetadata = class {
|
|
constructor(creationTime, lastSignInTime) {
|
|
this.creationTime = creationTime;
|
|
this.lastSignInTime = lastSignInTime;
|
|
}
|
|
/** Returns a plain JavaScript object with the properties of UserRecordMetadata. */
|
|
toJSON() {
|
|
return {
|
|
creationTime: this.creationTime,
|
|
lastSignInTime: this.lastSignInTime
|
|
};
|
|
}
|
|
};
|
|
/**
|
|
* Helper function that creates a `UserRecord` class from data sent over the wire.
|
|
* @param wireData data sent over the wire
|
|
* @returns an instance of `UserRecord` with correct toJSON functions
|
|
*/
|
|
function userRecordConstructor(wireData) {
|
|
const falseyValues = {
|
|
email: null,
|
|
emailVerified: false,
|
|
displayName: null,
|
|
photoURL: null,
|
|
phoneNumber: null,
|
|
disabled: false,
|
|
providerData: [],
|
|
customClaims: {},
|
|
passwordSalt: null,
|
|
passwordHash: null,
|
|
tokensValidAfterTime: null
|
|
};
|
|
const record = {
|
|
...falseyValues,
|
|
...wireData
|
|
};
|
|
const meta = record.metadata;
|
|
if (meta) {
|
|
record.metadata = new UserRecordMetadata(meta.createdAt || meta.creationTime, meta.lastSignedInAt || meta.lastSignInTime);
|
|
} else {
|
|
record.metadata = new UserRecordMetadata(null, null);
|
|
}
|
|
record.toJSON = () => {
|
|
const { uid, email, emailVerified, displayName, photoURL, phoneNumber, disabled, passwordHash, passwordSalt, tokensValidAfterTime } = record;
|
|
const json = {
|
|
uid,
|
|
email,
|
|
emailVerified,
|
|
displayName,
|
|
photoURL,
|
|
phoneNumber,
|
|
disabled,
|
|
passwordHash,
|
|
passwordSalt,
|
|
tokensValidAfterTime
|
|
};
|
|
json.metadata = record.metadata.toJSON();
|
|
json.customClaims = JSON.parse(JSON.stringify(record.customClaims));
|
|
json.providerData = record.providerData.map((entry) => {
|
|
const newEntry = { ...entry };
|
|
newEntry.toJSON = () => entry;
|
|
return newEntry;
|
|
});
|
|
return json;
|
|
};
|
|
return record;
|
|
}
|
|
/**
|
|
* Checks for a valid identity platform web request, otherwise throws an HttpsError.
|
|
* @internal
|
|
*/
|
|
function isValidRequest(req) {
|
|
if (req.method !== "POST") {
|
|
warn(`Request has invalid method "${req.method}".`);
|
|
return false;
|
|
}
|
|
const contentType = (req.header("Content-Type") || "").toLowerCase();
|
|
if (!contentType.includes("application/json")) {
|
|
warn("Request has invalid header Content-Type.");
|
|
return false;
|
|
}
|
|
if (!req.body?.data?.jwt) {
|
|
warn("Request has an invalid body.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
/**
|
|
* Decode, but not verify, an Auth Blocking token.
|
|
*
|
|
* Do not use in production. Token should always be verified using the Admin SDK.
|
|
*
|
|
* This is exposed only for testing.
|
|
*/
|
|
function unsafeDecodeAuthBlockingToken(token) {
|
|
const decoded = unsafeDecodeToken(token);
|
|
decoded.uid = decoded.sub;
|
|
return decoded;
|
|
}
|
|
/**
|
|
* Helper function to parse the decoded metadata object into a `UserMetaData` object
|
|
* @internal
|
|
*/
|
|
function parseMetadata(metadata) {
|
|
const creationTime = metadata?.creation_time ? new Date(metadata.creation_time).toUTCString() : null;
|
|
const lastSignInTime = metadata?.last_sign_in_time ? new Date(metadata.last_sign_in_time).toUTCString() : null;
|
|
return {
|
|
creationTime,
|
|
lastSignInTime
|
|
};
|
|
}
|
|
/**
|
|
* Helper function to parse the decoded user info array into an `AuthUserInfo` array.
|
|
* @internal
|
|
*/
|
|
function parseProviderData(providerData) {
|
|
const providers = [];
|
|
for (const provider of providerData) {
|
|
providers.push({
|
|
uid: provider.uid,
|
|
displayName: provider.display_name,
|
|
email: provider.email,
|
|
photoURL: provider.photo_url,
|
|
providerId: provider.provider_id,
|
|
phoneNumber: provider.phone_number
|
|
});
|
|
}
|
|
return providers;
|
|
}
|
|
/**
|
|
* Helper function to parse the date into a UTC string.
|
|
* @internal
|
|
*/
|
|
function parseDate(tokensValidAfterTime) {
|
|
if (!tokensValidAfterTime) {
|
|
return null;
|
|
}
|
|
tokensValidAfterTime = tokensValidAfterTime * 1e3;
|
|
try {
|
|
const date = new Date(tokensValidAfterTime);
|
|
if (!isNaN(date.getTime())) {
|
|
return date.toUTCString();
|
|
}
|
|
} catch {}
|
|
return null;
|
|
}
|
|
/**
|
|
* Helper function to parse the decoded enrolled factors into a valid MultiFactorSettings
|
|
* @internal
|
|
*/
|
|
function parseMultiFactor(multiFactor) {
|
|
if (!multiFactor) {
|
|
return null;
|
|
}
|
|
const parsedEnrolledFactors = [];
|
|
for (const factor of multiFactor.enrolled_factors || []) {
|
|
if (!factor.uid) {
|
|
throw new HttpsError("internal", "INTERNAL ASSERT FAILED: Invalid multi-factor info response");
|
|
}
|
|
const enrollmentTime = factor.enrollment_time ? new Date(factor.enrollment_time).toUTCString() : null;
|
|
parsedEnrolledFactors.push({
|
|
uid: factor.uid,
|
|
factorId: factor.phone_number ? factor.factor_id || "phone" : factor.factor_id,
|
|
displayName: factor.display_name,
|
|
enrollmentTime,
|
|
phoneNumber: factor.phone_number
|
|
});
|
|
}
|
|
if (parsedEnrolledFactors.length > 0) {
|
|
return { enrolledFactors: parsedEnrolledFactors };
|
|
}
|
|
return null;
|
|
}
|
|
/**
|
|
* Parses the decoded user record into a valid UserRecord for use in the handler
|
|
* @internal
|
|
*/
|
|
function parseAuthUserRecord(decodedJWTUserRecord) {
|
|
if (!decodedJWTUserRecord.uid) {
|
|
throw new HttpsError("internal", "INTERNAL ASSERT FAILED: Invalid user response");
|
|
}
|
|
const disabled = decodedJWTUserRecord.disabled || false;
|
|
const metadata = parseMetadata(decodedJWTUserRecord.metadata);
|
|
const providerData = parseProviderData(decodedJWTUserRecord.provider_data);
|
|
const tokensValidAfterTime = parseDate(decodedJWTUserRecord.tokens_valid_after_time);
|
|
const multiFactor = parseMultiFactor(decodedJWTUserRecord.multi_factor);
|
|
return {
|
|
uid: decodedJWTUserRecord.uid,
|
|
email: decodedJWTUserRecord.email,
|
|
emailVerified: decodedJWTUserRecord.email_verified,
|
|
displayName: decodedJWTUserRecord.display_name,
|
|
photoURL: decodedJWTUserRecord.photo_url,
|
|
phoneNumber: decodedJWTUserRecord.phone_number,
|
|
disabled,
|
|
metadata,
|
|
providerData,
|
|
passwordHash: decodedJWTUserRecord.password_hash,
|
|
passwordSalt: decodedJWTUserRecord.password_salt,
|
|
customClaims: decodedJWTUserRecord.custom_claims,
|
|
tenantId: decodedJWTUserRecord.tenant_id,
|
|
tokensValidAfterTime,
|
|
multiFactor
|
|
};
|
|
}
|
|
/** Helper to get the `AdditionalUserInfo` from the decoded JWT */
|
|
function parseAdditionalUserInfo(decodedJWT) {
|
|
let profile;
|
|
let username;
|
|
if (decodedJWT.raw_user_info) {
|
|
try {
|
|
profile = JSON.parse(decodedJWT.raw_user_info);
|
|
} catch (err) {
|
|
debug(`Parse Error: ${err.message}`);
|
|
}
|
|
}
|
|
if (profile) {
|
|
if (decodedJWT.sign_in_method === "github.com") {
|
|
username = profile.login;
|
|
}
|
|
if (decodedJWT.sign_in_method === "twitter.com") {
|
|
username = profile.screen_name;
|
|
}
|
|
}
|
|
return {
|
|
providerId: decodedJWT.sign_in_method === "emailLink" ? "password" : decodedJWT.sign_in_method,
|
|
profile,
|
|
username,
|
|
isNewUser: decodedJWT.event_type === "beforeCreate" ? true : false,
|
|
recaptchaScore: decodedJWT.recaptcha_score,
|
|
email: decodedJWT.email,
|
|
phoneNumber: decodedJWT.phone_number
|
|
};
|
|
}
|
|
/**
|
|
* Helper to generate a response from the blocking function to the Firebase Auth backend.
|
|
* @internal
|
|
*/
|
|
function generateResponsePayload(authResponse) {
|
|
if (!authResponse) {
|
|
return {};
|
|
}
|
|
const { recaptchaActionOverride,...formattedAuthResponse } = authResponse;
|
|
const result = {};
|
|
const updateMask = getUpdateMask(formattedAuthResponse);
|
|
if (updateMask.length !== 0) {
|
|
result.userRecord = {
|
|
...formattedAuthResponse,
|
|
updateMask
|
|
};
|
|
}
|
|
if (recaptchaActionOverride !== undefined) {
|
|
result.recaptchaActionOverride = recaptchaActionOverride;
|
|
}
|
|
return result;
|
|
}
|
|
/** Helper to get the Credential from the decoded JWT */
|
|
function parseAuthCredential(decodedJWT, time) {
|
|
if (!decodedJWT.sign_in_attributes && !decodedJWT.oauth_id_token && !decodedJWT.oauth_access_token && !decodedJWT.oauth_refresh_token) {
|
|
return null;
|
|
}
|
|
return {
|
|
claims: decodedJWT.sign_in_attributes,
|
|
idToken: decodedJWT.oauth_id_token,
|
|
accessToken: decodedJWT.oauth_access_token,
|
|
refreshToken: decodedJWT.oauth_refresh_token,
|
|
expirationTime: decodedJWT.oauth_expires_in ? new Date(time + decodedJWT.oauth_expires_in * 1e3).toUTCString() : undefined,
|
|
secret: decodedJWT.oauth_token_secret,
|
|
providerId: decodedJWT.sign_in_method === "emailLink" ? "password" : decodedJWT.sign_in_method,
|
|
signInMethod: decodedJWT.sign_in_method
|
|
};
|
|
}
|
|
/**
|
|
* Parses the decoded jwt into a valid AuthEventContext for use in the handler
|
|
* @internal
|
|
*/
|
|
function parseAuthEventContext(decodedJWT, projectId, time = new Date().getTime()) {
|
|
const eventType = (EVENT_MAPPING[decodedJWT.event_type] || decodedJWT.event_type) + (decodedJWT.sign_in_method ? `:${decodedJWT.sign_in_method}` : "");
|
|
return {
|
|
locale: decodedJWT.locale,
|
|
ipAddress: decodedJWT.ip_address,
|
|
userAgent: decodedJWT.user_agent,
|
|
eventId: decodedJWT.event_id,
|
|
eventType,
|
|
authType: decodedJWT.user_record ? "USER" : "UNAUTHENTICATED",
|
|
resource: {
|
|
service: "identitytoolkit.googleapis.com",
|
|
name: decodedJWT.tenant_id ? `projects/${projectId}/tenants/${decodedJWT.tenant_id}` : `projects/${projectId}`
|
|
},
|
|
timestamp: new Date(decodedJWT.iat * 1e3).toUTCString(),
|
|
additionalUserInfo: parseAdditionalUserInfo(decodedJWT),
|
|
credential: parseAuthCredential(decodedJWT, time),
|
|
emailType: decodedJWT.email_type,
|
|
smsType: decodedJWT.sms_type,
|
|
params: {}
|
|
};
|
|
}
|
|
/**
|
|
* Checks the handler response for invalid customClaims & sessionClaims objects
|
|
* @internal
|
|
*/
|
|
function validateAuthResponse(eventType, authRequest) {
|
|
if (!authRequest) {
|
|
authRequest = {};
|
|
}
|
|
if (authRequest.customClaims) {
|
|
const invalidClaims = DISALLOWED_CUSTOM_CLAIMS.filter((claim) => authRequest.customClaims.hasOwnProperty(claim));
|
|
if (invalidClaims.length > 0) {
|
|
throw new HttpsError("invalid-argument", `The customClaims claims "${invalidClaims.join(",")}" are reserved and cannot be specified.`);
|
|
}
|
|
if (JSON.stringify(authRequest.customClaims).length > CLAIMS_MAX_PAYLOAD_SIZE) {
|
|
throw new HttpsError("invalid-argument", `The customClaims payload should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters.`);
|
|
}
|
|
}
|
|
if (eventType === "beforeSignIn" && authRequest.sessionClaims) {
|
|
const invalidClaims = DISALLOWED_CUSTOM_CLAIMS.filter((claim) => authRequest.sessionClaims.hasOwnProperty(claim));
|
|
if (invalidClaims.length > 0) {
|
|
throw new HttpsError("invalid-argument", `The sessionClaims claims "${invalidClaims.join(",")}" are reserved and cannot be specified.`);
|
|
}
|
|
if (JSON.stringify(authRequest.sessionClaims).length > CLAIMS_MAX_PAYLOAD_SIZE) {
|
|
throw new HttpsError("invalid-argument", `The sessionClaims payload should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters.`);
|
|
}
|
|
const combinedClaims = {
|
|
...authRequest.customClaims,
|
|
...authRequest.sessionClaims
|
|
};
|
|
if (JSON.stringify(combinedClaims).length > CLAIMS_MAX_PAYLOAD_SIZE) {
|
|
throw new HttpsError("invalid-argument", `The customClaims and sessionClaims payloads should not exceed ${CLAIMS_MAX_PAYLOAD_SIZE} characters combined.`);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Helper function to generate the update mask for the identity platform changed values
|
|
* @internal
|
|
*/
|
|
function getUpdateMask(authResponse) {
|
|
if (!authResponse) {
|
|
return "";
|
|
}
|
|
const updateMask = [];
|
|
for (const key in authResponse) {
|
|
if (authResponse.hasOwnProperty(key) && typeof authResponse[key] !== "undefined") {
|
|
updateMask.push(key);
|
|
}
|
|
}
|
|
return updateMask.join(",");
|
|
}
|
|
/** @internal */
|
|
function wrapHandler(eventType, handler) {
|
|
return async (req, res) => {
|
|
try {
|
|
const projectId = process.env.GCLOUD_PROJECT;
|
|
if (!isValidRequest(req)) {
|
|
error("Invalid request, unable to process");
|
|
throw new HttpsError("invalid-argument", "Bad Request");
|
|
}
|
|
if (!auth.getAuth(getApp())._verifyAuthBlockingToken) {
|
|
throw new Error("Cannot validate Auth Blocking token. Please update Firebase Admin SDK to >= v10.1.0");
|
|
}
|
|
let decodedPayload;
|
|
if (isDebugFeatureEnabled("skipTokenVerification")) {
|
|
decodedPayload = unsafeDecodeAuthBlockingToken(req.body.data.jwt);
|
|
} else {
|
|
const unverified = unsafeDecodeAuthBlockingToken(req.body.data.jwt);
|
|
if (handler.platform === "gcfv2" && unverified.aud && unverified.aud.includes("run.app")) {
|
|
decodedPayload = await auth.getAuth(getApp())._verifyAuthBlockingToken(req.body.data.jwt, "run.app");
|
|
} else {
|
|
decodedPayload = await auth.getAuth(getApp())._verifyAuthBlockingToken(req.body.data.jwt);
|
|
}
|
|
}
|
|
let authUserRecord;
|
|
if (decodedPayload.event_type === "beforeCreate" || decodedPayload.event_type === "beforeSignIn") {
|
|
authUserRecord = parseAuthUserRecord(decodedPayload.user_record);
|
|
}
|
|
const authEventContext = parseAuthEventContext(decodedPayload, projectId);
|
|
let authResponse;
|
|
if (handler.platform === "gcfv1") {
|
|
authResponse = authUserRecord ? await handler(authUserRecord, authEventContext) || undefined : await handler(authEventContext) || undefined;
|
|
} else {
|
|
authResponse = await handler({
|
|
...authEventContext,
|
|
data: authUserRecord
|
|
}) || undefined;
|
|
}
|
|
validateAuthResponse(eventType, authResponse);
|
|
const result = generateResponsePayload(authResponse);
|
|
res.status(200);
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.send(JSON.stringify(result));
|
|
} catch (err) {
|
|
let httpErr = err;
|
|
if (!(httpErr instanceof HttpsError)) {
|
|
error("Unhandled error", err);
|
|
httpErr = new HttpsError("internal", "An unexpected error occurred.");
|
|
}
|
|
const { status } = httpErr.httpErrorCode;
|
|
const body = { error: httpErr.toJSON() };
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.status(status).send(body);
|
|
}
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
export { HttpsError, UserRecordMetadata, generateResponsePayload, getUpdateMask, isValidRequest, parseAuthEventContext, parseAuthUserRecord, parseDate, parseMetadata, parseMultiFactor, parseProviderData, userRecordConstructor, validateAuthResponse, wrapHandler }; |