From 161fc97dfd5d8059bf1082bfe00367ad9071548f Mon Sep 17 00:00:00 2001 From: Kei Kamikawa Date: Sat, 24 Feb 2024 00:31:44 +0900 Subject: [PATCH 1/4] added revoke function --- src/auth-api-requests.ts | 239 ++++++++++++- src/auth.ts | 147 +++++++- src/client.ts | 24 +- src/errors.ts | 194 +++++++++++ src/user-record.ts | 719 +++++++++++++++++++++++++++++++++++++++ src/validator.ts | 10 + tests/auth.test.ts | 229 +++++++++++-- 7 files changed, 1522 insertions(+), 40 deletions(-) create mode 100644 src/user-record.ts diff --git a/src/auth-api-requests.ts b/src/auth-api-requests.ts index 63e9e4a..5d0af45 100644 --- a/src/auth-api-requests.ts +++ b/src/auth-api-requests.ts @@ -2,7 +2,8 @@ import { ApiSettings } from './api-requests'; import { BaseClient } from './client'; import type { EmulatorEnv } from './emulator'; import { AuthClientErrorCode, FirebaseAuthError } from './errors'; -import { isNonEmptyString, isNumber } from './validator'; +import { UserRecord } from './user-record'; +import { isNonEmptyString, isNumber, isObject, isUid } from './validator'; /** Minimum allowed session cookie duration in seconds (5 minutes). */ const MIN_SESSION_COOKIE_DURATION_SECS = 5 * 60; @@ -10,6 +11,29 @@ const MIN_SESSION_COOKIE_DURATION_SECS = 5 * 60; /** Maximum allowed session cookie duration in seconds (2 weeks). */ const MAX_SESSION_COOKIE_DURATION_SECS = 14 * 24 * 60 * 60; +/** List of reserved claims which cannot be provided when creating a custom token. */ +const RESERVED_CLAIMS = [ + 'acr', + 'amr', + 'at_hash', + 'aud', + 'auth_time', + 'azp', + 'cnf', + 'c_hash', + 'exp', + 'iat', + 'iss', + 'jti', + 'nbf', + 'nonce', + 'sub', + 'firebase', +]; + +/** Maximum allowed number of characters in the custom claims payload. */ +const MAX_CLAIMS_PAYLOAD_SIZE = 1000; + /** * Instantiates the createSessionCookie endpoint settings. * @@ -39,6 +63,131 @@ export const FIREBASE_AUTH_CREATE_SESSION_COOKIE = new ApiSettings('v1', ':creat } }); +interface GetAccountInfoRequest { + localId?: string[]; + email?: string[]; + phoneNumber?: string[]; + federatedUserId?: Array<{ + providerId: string; + rawId: string; + }>; +} + +/** + * Instantiates the getAccountInfo endpoint settings. + * + * @internal + */ +export const FIREBASE_AUTH_GET_ACCOUNT_INFO = new ApiSettings('v1', '/accounts:lookup', 'POST') + // Set request validator. + .setRequestValidator((request: GetAccountInfoRequest) => { + if (!request.localId && !request.email && !request.phoneNumber && !request.federatedUserId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing user identifier' + ); + } + }) + // Set response validator. + .setResponseValidator((response: any) => { + if (!response.users || !response.users.length) { + throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + } + }); + +/** + * Instantiates the revokeRefreshTokens endpoint settings for updating existing accounts. + * + * @internal + * @link https://github.com/firebase/firebase-admin-node/blob/9955bca47249301aa970679ae99fe01d54adf6a8/src/auth/auth-api-request.ts#L746 + */ +export const FIREBASE_AUTH_REVOKE_REFRESH_TOKENS = new ApiSettings('v1', '/accounts:update', 'POST') + // Set request validator. + .setRequestValidator((request: any) => { + // localId is a required parameter. + if (typeof request.localId === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing user identifier' + ); + } + // validSince should be a number. + if (typeof request.validSince !== 'undefined' && !isNumber(request.validSince)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TOKENS_VALID_AFTER_TIME); + } + }) + // Set response validator. + .setResponseValidator((response: any) => { + // If the localId is not returned, then the request failed. + if (!response.localId) { + throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + } + }); + +/** + * Instantiates the setCustomUserClaims endpoint settings for updating existing accounts. + * + * @internal + * @link https://github.com/firebase/firebase-admin-node/blob/9955bca47249301aa970679ae99fe01d54adf6a8/src/auth/auth-api-request.ts#L746 + */ +export const FIREBASE_AUTH_SET_CUSTOM_USER_CLAIMS = new ApiSettings('v1', '/accounts:update', 'POST') + // Set request validator. + .setRequestValidator((request: any) => { + // localId is a required parameter. + if (typeof request.localId === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing user identifier' + ); + } + // customAttributes should be stringified JSON with no blacklisted claims. + // The payload should not exceed 1KB. + if (typeof request.customAttributes !== 'undefined') { + let developerClaims: object; + try { + developerClaims = JSON.parse(request.customAttributes); + } catch (error) { + if (error instanceof Error) { + // JSON parsing error. This should never happen as we stringify the claims internally. + // However, we still need to check since setAccountInfo via edit requests could pass + // this field. + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_CLAIMS, error.message); + } + throw error; + } + const invalidClaims: string[] = []; + // Check for any invalid claims. + RESERVED_CLAIMS.forEach(blacklistedClaim => { + if (Object.prototype.hasOwnProperty.call(developerClaims, blacklistedClaim)) { + invalidClaims.push(blacklistedClaim); + } + }); + // Throw an error if an invalid claim is detected. + if (invalidClaims.length > 0) { + throw new FirebaseAuthError( + AuthClientErrorCode.FORBIDDEN_CLAIM, + invalidClaims.length > 1 + ? `Developer claims "${invalidClaims.join('", "')}" are reserved and cannot be specified.` + : `Developer claim "${invalidClaims[0]}" is reserved and cannot be specified.` + ); + } + // Check claims payload does not exceed maxmimum size. + if (request.customAttributes.length > MAX_CLAIMS_PAYLOAD_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.CLAIMS_TOO_LARGE, + `Developer claims payload should not exceed ${MAX_CLAIMS_PAYLOAD_SIZE} characters.` + ); + } + } + }) + // Set response validator. + .setResponseValidator((response: any) => { + // If the localId is not returned, then the request failed. + if (!response.localId) { + throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + } + }); + export class AuthApiClient extends BaseClient { /** * Creates a new Firebase session cookie with the specified duration that can be used for @@ -47,7 +196,7 @@ export class AuthApiClient extends BaseClient { * * @param idToken - The Firebase ID token to exchange for a session cookie. * @param expiresIn - The session cookie duration in milliseconds. - * @param - An optional parameter specifying the environment in which the function is running. + * @param env - An optional parameter specifying the environment in which the function is running. * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. * If not specified, the function will assume it is running in a production environment. * @@ -62,4 +211,90 @@ export class AuthApiClient extends BaseClient { const res = await this.fetch<{ sessionCookie: string }>(FIREBASE_AUTH_CREATE_SESSION_COOKIE, request, env); return res.sessionCookie; } + + /** + * Looks up a user by uid. + * + * @param uid - The uid of the user to lookup. + * @param env - An optional parameter specifying the environment in which the function is running. + * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. + * If not specified, the function will assume it is running in a production environment. + * @returns A promise that resolves with the user information. + */ + public async getAccountInfoByUid(uid: string, env?: EmulatorEnv): Promise { + if (!isUid(uid)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); + } + + const request = { + localId: [uid], + }; + const res = await this.fetch(FIREBASE_AUTH_GET_ACCOUNT_INFO, request, env); + // Returns the user record populated with server response. + return new UserRecord((res as any).users[0]); + } + + /** + * Revokes all refresh tokens for the specified user identified by the uid provided. + * In addition to revoking all refresh tokens for a user, all ID tokens issued + * before revocation will also be revoked on the Auth backend. Any request with an + * ID token generated before revocation will be rejected with a token expired error. + * Note that due to the fact that the timestamp is stored in seconds, any tokens minted in + * the same second as the revocation will still be valid. If there is a chance that a token + * was minted in the last second, delay for 1 second before revoking. + * + * @param uid - The user whose tokens are to be revoked. + * @param env - An optional parameter specifying the environment in which the function is running. + * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. + * If not specified, the function will assume it is running in a production environment. + * @returns A promise that resolves when the operation completes + * successfully with the user id of the corresponding user. + */ + public async revokeRefreshTokens(uid: string, env?: EmulatorEnv): Promise { + // Validate user UID. + if (!isUid(uid)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); + } + const request: any = { + localId: uid, + // validSince is in UTC seconds. + validSince: Math.floor(new Date().getTime() / 1000), + }; + const res = await this.fetch<{ localId: string }>(FIREBASE_AUTH_REVOKE_REFRESH_TOKENS, request, env); + return res.localId; + } + + /** + * Sets additional developer claims on an existing user identified by provided UID. + * + * @param uid - The user to edit. + * @param customUserClaims - The developer claims to set. + * @param env - An optional parameter specifying the environment in which the function is running. + * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. + * If not specified, the function will assume it is running in a production environment. + * @returns A promise that resolves when the operation completes + * with the user id that was edited. + */ + public async setCustomUserClaims(uid: string, customUserClaims: object | null, env?: EmulatorEnv): Promise { + // Validate user UID. + if (!isUid(uid)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); + } else if (!isObject(customUserClaims)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'CustomUserClaims argument must be an object or null.' + ); + } + // Delete operation. Replace null with an empty object. + if (customUserClaims === null) { + customUserClaims = {}; + } + // Construct custom user attribute editting request. + const request: any = { + localId: uid, + customAttributes: JSON.stringify(customUserClaims), + }; + const res = await this.fetch<{ localId: string }>(FIREBASE_AUTH_SET_CUSTOM_USER_CLAIMS, request, env); + return res.localId; + } } diff --git a/src/auth.ts b/src/auth.ts index 9533531..e9f8441 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,10 +2,12 @@ import { AuthApiClient } from './auth-api-requests'; import type { Credential } from './credential'; import type { EmulatorEnv } from './emulator'; import { useEmulator } from './emulator'; +import type { ErrorInfo } from './errors'; import { AppErrorCodes, AuthClientErrorCode, FirebaseAppError, FirebaseAuthError } from './errors'; import type { KeyStorer } from './key-store'; import type { FirebaseIdToken, FirebaseTokenVerifier } from './token-verifier'; import { createIdTokenVerifier, createSessionCookieVerifier } from './token-verifier'; +import type { UserRecord } from './user-record'; import { isNonNullObject, isNumber } from './validator'; export class BaseAuth { @@ -35,16 +37,36 @@ export class BaseAuth { * fulfilled with the token's decoded claims; otherwise, the promise is * rejected. * + * If `checkRevoked` is set to true, first verifies whether the corresponding + * user is disabled. If yes, an `auth/user-disabled` error is thrown. If no, + * verifies if the session corresponding to the ID token was revoked. If the + * corresponding user's session was invalidated, an `auth/id-token-revoked` + * error is thrown. If not specified the check is not applied. + * * See {@link https://firebase.google.com/docs/auth/admin/verify-id-tokens | Verify ID Tokens} * for code samples and detailed documentation. * + * @param idToken - The ID token to verify. + * @param checkRevoked - Whether to check if the ID token was revoked. + * This requires an extra request to the Firebase Auth backend to check + * the `tokensValidAfterTime` time for the corresponding user. + * When not specified, this additional check is not applied. + * @param env - An optional parameter specifying the environment in which the function is running. + * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. + * If not specified, the function will assume it is running in a production environment. + * * @returns A promise fulfilled with the * token's decoded claims if the ID token is valid; otherwise, a rejected * promise. */ - public verifyIdToken(idToken: string, env?: EmulatorEnv): Promise { + public async verifyIdToken(idToken: string, checkRevoked = false, env?: EmulatorEnv): Promise { const isEmulator = useEmulator(env); - return this.idTokenVerifier.verifyJWT(idToken, isEmulator); + const decodedIdToken = await this.idTokenVerifier.verifyJWT(idToken, isEmulator); + // Whether to check if the token was revoked. + if (checkRevoked || isEmulator) { + return await this.verifyDecodedJWTNotRevokedOrDisabled(decodedIdToken, AuthClientErrorCode.ID_TOKEN_REVOKED, env); + } + return decodedIdToken; } /** @@ -95,6 +117,10 @@ export class BaseAuth { * for code samples and detailed documentation * * @param sessionCookie - The session cookie to verify. + * @param checkRevoked - Whether to check if the session cookie was + * revoked. This requires an extra request to the Firebase Auth backend to + * check the `tokensValidAfterTime` time for the corresponding user. + * When not specified, this additional check is not performed. * @param env - An optional parameter specifying the environment in which the function is running. * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. * If not specified, the function will assume it is running in a production environment. @@ -103,9 +129,122 @@ export class BaseAuth { * session cookie's decoded claims if the session cookie is valid; otherwise, * a rejected promise. */ - public verifySessionCookie(sessionCookie: string, env?: EmulatorEnv): Promise { + public async verifySessionCookie( + sessionCookie: string, + checkRevoked = false, + env?: EmulatorEnv + ): Promise { const isEmulator = useEmulator(env); - return this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator); + const decodedIdToken = await this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator); + // Whether to check if the token was revoked. + if (checkRevoked || isEmulator) { + return await this.verifyDecodedJWTNotRevokedOrDisabled( + decodedIdToken, + AuthClientErrorCode.SESSION_COOKIE_REVOKED, + env + ); + } + return decodedIdToken; + } + + /** + * Gets the user data for the user corresponding to a given `uid`. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data | Retrieve user data} + * for code samples and detailed documentation. + * + * @param uid - The `uid` corresponding to the user whose data to fetch. + * + * @returns A promise fulfilled with the user + * data corresponding to the provided `uid`. + */ + public async getUser(uid: string, env?: EmulatorEnv): Promise { + return await this.authApiClient.getAccountInfoByUid(uid, env); + } + + /** + * Verifies the decoded Firebase issued JWT is not revoked or disabled. Returns a promise that + * resolves with the decoded claims on success. Rejects the promise with revocation error if revoked + * or user disabled. + * + * @param decodedIdToken - The JWT's decoded claims. + * @param revocationErrorInfo - The revocation error info to throw on revocation + * detection. + * @returns A promise that will be fulfilled after a successful verification. + */ + private async verifyDecodedJWTNotRevokedOrDisabled( + decodedIdToken: FirebaseIdToken, + revocationErrorInfo: ErrorInfo, + env?: EmulatorEnv + ): Promise { + // Get tokens valid after time for the corresponding user. + const user = await this.getUser(decodedIdToken.sub, env); + if (user.disabled) { + throw new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED, 'The user record is disabled.'); + } + // If no tokens valid after time available, token is not revoked. + if (user.tokensValidAfterTime) { + // Get the ID token authentication time and convert to milliseconds UTC. + const authTimeUtc = decodedIdToken.auth_time * 1000; + // Get user tokens valid after time in milliseconds UTC. + const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); + // Check if authentication time is older than valid since time. + if (authTimeUtc < validSinceUtc) { + throw new FirebaseAuthError(revocationErrorInfo); + } + } + // All checks above passed. Return the decoded token. + return decodedIdToken; + } + + /** + * Revokes all refresh tokens for an existing user. + * + * This API will update the user's {@link UserRecord.tokensValidAfterTime} to + * the current UTC. It is important that the server on which this is called has + * its clock set correctly and synchronized. + * + * While this will revoke all sessions for a specified user and disable any + * new ID tokens for existing sessions from getting minted, existing ID tokens + * may remain active until their natural expiration (one hour). To verify that + * ID tokens are revoked, use {@link BaseAuth.verifyIdToken} + * where `checkRevoked` is set to true. + * + * @param uid - The `uid` corresponding to the user whose refresh tokens + * are to be revoked. + * + * @returns An empty promise fulfilled once the user's refresh + * tokens have been revoked. + */ + public async revokeRefreshTokens(uid: string, env?: EmulatorEnv): Promise { + await this.authApiClient.revokeRefreshTokens(uid, env); + } + + /** + * Sets additional developer claims on an existing user identified by the + * provided `uid`, typically used to define user roles and levels of + * access. These claims should propagate to all devices where the user is + * already signed in (after token expiration or when token refresh is forced) + * and the next time the user signs in. If a reserved OIDC claim name + * is used (sub, iat, iss, etc), an error is thrown. They are set on the + * authenticated user's ID token JWT. + * + * See {@link https://firebase.google.com/docs/auth/admin/custom-claims | + * Defining user roles and access levels} + * for code samples and detailed documentation. + * + * @param uid - The `uid` of the user to edit. + * @param customUserClaims - The developer claims to set. If null is + * passed, existing custom claims are deleted. Passing a custom claims payload + * larger than 1000 bytes will throw an error. Custom claims are added to the + * user's ID token which is transmitted on every authenticated request. + * For profile non-access related user attributes, use database or other + * separate storage systems. + * @returns A promise that resolves when the operation completes + * successfully. + */ + public async setCustomUserClaims(uid: string, customUserClaims: object | null, env?: EmulatorEnv): Promise { + await this.authApiClient.setCustomUserClaims(uid, customUserClaims, env); } } diff --git a/src/client.ts b/src/client.ts index 4f2a9a2..5bcf19d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,8 @@ import type { ApiSettings } from './api-requests'; import type { Credential } from './credential'; import { useEmulator, type EmulatorEnv } from './emulator'; -import { AppErrorCodes, FirebaseAppError } from './errors'; +import { AppErrorCodes, FirebaseAppError, FirebaseAuthError } from './errors'; +import { isNonNullObject } from './validator'; import { version } from './version'; /** @@ -120,6 +121,19 @@ export class BaseClient { if (err.cause) { throw err.cause; } + + try { + const json = JSON.parse(err.message); + const errorCode = this.getErrorCode(json); + if (errorCode) { + throw FirebaseAuthError.fromServerError(errorCode, json); + } + } catch (err) { + if (err instanceof FirebaseAuthError) { + throw err; + } + } + throw new FirebaseAppError( AppErrorCodes.INTERNAL_ERROR, `Error while sending request or reading response: "${err}". Raw server ` + @@ -131,6 +145,14 @@ export class BaseClient { } } + /** + * @param response - The response to check for errors. + * @returns The error code if present; null otherwise. + */ + private getErrorCode(response: any): string | null { + return (isNonNullObject(response) && response.error && response.error.message) || null; + } + private waitForRetry(delayMillis: number): Promise { if (delayMillis > 0) { return new Promise(resolve => { diff --git a/src/errors.ts b/src/errors.ts index dc2a412..080685d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -79,10 +79,34 @@ export class AuthClientErrorCode { code: 'session-cookie-expired', message: 'The Firebase session cookie is expired.', }; + public static SESSION_COOKIE_REVOKED = { + code: 'session-cookie-revoked', + message: 'The Firebase session cookie has been revoked.', + }; public static INVALID_SESSION_COOKIE_DURATION = { code: 'invalid-session-cookie-duration', message: 'The session cookie duration must be a valid number in milliseconds ' + 'between 5 minutes and 2 weeks.', }; + public static INVALID_UID = { + code: 'invalid-uid', + message: 'The uid must be a non-empty string with at most 128 characters.', + }; + public static INVALID_TOKENS_VALID_AFTER_TIME = { + code: 'invalid-tokens-valid-after-time', + message: 'The tokensValidAfterTime must be a valid UTC number in seconds.', + }; + public static FORBIDDEN_CLAIM = { + code: 'reserved-claim', + message: 'The specified developer claim is reserved and cannot be specified.', + }; + public static INVALID_CLAIMS = { + code: 'invalid-claims', + message: 'The provided custom claim attributes are invalid.', + }; + public static CLAIMS_TOO_LARGE = { + code: 'claims-too-large', + message: 'Developer claims maximum payload size exceeded.', + }; } /** @@ -227,6 +251,39 @@ export class FirebaseAuthError extends PrefixedFirebaseError { /* tslint:enable:max-line-length */ (this as any).__proto__ = FirebaseAuthError.prototype; } + + /** + * Creates the developer-facing error corresponding to the backend error code. + * + * @param serverErrorCode - The server error code. + * @param [message] The error message. The default message is used + * if not provided. + * @param [rawServerResponse] The error's raw server response. + * @returns The corresponding developer-facing error. + */ + public static fromServerError(serverErrorCode: string, rawServerResponse?: object): FirebaseAuthError { + // serverErrorCode could contain additional details: + // ERROR_CODE : Detailed message which can also contain colons + const colonSeparator = (serverErrorCode || '').indexOf(':'); + if (colonSeparator !== -1) { + serverErrorCode = serverErrorCode.substring(0, colonSeparator).trim(); + } + // If not found, default to internal error. + const clientCodeKey = AUTH_SERVER_TO_CLIENT_CODE[serverErrorCode] || 'INTERNAL_ERROR'; + const error: ErrorInfo = { + ...AuthClientErrorCode.INTERNAL_ERROR, + ...(AuthClientErrorCode as any)[clientCodeKey], + }; + + if (clientCodeKey === 'INTERNAL_ERROR' && typeof rawServerResponse !== 'undefined') { + try { + error.message += ` Raw server response: "${JSON.stringify(rawServerResponse)}"`; + } catch (e) { + // Ignore JSON parsing error. + } + } + return new FirebaseAuthError(error); + } } /** @@ -247,3 +304,140 @@ export class FirebaseAppError extends PrefixedFirebaseError { (this as any).__proto__ = FirebaseAppError.prototype; } } + +/** + * Defines a type that stores all server to client codes (string enum). + */ +interface ServerToClientCode { + [code: string]: string; +} + +/** @const {ServerToClientCode} Auth server to client enum error codes. */ +const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { + // Feature being configured or used requires a billing account. + BILLING_NOT_ENABLED: 'BILLING_NOT_ENABLED', + // Claims payload is too large. + CLAIMS_TOO_LARGE: 'CLAIMS_TOO_LARGE', + // Configuration being added already exists. + CONFIGURATION_EXISTS: 'CONFIGURATION_EXISTS', + // Configuration not found. + CONFIGURATION_NOT_FOUND: 'CONFIGURATION_NOT_FOUND', + // Provided credential has insufficient permissions. + INSUFFICIENT_PERMISSION: 'INSUFFICIENT_PERMISSION', + // Provided configuration has invalid fields. + INVALID_CONFIG: 'INVALID_CONFIG', + // Provided configuration identifier is invalid. + INVALID_CONFIG_ID: 'INVALID_PROVIDER_ID', + // ActionCodeSettings missing continue URL. + INVALID_CONTINUE_URI: 'INVALID_CONTINUE_URI', + // Dynamic link domain in provided ActionCodeSettings is not authorized. + INVALID_DYNAMIC_LINK_DOMAIN: 'INVALID_DYNAMIC_LINK_DOMAIN', + // uploadAccount provides an email that already exists. + DUPLICATE_EMAIL: 'EMAIL_ALREADY_EXISTS', + // uploadAccount provides a localId that already exists. + DUPLICATE_LOCAL_ID: 'UID_ALREADY_EXISTS', + // Request specified a multi-factor enrollment ID that already exists. + DUPLICATE_MFA_ENROLLMENT_ID: 'SECOND_FACTOR_UID_ALREADY_EXISTS', + // setAccountInfo email already exists. + EMAIL_EXISTS: 'EMAIL_ALREADY_EXISTS', + // /accounts:sendOobCode for password reset when user is not found. + EMAIL_NOT_FOUND: 'EMAIL_NOT_FOUND', + // Reserved claim name. + FORBIDDEN_CLAIM: 'FORBIDDEN_CLAIM', + // Invalid claims provided. + INVALID_CLAIMS: 'INVALID_CLAIMS', + // Invalid session cookie duration. + INVALID_DURATION: 'INVALID_SESSION_COOKIE_DURATION', + // Invalid email provided. + INVALID_EMAIL: 'INVALID_EMAIL', + // Invalid new email provided. + INVALID_NEW_EMAIL: 'INVALID_NEW_EMAIL', + // Invalid tenant display name. This can be thrown on CreateTenant and UpdateTenant. + INVALID_DISPLAY_NAME: 'INVALID_DISPLAY_NAME', + // Invalid ID token provided. + INVALID_ID_TOKEN: 'INVALID_ID_TOKEN', + // Invalid tenant/parent resource name. + INVALID_NAME: 'INVALID_NAME', + // OIDC configuration has an invalid OAuth client ID. + INVALID_OAUTH_CLIENT_ID: 'INVALID_OAUTH_CLIENT_ID', + // Invalid page token. + INVALID_PAGE_SELECTION: 'INVALID_PAGE_TOKEN', + // Invalid phone number. + INVALID_PHONE_NUMBER: 'INVALID_PHONE_NUMBER', + // Invalid agent project. Either agent project doesn't exist or didn't enable multi-tenancy. + INVALID_PROJECT_ID: 'INVALID_PROJECT_ID', + // Invalid provider ID. + INVALID_PROVIDER_ID: 'INVALID_PROVIDER_ID', + // Invalid service account. + INVALID_SERVICE_ACCOUNT: 'INVALID_SERVICE_ACCOUNT', + // Invalid testing phone number. + INVALID_TESTING_PHONE_NUMBER: 'INVALID_TESTING_PHONE_NUMBER', + // Invalid tenant type. + INVALID_TENANT_TYPE: 'INVALID_TENANT_TYPE', + // Missing Android package name. + MISSING_ANDROID_PACKAGE_NAME: 'MISSING_ANDROID_PACKAGE_NAME', + // Missing configuration. + MISSING_CONFIG: 'MISSING_CONFIG', + // Missing configuration identifier. + MISSING_CONFIG_ID: 'MISSING_PROVIDER_ID', + // Missing tenant display name: This can be thrown on CreateTenant and UpdateTenant. + MISSING_DISPLAY_NAME: 'MISSING_DISPLAY_NAME', + // Email is required for the specified action. For example a multi-factor user requires + // a verified email. + MISSING_EMAIL: 'MISSING_EMAIL', + // Missing iOS bundle ID. + MISSING_IOS_BUNDLE_ID: 'MISSING_IOS_BUNDLE_ID', + // Missing OIDC issuer. + MISSING_ISSUER: 'MISSING_ISSUER', + // No localId provided (deleteAccount missing localId). + MISSING_LOCAL_ID: 'MISSING_UID', + // OIDC configuration is missing an OAuth client ID. + MISSING_OAUTH_CLIENT_ID: 'MISSING_OAUTH_CLIENT_ID', + // Missing provider ID. + MISSING_PROVIDER_ID: 'MISSING_PROVIDER_ID', + // Missing SAML RP config. + MISSING_SAML_RELYING_PARTY_CONFIG: 'MISSING_SAML_RELYING_PARTY_CONFIG', + // Empty user list in uploadAccount. + MISSING_USER_ACCOUNT: 'MISSING_UID', + // Password auth disabled in console. + OPERATION_NOT_ALLOWED: 'OPERATION_NOT_ALLOWED', + // Provided credential has insufficient permissions. + PERMISSION_DENIED: 'INSUFFICIENT_PERMISSION', + // Phone number already exists. + PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_ALREADY_EXISTS', + // Project not found. + PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', + // In multi-tenancy context: project creation quota exceeded. + QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', + // Currently only 5 second factors can be set on the same user. + SECOND_FACTOR_LIMIT_EXCEEDED: 'SECOND_FACTOR_LIMIT_EXCEEDED', + // Tenant not found. + TENANT_NOT_FOUND: 'TENANT_NOT_FOUND', + // Tenant ID mismatch. + TENANT_ID_MISMATCH: 'MISMATCHING_TENANT_ID', + // Token expired error. + TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED', + // Continue URL provided in ActionCodeSettings has a domain that is not whitelisted. + UNAUTHORIZED_DOMAIN: 'UNAUTHORIZED_DOMAIN', + // A multi-factor user requires a supported first factor. + UNSUPPORTED_FIRST_FACTOR: 'UNSUPPORTED_FIRST_FACTOR', + // The request specified an unsupported type of second factor. + UNSUPPORTED_SECOND_FACTOR: 'UNSUPPORTED_SECOND_FACTOR', + // Operation is not supported in a multi-tenant context. + UNSUPPORTED_TENANT_OPERATION: 'UNSUPPORTED_TENANT_OPERATION', + // A verified email is required for the specified action. For example a multi-factor user + // requires a verified email. + UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL', + // User on which action is to be performed is not found. + USER_NOT_FOUND: 'USER_NOT_FOUND', + // User record is disabled. + USER_DISABLED: 'USER_DISABLED', + // Password provided is too weak. + WEAK_PASSWORD: 'INVALID_PASSWORD', + // Unrecognized reCAPTCHA action. + INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION', + // Unrecognized reCAPTCHA enforcement state. + INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE', + // reCAPTCHA is not enabled for account defender. + RECAPTCHA_NOT_ENABLED: 'RECAPTCHA_NOT_ENABLED', +}; diff --git a/src/user-record.ts b/src/user-record.ts new file mode 100644 index 0000000..1383978 --- /dev/null +++ b/src/user-record.ts @@ -0,0 +1,719 @@ +import { AuthClientErrorCode, FirebaseAuthError } from './errors'; +import { isNonNullObject } from './validator'; + +/** + * 'REDACTED', encoded as a base64 string. + */ +const B64_REDACTED = 'UkVEQUNURUQ='; // Buffer.from('REDACTED').toString('base64'); + +/** + * Parses a time stamp string or number and returns the corresponding date if valid. + * + * @param time - The unix timestamp string or number in milliseconds. + * @returns The corresponding date as a UTC string, if valid. Otherwise, null. + */ +function parseDate(time: any): string | null { + try { + const date = new Date(parseInt(time, 10)); + if (!isNaN(date.getTime())) { + return date.toUTCString(); + } + } catch (e) { + // Do nothing. null will be returned. + } + return null; +} + +export interface MultiFactorInfoResponse { + mfaEnrollmentId: string; + displayName?: string; + phoneInfo?: string; + totpInfo?: TotpInfoResponse; + enrolledAt?: string; + [key: string]: unknown; +} + +export interface TotpInfoResponse { + [key: string]: unknown; +} + +export interface ProviderUserInfoResponse { + rawId: string; + displayName?: string; + email?: string; + photoUrl?: string; + phoneNumber?: string; + providerId: string; + federatedId?: string; +} + +export interface GetAccountInfoUserResponse { + localId: string; + email?: string; + emailVerified?: boolean; + phoneNumber?: string; + displayName?: string; + photoUrl?: string; + disabled?: boolean; + passwordHash?: string; + salt?: string; + customAttributes?: string; + validSince?: string; + tenantId?: string; + providerUserInfo?: ProviderUserInfoResponse[]; + mfaInfo?: MultiFactorInfoResponse[]; + createdAt?: string; + lastLoginAt?: string; + lastRefreshAt?: string; + [key: string]: any; +} + +enum MultiFactorId { + Phone = 'phone', + Totp = 'totp', +} + +/** + * Interface representing the common properties of a user-enrolled second factor. + */ +export abstract class MultiFactorInfo { + /** + * The ID of the enrolled second factor. This ID is unique to the user. + */ + public readonly uid: string; + + /** + * The optional display name of the enrolled second factor. + */ + public readonly displayName?: string; + + /** + * The type identifier of the second factor. + * For SMS second factors, this is `phone`. + * For TOTP second factors, this is `totp`. + */ + public readonly factorId: string; + + /** + * The optional date the second factor was enrolled, formatted as a UTC string. + */ + public readonly enrollmentTime?: string; + + /** + * Initializes the MultiFactorInfo associated subclass using the server side. + * If no MultiFactorInfo is associated with the response, null is returned. + * + * @param response - The server side response. + * @internal + */ + public static initMultiFactorInfo(response: MultiFactorInfoResponse): MultiFactorInfo | null { + let multiFactorInfo: MultiFactorInfo | null = null; + // PhoneMultiFactorInfo, TotpMultiFactorInfo currently available. + try { + if (response.phoneInfo !== undefined) { + multiFactorInfo = new PhoneMultiFactorInfo(response); + } else if (response.totpInfo !== undefined) { + multiFactorInfo = new TotpMultiFactorInfo(response); + } else { + // Ignore the other SDK unsupported MFA factors to prevent blocking developers using the current SDK. + } + } catch (e) { + // Ignore error. + } + return multiFactorInfo; + } + + /** + * Initializes the MultiFactorInfo object using the server side response. + * + * @param response - The server side response. + * @constructor + * @internal + */ + constructor(response: MultiFactorInfoResponse) { + this.initFromServerResponse(response); + } + + /** + * Returns a JSON-serializable representation of this object. + * + * @returns A JSON-serializable representation of this object. + */ + public toJSON(): object { + return { + uid: this.uid, + displayName: this.displayName, + factorId: this.factorId, + enrollmentTime: this.enrollmentTime, + }; + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response - The server side response. + * @returns The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + * + * @internal + */ + protected abstract getFactorId(response: MultiFactorInfoResponse): string | null; + + /** + * Initializes the MultiFactorInfo object using the provided server response. + * + * @param response - The server side response. + */ + private initFromServerResponse(response: MultiFactorInfoResponse): void { + const factorId = response && this.getFactorId(response); + if (!factorId || !response || !response.mfaEnrollmentId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response' + ); + } + addReadonlyGetter(this, 'uid', response.mfaEnrollmentId); + addReadonlyGetter(this, 'factorId', factorId); + addReadonlyGetter(this, 'displayName', response.displayName); + // Encoded using [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. + // For example, "2017-01-15T01:30:15.01Z". + // This can be parsed directly via Date constructor. + // This can be computed using Data.prototype.toISOString. + if (response.enrolledAt) { + addReadonlyGetter(this, 'enrollmentTime', new Date(response.enrolledAt).toUTCString()); + } else { + addReadonlyGetter(this, 'enrollmentTime', null); + } + } +} + +/** + * Interface representing a phone specific user-enrolled second factor. + */ +export class PhoneMultiFactorInfo extends MultiFactorInfo { + /** + * The phone number associated with a phone second factor. + */ + public readonly phoneNumber: string; + + /** + * Initializes the PhoneMultiFactorInfo object using the server side response. + * + * @param response - The server side response. + * @constructor + * @internal + */ + constructor(response: MultiFactorInfoResponse) { + super(response); + addReadonlyGetter(this, 'phoneNumber', response.phoneInfo); + } + + /** + * {@inheritdoc MultiFactorInfo.toJSON} + */ + public toJSON(): object { + return Object.assign(super.toJSON(), { + phoneNumber: this.phoneNumber, + }); + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response - The server side response. + * @returns The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + * + * @internal + */ + protected getFactorId(response: MultiFactorInfoResponse): string | null { + return response && response.phoneInfo ? MultiFactorId.Phone : null; + } +} + +/** + * `TotpInfo` struct associated with a second factor + */ +export class TotpInfo {} + +/** + * Interface representing a TOTP specific user-enrolled second factor. + */ +export class TotpMultiFactorInfo extends MultiFactorInfo { + /** + * `TotpInfo` struct associated with a second factor + */ + public readonly totpInfo: TotpInfo; + + /** + * Initializes the `TotpMultiFactorInfo` object using the server side response. + * + * @param response - The server side response. + * @constructor + * @internal + */ + constructor(response: MultiFactorInfoResponse) { + super(response); + addReadonlyGetter(this, 'totpInfo', response.totpInfo); + } + + /** + * {@inheritdoc MultiFactorInfo.toJSON} + */ + public toJSON(): object { + return Object.assign(super.toJSON(), { + totpInfo: this.totpInfo, + }); + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response - The server side response. + * @returns The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, `null` is returned. + * + * @internal + */ + protected getFactorId(response: MultiFactorInfoResponse): string | null { + return response && response.totpInfo ? MultiFactorId.Totp : null; + } +} + +/** + * The multi-factor related user settings. + */ +export class MultiFactorSettings { + /** + * List of second factors enrolled with the current user. + * Currently only phone and TOTP second factors are supported. + */ + public enrolledFactors: MultiFactorInfo[]; + + /** + * Initializes the `MultiFactor` object using the server side or JWT format response. + * + * @param response - The server side response. + * @constructor + * @internal + */ + constructor(response: GetAccountInfoUserResponse) { + const parsedEnrolledFactors: MultiFactorInfo[] = []; + if (!isNonNullObject(response)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor response' + ); + } else if (response.mfaInfo) { + response.mfaInfo.forEach(factorResponse => { + const multiFactorInfo = MultiFactorInfo.initMultiFactorInfo(factorResponse); + if (multiFactorInfo) { + parsedEnrolledFactors.push(multiFactorInfo); + } + }); + } + // Make enrolled factors immutable. + addReadonlyGetter(this, 'enrolledFactors', Object.freeze(parsedEnrolledFactors)); + } + + /** + * Returns a JSON-serializable representation of this multi-factor object. + * + * @returns A JSON-serializable representation of this multi-factor object. + */ + public toJSON(): object { + return { + enrolledFactors: this.enrolledFactors.map(info => info.toJSON()), + }; + } +} + +/** + * Represents a user's metadata. + */ +export class UserMetadata { + /** + * The date the user was created, formatted as a UTC string. + */ + public readonly creationTime: string; + + /** + * The date the user last signed in, formatted as a UTC string. + */ + public readonly lastSignInTime: string; + + /** + * The time at which the user was last active (ID token refreshed), + * formatted as a UTC Date string (eg 'Sat, 03 Feb 2001 04:05:06 GMT'). + * Returns null if the user was never active. + */ + public readonly lastRefreshTime?: string | null; + + /** + * @param response - The server side response returned from the `getAccountInfo` + * endpoint. + * @constructor + * @internal + */ + constructor(response: GetAccountInfoUserResponse) { + // Creation date should always be available but due to some backend bugs there + // were cases in the past where users did not have creation date properly set. + // This included legacy Firebase migrating project users and some anonymous users. + // These bugs have already been addressed since then. + addReadonlyGetter(this, 'creationTime', parseDate(response.createdAt)); + addReadonlyGetter(this, 'lastSignInTime', parseDate(response.lastLoginAt)); + const lastRefreshAt = response.lastRefreshAt ? new Date(response.lastRefreshAt).toUTCString() : null; + addReadonlyGetter(this, 'lastRefreshTime', lastRefreshAt); + } + + /** + * Returns a JSON-serializable representation of this object. + * + * @returns A JSON-serializable representation of this object. + */ + public toJSON(): object { + return { + lastSignInTime: this.lastSignInTime, + creationTime: this.creationTime, + lastRefreshTime: this.lastRefreshTime, + }; + } +} + +/** + * Represents a user's info from a third-party identity provider + * such as Google or Facebook. + */ +export class UserInfo { + /** + * The user identifier for the linked provider. + */ + public readonly uid: string; + + /** + * The display name for the linked provider. + */ + public readonly displayName: string; + + /** + * The email for the linked provider. + */ + public readonly email: string; + + /** + * The photo URL for the linked provider. + */ + public readonly photoURL: string; + + /** + * The linked provider ID (for example, "google.com" for the Google provider). + */ + public readonly providerId: string; + + /** + * The phone number for the linked provider. + */ + public readonly phoneNumber: string; + + /** + * @param response - The server side response returned from the `getAccountInfo` + * endpoint. + * @constructor + * @internal + */ + constructor(response: ProviderUserInfoResponse) { + // Provider user id and provider id are required. + if (!response.rawId || !response.providerId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid user info response' + ); + } + + addReadonlyGetter(this, 'uid', response.rawId); + addReadonlyGetter(this, 'displayName', response.displayName); + addReadonlyGetter(this, 'email', response.email); + addReadonlyGetter(this, 'photoURL', response.photoUrl); + addReadonlyGetter(this, 'providerId', response.providerId); + addReadonlyGetter(this, 'phoneNumber', response.phoneNumber); + } + + /** + * Returns a JSON-serializable representation of this object. + * + * @returns A JSON-serializable representation of this object. + */ + public toJSON(): object { + return { + uid: this.uid, + displayName: this.displayName, + email: this.email, + photoURL: this.photoURL, + providerId: this.providerId, + phoneNumber: this.phoneNumber, + }; + } +} + +/** + * Represents a user. + */ +export class UserRecord { + /** + * The user's `uid`. + */ + public readonly uid: string; + + /** + * The user's primary email, if set. + */ + public readonly email?: string; + + /** + * Whether or not the user's primary email is verified. + */ + public readonly emailVerified: boolean; + + /** + * The user's display name. + */ + public readonly displayName?: string; + + /** + * The user's photo URL. + */ + public readonly photoURL?: string; + + /** + * The user's primary phone number, if set. + */ + public readonly phoneNumber?: string; + + /** + * Whether or not the user is disabled: `true` for disabled; `false` for + * enabled. + */ + public readonly disabled: boolean; + + /** + * Additional metadata about the user. + */ + public readonly metadata: UserMetadata; + + /** + * An array of providers (for example, Google, Facebook) linked to the user. + */ + public readonly providerData: UserInfo[]; + + /** + * The user's hashed password (base64-encoded), only if Firebase Auth hashing + * algorithm (SCRYPT) is used. If a different hashing algorithm had been used + * when uploading this user, as is typical when migrating from another Auth + * system, this will be an empty string. If no password is set, this is + * null. This is only available when the user is obtained from + * {@link BaseAuth.listUsers}. + */ + public readonly passwordHash?: string; + + /** + * The user's password salt (base64-encoded), only if Firebase Auth hashing + * algorithm (SCRYPT) is used. If a different hashing algorithm had been used to + * upload this user, typical when migrating from another Auth system, this will + * be an empty string. If no password is set, this is null. This is only + * available when the user is obtained from {@link BaseAuth.listUsers}. + */ + public readonly passwordSalt?: string; + + /** + * The user's custom claims object if available, typically used to define + * user roles and propagated to an authenticated user's ID token. + * This is set via {@link BaseAuth.setCustomUserClaims} + */ + public readonly customClaims?: { [key: string]: any }; + + /** + * The ID of the tenant the user belongs to, if available. + */ + public readonly tenantId?: string | null; + + /** + * The date the user's tokens are valid after, formatted as a UTC string. + * This is updated every time the user's refresh token are revoked either + * from the {@link BaseAuth.revokeRefreshTokens} + * API or from the Firebase Auth backend on big account changes (password + * resets, password or email updates, etc). + */ + public readonly tokensValidAfterTime?: string; + + /** + * The multi-factor related properties for the current user, if available. + */ + public readonly multiFactor?: MultiFactorSettings; + + /** + * @param response - The server side response returned from the getAccountInfo + * endpoint. + * @constructor + * @internal + */ + constructor(response: GetAccountInfoUserResponse) { + // The Firebase user id is required. + if (!response.localId) { + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid user response'); + } + + addReadonlyGetter(this, 'uid', response.localId); + addReadonlyGetter(this, 'email', response.email); + addReadonlyGetter(this, 'emailVerified', !!response.emailVerified); + addReadonlyGetter(this, 'displayName', response.displayName); + addReadonlyGetter(this, 'photoURL', response.photoUrl); + addReadonlyGetter(this, 'phoneNumber', response.phoneNumber); + // If disabled is not provided, the account is enabled by default. + addReadonlyGetter(this, 'disabled', response.disabled || false); + addReadonlyGetter(this, 'metadata', new UserMetadata(response)); + const providerData: UserInfo[] = []; + for (const entry of response.providerUserInfo || []) { + providerData.push(new UserInfo(entry)); + } + addReadonlyGetter(this, 'providerData', providerData); + + // If the password hash is redacted (probably due to missing permissions) + // then clear it out, similar to how the salt is returned. (Otherwise, it + // *looks* like a b64-encoded hash is present, which is confusing.) + if (response.passwordHash === B64_REDACTED) { + addReadonlyGetter(this, 'passwordHash', undefined); + } else { + addReadonlyGetter(this, 'passwordHash', response.passwordHash); + } + + addReadonlyGetter(this, 'passwordSalt', response.salt); + if (response.customAttributes) { + addReadonlyGetter(this, 'customClaims', JSON.parse(response.customAttributes)); + } + + let validAfterTime: string | null = null; + // Convert validSince first to UTC milliseconds and then to UTC date string. + if (typeof response.validSince !== 'undefined') { + validAfterTime = parseDate(parseInt(response.validSince, 10) * 1000); + } + addReadonlyGetter(this, 'tokensValidAfterTime', validAfterTime || undefined); + addReadonlyGetter(this, 'tenantId', response.tenantId); + const multiFactor = new MultiFactorSettings(response); + if (multiFactor.enrolledFactors.length > 0) { + addReadonlyGetter(this, 'multiFactor', multiFactor); + } + } + + /** + * Returns a JSON-serializable representation of this object. + * + * @returns A JSON-serializable representation of this object. + */ + public toJSON(): object { + const json: any = { + uid: this.uid, + email: this.email, + emailVerified: this.emailVerified, + displayName: this.displayName, + photoURL: this.photoURL, + phoneNumber: this.phoneNumber, + disabled: this.disabled, + // Convert metadata to json. + metadata: this.metadata.toJSON(), + passwordHash: this.passwordHash, + passwordSalt: this.passwordSalt, + customClaims: deepCopy(this.customClaims), + tokensValidAfterTime: this.tokensValidAfterTime, + tenantId: this.tenantId, + }; + if (this.multiFactor) { + json.multiFactor = this.multiFactor.toJSON(); + } + json.providerData = []; + for (const entry of this.providerData) { + // Convert each provider data to json. + json.providerData.push(entry.toJSON()); + } + return json; + } +} + +/** + * Defines a new read-only property directly on an object and returns the object. + * + * @param obj - The object on which to define the property. + * @param prop - The name of the property to be defined or modified. + * @param value - The value associated with the property. + */ +function addReadonlyGetter(obj: object, prop: string, value: any): void { + Object.defineProperty(obj, prop, { + value, + // Make this property read-only. + writable: false, + // Include this property during enumeration of obj's properties. + enumerable: true, + }); +} + +/** + * Returns a deep copy of an object or array. + * + * @param value - The object or array to deep copy. + * @returns A deep copy of the provided object or array. + */ +function deepCopy(value: T): T { + return deepExtend(undefined, value); +} + +/** + * Copies properties from source to target (recursively allows extension of objects and arrays). + * Scalar values in the target are over-written. If target is undefined, an object of the + * appropriate type will be created (and returned). + * + * We recursively copy all child properties of plain objects in the source - so that namespace-like + * objects are merged. + * + * Note that the target can be a function, in which case the properties in the source object are + * copied onto it as static properties of the function. + * + * @param target - The value which is being extended. + * @param source - The value whose properties are extending the target. + * @returns The target value. + */ +function deepExtend(target: any, source: any): any { + if (!(source instanceof Object)) { + return source; + } + + switch (source.constructor) { + case Date: { + // Treat Dates like scalars; if the target date object had any child + // properties - they will be lost! + const dateValue = source as any as Date; + return new Date(dateValue.getTime()); + } + case Object: + if (target === undefined) { + target = {}; + } + break; + + case Array: + // Always copy the array source and overwrite the target. + target = []; + break; + + default: + // Not a plain Object - treat it as a scalar. + return source; + } + + for (const prop in source) { + if (!Object.prototype.hasOwnProperty.call(source, prop)) { + continue; + } + target[prop] = deepExtend(target[prop], source[prop]); + } + + return target; +} diff --git a/src/validator.ts b/src/validator.ts index c4f95d2..0c1e897 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -91,3 +91,13 @@ export function isObject(value: any): boolean { export function isNonNullObject(value: T | null | undefined): value is T { return isObject(value) && value !== null; } + +/** + * Validates that a string is a valid Firebase Auth uid. + * + * @param uid - The string to validate. + * @returns Whether the string is a valid Firebase Auth uid. + */ +export function isUid(uid: any): boolean { + return typeof uid === 'string' && uid.length > 0 && uid.length <= 128; +} diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 10f827a..b7cb2e9 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; +import { ApiSettings } from '../src/api-requests'; import { BaseAuth } from '../src/auth'; -import { FirebaseAuthError } from '../src/errors'; +import { AuthApiClient } from '../src/auth-api-requests'; +import { AuthClientErrorCode, FirebaseAuthError } from '../src/errors'; +import type { UserRecord } from '../src/user-record'; import type { EmulatorEnv, KeyStorer } from './../src/index'; import { EmulatedSigner, @@ -14,31 +17,32 @@ const env: EmulatorEnv = { FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099', }; +const sessionCookieUids = [ + generateRandomString(20), + generateRandomString(20), + generateRandomString(20), + generateRandomString(20), +]; + describe('createSessionCookie()', () => { const expiresIn = 24 * 60 * 60 * 1000; - let currentIdToken: string; - const sessionCookieUids = [ - generateRandomString(20), - generateRandomString(20), - generateRandomString(20), - generateRandomString(20), - ]; + const uid = sessionCookieUids[0]; - // const uid2 = sessionCookieUids[1]; - // const uid3 = sessionCookieUids[2]; - // const uid4 = sessionCookieUids[3]; + const uid2 = sessionCookieUids[1]; + const uid3 = sessionCookieUids[2]; + const uid4 = sessionCookieUids[3]; + + const signer = new EmulatedSigner(); + const tokenGenerator = new FirebaseTokenGenerator(signer); + const keyStorer = new InMemoryKeyStorer('cache-key'); it('creates a valid Firebase session cookie', async () => { - const keyStorer = new InMemoryKeyStorer('cache-key'); const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); - const signer = new EmulatedSigner(); - const tokenGenerator = new FirebaseTokenGenerator(signer); const customToken = await tokenGenerator.createCustomToken(uid, { admin: true, groupId: '1234' }); const { idToken } = await signInWithCustomToken(customToken, env); - currentIdToken = idToken; - const decodedToken = await auth.verifyIdToken(idToken, env); + const decodedToken = await auth.verifyIdToken(idToken, false, env); const expectedExp = Math.floor((new Date().getTime() + expiresIn) / 1000); const want = { @@ -50,8 +54,8 @@ describe('createSessionCookie()', () => { }; const expectedIat = Math.floor(new Date().getTime() / 1000); - const sessionCookie = await auth.createSessionCookie(currentIdToken, { expiresIn }, env); - const got = await auth.verifySessionCookie(sessionCookie, env); + const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn }, env); + const got = await auth.verifySessionCookie(sessionCookie, false, env); // Check for expected expiration with +/-5 seconds of variation. expect(got.exp).to.be.within(expectedExp - 5, expectedExp + 5); expect(got.iat).to.be.within(expectedIat - 5, expectedIat + 5); @@ -65,22 +69,161 @@ describe('createSessionCookie()', () => { }).to.deep.equal(want); }); - describe('verifySessionCookie()', () => { - const uid = sessionCookieUids[0]; - const keyStorer = new InMemoryKeyStorer('cache-key'); + it('creates a revocable session cookie', async () => { const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); - it('fails when called with an invalid session cookie', async () => { - await expect(auth.verifySessionCookie('invalid-token')).rejects.toThrowError(FirebaseAuthError); - }); - - it('fails when called with a Firebase ID token', async () => { - const signer = new EmulatedSigner(); - const tokenGenerator = new FirebaseTokenGenerator(signer); - const customToken = await tokenGenerator.createCustomToken(uid, { admin: true, groupId: '1234' }); - const { idToken } = await signInWithCustomToken(customToken, env); - - await expect(auth.verifySessionCookie(idToken)).rejects.toThrowError(FirebaseAuthError); - }); + + const customToken = await tokenGenerator.createCustomToken(uid2, { admin: true, groupId: '1234' }); + const { idToken } = await signInWithCustomToken(customToken, env); + + const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn }, env); + + await new Promise(resolve => setTimeout(() => resolve(auth.revokeRefreshTokens(uid2, env)), 1000)); + + // Check revocation is forced in emulator-mode and this should throw. + await expect(auth.verifySessionCookie(sessionCookie, false, env)).rejects.toThrowError( + new FirebaseAuthError(AuthClientErrorCode.SESSION_COOKIE_REVOKED) + ); + + await expect(auth.verifySessionCookie(sessionCookie, true, env)).rejects.toThrowError( + new FirebaseAuthError(AuthClientErrorCode.SESSION_COOKIE_REVOKED) + ); + }); + + it('fails when called with a revoked ID token', async () => { + const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); + + const customToken = await tokenGenerator.createCustomToken(uid3, { admin: true, groupId: '1234' }); + const { idToken } = await signInWithCustomToken(customToken, env); + + await new Promise(resolve => setTimeout(() => resolve(auth.revokeRefreshTokens(uid3, env)), 1000)); + // auth/id-token-expired + await expect(auth.createSessionCookie(idToken, { expiresIn }, env)).rejects.toThrowError( + new FirebaseAuthError(AuthClientErrorCode.ID_TOKEN_EXPIRED) + ); + }); + + it('fails when called with user disabled', async () => { + const expiresIn = 24 * 60 * 60 * 1000; + const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); + + const customToken = await tokenGenerator.createCustomToken(uid4, { admin: true, groupId: '1234' }); + const { idToken } = await signInWithCustomToken(customToken, env); + + const decodedIdTokenClaims = await auth.verifyIdToken(idToken, false, env); + expect(decodedIdTokenClaims.uid).toBe(uid4); + + const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn }, env); + const decodedIdToken = await auth.verifySessionCookie(sessionCookie, true, env); + expect(decodedIdToken.uid).toBe(uid4); + + const cli = new TestAuthApiClient(projectId, new NopCredential()); + + const userRecord = await cli.disableUser(uid4, env); + // Ensure disabled field has been updated. + expect(userRecord.uid).toBe(uid4); + expect(userRecord.disabled).toBe(true); + + await expect(auth.createSessionCookie(idToken, { expiresIn }, env)).rejects.toThrowError( + new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED) + ); + }); +}); + +describe('verifySessionCookie()', () => { + const uid = sessionCookieUids[0]; + const keyStorer = new InMemoryKeyStorer('cache-key'); + const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); + const signer = new EmulatedSigner(); + const tokenGenerator = new FirebaseTokenGenerator(signer); + + it('fails when called with an invalid session cookie', async () => { + await expect(auth.verifySessionCookie('invalid-token', false, env)).rejects.toThrowError(FirebaseAuthError); + }); + + it('fails when called with a Firebase ID token', async () => { + const customToken = await tokenGenerator.createCustomToken(uid, { admin: true, groupId: '1234' }); + const { idToken } = await signInWithCustomToken(customToken, env); + + await expect(auth.verifySessionCookie(idToken, false, env)).rejects.toThrowError(FirebaseAuthError); + }); + + it('fails with checkRevoked set to true and corresponding user disabled', async () => { + const expiresIn = 24 * 60 * 60 * 1000; + const customToken = await tokenGenerator.createCustomToken(uid, { admin: true, groupId: '1234' }); + const { idToken } = await signInWithCustomToken(customToken, env); + + const decodedIdTokenClaims = await auth.verifyIdToken(idToken, false, env); + expect(decodedIdTokenClaims.uid).toBe(uid); + + const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn }, env); + const decodedIdToken = await auth.verifySessionCookie(sessionCookie, true, env); + expect(decodedIdToken.uid).to.equal(uid); + + const cli = new TestAuthApiClient(projectId, new NopCredential()); + const userRecord = await cli.disableUser(uid, env); + + // Ensure disabled field has been updated. + expect(userRecord.uid).to.equal(uid); + expect(userRecord.disabled).to.equal(true); + + // If it is in emulator mode, a user-disabled error will be thrown. + await expect(auth.verifySessionCookie(sessionCookie, false, env)).rejects.toThrowError( + new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED) + ); + + await expect(auth.verifySessionCookie(sessionCookie, true, env)).rejects.toThrowError( + new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED) + ); + }); +}); + +describe('getUser()', () => { + const newUserUid = generateRandomString(20); + const customClaims: { [key: string]: any } = { + admin: true, + groupId: '1234', + }; + const keyStorer = new InMemoryKeyStorer('cache-key'); + const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); + const signer = new EmulatedSigner(); + const tokenGenerator = new FirebaseTokenGenerator(signer); + + it('setCustomUserClaims() sets claims that are accessible via user ID token', async () => { + // Register user + const customToken = await tokenGenerator.createCustomToken(newUserUid, {}); + await signInWithCustomToken(customToken, env); + + // Set custom claims on the user. + await auth.setCustomUserClaims(newUserUid, customClaims, env); + const userRecord = await auth.getUser(newUserUid, env); + expect(userRecord.customClaims).toEqual(customClaims); + + const { idToken } = await signInWithCustomToken(customToken, env); + const decodedIdToken = await auth.verifyIdToken(idToken, false, env); + + // Confirm expected claims set on the user's ID token. + for (const key in customClaims) { + if (Object.prototype.hasOwnProperty.call(customClaims, key)) { + expect(decodedIdToken[key]).toEqual(customClaims[key]); + } + } + + // Test clearing of custom claims. + await auth.setCustomUserClaims(newUserUid, null, env); + const userRecord2 = await auth.getUser(newUserUid, env); + + // Custom claims should be cleared. + expect(userRecord2.customClaims).toEqual({}); + + // Confirm all custom claims are cleared from id token. + const { idToken: idToken2 } = await signInWithCustomToken(customToken, env); + const decodedIdToken2 = await auth.verifyIdToken(idToken2, false, env); + + for (const key in customClaims) { + if (Object.prototype.hasOwnProperty.call(customClaims, key)) { + expect(decodedIdToken2[key]).toBeUndefined(); + } + } }); }); @@ -112,3 +255,23 @@ class InMemoryKeyStorer implements KeyStorer { this.timerId = setTimeout(() => this.store.delete(this.cacheKey), expirationTtl * 1000); } } + +const FIREBASE_AUTH_DISABLE_USER = new ApiSettings('v1', '/accounts:update', 'POST') + // Set response validator. + .setResponseValidator((response: any) => { + // If the localId is not returned, then the request failed. + if (!response.localId) { + throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + } + }); + +class TestAuthApiClient extends AuthApiClient { + public async disableUser(uid: string, env?: EmulatorEnv): Promise { + const request: any = { + localId: uid, + disableUser: true, + }; + const { localId } = await this.fetch<{ localId: string }>(FIREBASE_AUTH_DISABLE_USER, request, env); + return await this.getAccountInfoByUid(localId, env); + } +} From 9306a28f43ae165779daddac67761a6d4a053f12 Mon Sep 17 00:00:00 2001 From: Kei Kamikawa Date: Sat, 24 Feb 2024 00:40:14 +0900 Subject: [PATCH 2/4] fixed example --- example/index.ts | 20 ++++++++++---------- example/wrangler.toml | 3 +++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/example/index.ts b/example/index.ts index 54078ec..84ed247 100644 --- a/example/index.ts +++ b/example/index.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { getCookie, setCookie } from 'hono/cookie'; import { csrf } from 'hono/csrf'; import { html } from 'hono/html'; -import { Auth, EmulatorCredential, emulatorHost, WorkersKVStoreSingle } from '../src'; +import { Auth, ServiceAccountCredential, emulatorHost, WorkersKVStoreSingle, AdminAuthApiClient } from '../src'; type Env = { EMAIL_ADDRESS: string; @@ -12,6 +12,9 @@ type Env = { PUBLIC_JWK_CACHE_KEY: string; FIREBASE_AUTH_EMULATOR_HOST: string; // satisfied EmulatorEnv + // Set JSON as string. + // See: https://cloud.google.com/iam/docs/keys-create-delete + SERVICE_ACCOUNT_JSON: string; }; const app = new Hono<{ Bindings: Env }>(); @@ -46,7 +49,7 @@ app.post('/verify-header', async c => { c.env.PROJECT_ID, WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV) ); - const firebaseToken = await auth.verifyIdToken(jwt, c.env); + const firebaseToken = await auth.verifyIdToken(jwt, false, c.env); return new Response(JSON.stringify(firebaseToken), { headers: { @@ -153,16 +156,13 @@ app.post('/admin/login_session', async c => { // The session cookie will have the same claims as the ID token. // To only allow session cookie setting on recent sign-in, auth_time in ID token // can be checked to ensure user was recently signed in before creating a session cookie. - const auth = Auth.getOrInitialize( + const auth = AdminAuthApiClient.getOrInitialize( c.env.PROJECT_ID, - WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV), - new EmulatorCredential() // You MUST use ServiceAccountCredential in real world + new ServiceAccountCredential(c.env.SERVICE_ACCOUNT_JSON) ); const sessionCookie = await auth.createSessionCookie( idToken, - { - expiresIn, - }, + expiresIn, c.env // This valus must be removed in real world ); setCookie(c, 'session', sessionCookie, { @@ -178,13 +178,13 @@ app.get('/admin/profile', async c => { const auth = Auth.getOrInitialize( c.env.PROJECT_ID, - WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV), - new EmulatorCredential() // You MUST use ServiceAccountCredential in real world + WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV) ); try { const decodedToken = await auth.verifySessionCookie( session, + false, c.env // This valus must be removed in real world ); return c.json(decodedToken); diff --git a/example/wrangler.toml b/example/wrangler.toml index fa5a579..f76470f 100644 --- a/example/wrangler.toml +++ b/example/wrangler.toml @@ -18,6 +18,9 @@ tsconfig = "./tsconfig.json" # FIREBASE_AUTH_EMULATOR_HOST = "" FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099" +# See: https://cloud.google.com/iam/docs/keys-create-delete +SERVICE_ACCOUNT_JSON = "{\"type\":\"service_account\",\"project_id\":\"project12345\",\"private_key_id\":\"xxxxxxxxxxxxxxxxx\",\"private_key\":\"-----BEGIN PRIVATE KEY-----XXXXXX-----END PRIVATE KEY-----\n\",\"client_email\":\"xxxxx@xxxxxx.iam.gserviceaccount.com\",\"client_id\":\"xxxxxx\",\"auth_uri\":\"https://accounts.google.com/o/oauth2/auth\",\"token_uri\":\"https://oauth2.googleapis.com/token\",\"auth_provider_x509_cert_url\":\"https://www.googleapis.com/oauth2/v1/certs\",\"client_x509_cert_url\":\"https://www.googleapis.com/robot/v1/metadata/x509/xxxxx@xxxxxx.iam.gserviceaccount.com\"}" + # Setup user account in Emulator UI EMAIL_ADDRESS = "test@example.com" PASSWORD = "test1234" From 57a4b630a793d0ac86c0aca301a1f2580c9fe509 Mon Sep 17 00:00:00 2001 From: Kei Kamikawa Date: Sat, 24 Feb 2024 00:49:10 +0900 Subject: [PATCH 3/4] tidy --- src/auth.ts | 79 +++++++++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index e9f8441..6dc8652 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -154,6 +154,9 @@ export class BaseAuth { * for code samples and detailed documentation. * * @param uid - The `uid` corresponding to the user whose data to fetch. + * @param env - An optional parameter specifying the environment in which the function is running. + * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. + * If not specified, the function will assume it is running in a production environment. * * @returns A promise fulfilled with the user * data corresponding to the provided `uid`. @@ -162,41 +165,6 @@ export class BaseAuth { return await this.authApiClient.getAccountInfoByUid(uid, env); } - /** - * Verifies the decoded Firebase issued JWT is not revoked or disabled. Returns a promise that - * resolves with the decoded claims on success. Rejects the promise with revocation error if revoked - * or user disabled. - * - * @param decodedIdToken - The JWT's decoded claims. - * @param revocationErrorInfo - The revocation error info to throw on revocation - * detection. - * @returns A promise that will be fulfilled after a successful verification. - */ - private async verifyDecodedJWTNotRevokedOrDisabled( - decodedIdToken: FirebaseIdToken, - revocationErrorInfo: ErrorInfo, - env?: EmulatorEnv - ): Promise { - // Get tokens valid after time for the corresponding user. - const user = await this.getUser(decodedIdToken.sub, env); - if (user.disabled) { - throw new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED, 'The user record is disabled.'); - } - // If no tokens valid after time available, token is not revoked. - if (user.tokensValidAfterTime) { - // Get the ID token authentication time and convert to milliseconds UTC. - const authTimeUtc = decodedIdToken.auth_time * 1000; - // Get user tokens valid after time in milliseconds UTC. - const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); - // Check if authentication time is older than valid since time. - if (authTimeUtc < validSinceUtc) { - throw new FirebaseAuthError(revocationErrorInfo); - } - } - // All checks above passed. Return the decoded token. - return decodedIdToken; - } - /** * Revokes all refresh tokens for an existing user. * @@ -212,6 +180,9 @@ export class BaseAuth { * * @param uid - The `uid` corresponding to the user whose refresh tokens * are to be revoked. + * @param env - An optional parameter specifying the environment in which the function is running. + * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. + * If not specified, the function will assume it is running in a production environment. * * @returns An empty promise fulfilled once the user's refresh * tokens have been revoked. @@ -240,12 +211,50 @@ export class BaseAuth { * user's ID token which is transmitted on every authenticated request. * For profile non-access related user attributes, use database or other * separate storage systems. + * @param env - An optional parameter specifying the environment in which the function is running. + * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. + * If not specified, the function will assume it is running in a production environment. * @returns A promise that resolves when the operation completes * successfully. */ public async setCustomUserClaims(uid: string, customUserClaims: object | null, env?: EmulatorEnv): Promise { await this.authApiClient.setCustomUserClaims(uid, customUserClaims, env); } + + /** + * Verifies the decoded Firebase issued JWT is not revoked or disabled. Returns a promise that + * resolves with the decoded claims on success. Rejects the promise with revocation error if revoked + * or user disabled. + * + * @param decodedIdToken - The JWT's decoded claims. + * @param revocationErrorInfo - The revocation error info to throw on revocation + * detection. + * @returns A promise that will be fulfilled after a successful verification. + */ + private async verifyDecodedJWTNotRevokedOrDisabled( + decodedIdToken: FirebaseIdToken, + revocationErrorInfo: ErrorInfo, + env?: EmulatorEnv + ): Promise { + // Get tokens valid after time for the corresponding user. + const user = await this.getUser(decodedIdToken.sub, env); + if (user.disabled) { + throw new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED, 'The user record is disabled.'); + } + // If no tokens valid after time available, token is not revoked. + if (user.tokensValidAfterTime) { + // Get the ID token authentication time and convert to milliseconds UTC. + const authTimeUtc = decodedIdToken.auth_time * 1000; + // Get user tokens valid after time in milliseconds UTC. + const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); + // Check if authentication time is older than valid since time. + if (authTimeUtc < validSinceUtc) { + throw new FirebaseAuthError(revocationErrorInfo); + } + } + // All checks above passed. Return the decoded token. + return decodedIdToken; + } } /** From fbd71b6d4e9585592ec9874ab06ce512e8139dd1 Mon Sep 17 00:00:00 2001 From: Kei Kamikawa Date: Sat, 24 Feb 2024 00:49:31 +0900 Subject: [PATCH 4/4] fixed README --- README.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 22352a2..a1287ea 100644 --- a/README.md +++ b/README.md @@ -120,22 +120,24 @@ Auth is created as a singleton object. This is because the Module Worker syntax See official document for project ID: https://firebase.google.com/docs/projects/learn-more#project-identifiers -### `authObj.verifyIdToken(idToken: string, env?: EmulatorEnv): Promise` +### `authObj.verifyIdToken(idToken: string, checkRevoked?: boolean, env?: EmulatorEnv): Promise` Verifies a Firebase ID token (JWT). If the token is valid, the promise is fulfilled with the token's decoded claims; otherwise, the promise is rejected. See the [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken) for more information about the specific properties below. - `idToken` The ID token to verify. +- `checkRevoked` - Whether to check if the session cookie was revoked. This requires an extra request to the Firebase Auth backend to check the `tokensValidAfterTime` time for the corresponding user. When not specified, this additional check is not performed. - `env` is an optional parameter. but this is using to detect should use emulator or not. -### `authObj.verifySessionCookie(sessionCookie: string, env?: EmulatorEnv): Promise` +### `authObj.verifySessionCookie(sessionCookie: string, checkRevoked?: boolean, env?: EmulatorEnv): Promise` Verifies a Firebase session cookie. Returns a Promise with the cookie claims. Rejects the promise if the cookie could not be verified. See [Verify Session Cookies](https://firebase.google.com/docs/auth/admin/manage-cookies#verify_session_cookie_and_check_permissions) for code samples and detailed documentation. - `sessionCookie` The session cookie to verify. +- `checkRevoked` - Whether to check if the session cookie was revoked. This requires an extra request to the Firebase Auth backend to check the `tokensValidAfterTime` time for the corresponding user. When not specified, this additional check is not performed. - `env` is an optional parameter. but this is using to detect should use emulator or not. ### `authObj.createSessionCookie(idToken: string, sessionCookieOptions: SessionCookieOptions, env?: EmulatorEnv): Promise` @@ -148,6 +150,28 @@ Creates a new Firebase session cookie with the specified options. The created JW **Required** service acccount credential to use this API. You need to set the credentials with `Auth.getOrInitialize`. +### `authObj.getUser(uid: string, env?: EmulatorEnv): Promise` + +Gets the user data for the user corresponding to a given `uid`. + +- `uid` corresponding to the user whose data to fetch. +- `env` is an optional parameter. but this is using to detect should use emulator or not. + +### `authObj.revokeRefreshTokens(uid: string, env?: EmulatorEnv): Promise` + +Revokes all refresh tokens for an existing user. + +- `uid` corresponding to the user whose refresh tokens are to be revoked. +- `env` is an optional parameter. but this is using to detect should use emulator or not. + +### `authObj.setCustomUserClaims(uid: string, customUserClaims: object | null, env?: EmulatorEnv): Promise` + +Sets additional developer claims on an existing user identified by the provided `uid`, typically used to define user roles and levels of access. These claims should propagate to all devices where the user is already signed in (after token expiration or when token refresh is forced) and the next time the user signs in. If a reserved OIDC claim name is used (sub, iat, iss, etc), an error is thrown. They are set on the authenticated user's ID token JWT. + +- `uid` - The `uid` of the user to edit. +- `customUserClaims` The developer claims to set. If null is passed, existing custom claims are deleted. Passing a custom claims payload larger than 1000 bytes will throw an error. Custom claims are added to the user's ID token which is transmitted on every authenticated request. For profile non-access related user attributes, use database or other separate storage systems. +- `env` is an optional parameter. but this is using to detect should use emulator or not. + ### `WorkersKVStoreSingle.getOrInitialize(cacheKey: string, cfKVNamespace: KVNamespace): WorkersKVStoreSingle` WorkersKVStoreSingle is created as a singleton object. This is because the Module Worker syntax only use environment variables at the time of request. @@ -236,4 +260,4 @@ Access to `/admin/login` after started up Emulator and created an account (email ### Required service account key. -- [ ] Check authorized user is deleted (revoked) +- [x] Check authorized user is deleted (revoked)