From 79850289c6f60f1f3a3039ca3486bf968b6b4c0f Mon Sep 17 00:00:00 2001 From: Pragati Date: Wed, 4 Oct 2023 13:03:13 -0400 Subject: [PATCH] passkey config admin changes --- etc/firebase-admin.auth.api.md | 36 +++++++ src/auth/auth-api-request.ts | 70 ++++++++++++++ src/auth/auth.ts | 12 +++ src/auth/index.ts | 9 ++ src/auth/passkey-config-manager.ts | 50 ++++++++++ src/auth/passkey-config.ts | 131 ++++++++++++++++++++++++++ test/integration/auth.spec.ts | 51 +++++++++- test/unit/auth/passkey-config.spec.ts | 129 +++++++++++++++++++++++++ 8 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/auth/passkey-config-manager.ts create mode 100644 src/auth/passkey-config.ts create mode 100644 test/unit/auth/passkey-config.spec.ts diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 3723abd051..fe03cb5ed8 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -51,6 +51,7 @@ export interface AllowlistOnlyWrap { export class Auth extends BaseAuth { // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts get app(): App; + passkeyConfigManager(): PasskeyConfigManager; projectConfigManager(): ProjectConfigManager; tenantManager(): TenantManager; } @@ -344,6 +345,41 @@ export interface OIDCUpdateAuthProviderRequest { responseType?: OAuthResponseType; } +// @public (undocumented) +export class PasskeyConfig { + // Warning: (ae-forgotten-export) The symbol "PasskeyConfigServerResponse" needs to be exported by the entry point index.d.ts + constructor(response: PasskeyConfigServerResponse); + // Warning: (ae-forgotten-export) The symbol "PasskeyConfigClientRequest" needs to be exported by the entry point index.d.ts + // + // (undocumented) + static buildServerRequest(isCreateRequest: boolean, passkeyConfigRequest?: PasskeyConfigRequest, rpId?: string): PasskeyConfigClientRequest; + // (undocumented) + readonly expectedOrigins?: string[]; + // (undocumented) + readonly name?: string; + // (undocumented) + readonly rpId?: string; + // (undocumented) + toJSON(): object; +} + +// @public (undocumented) +export class PasskeyConfigManager { + constructor(app: App); + // (undocumented) + createPasskeyConfig(rpId: string, passkeyConfigRequest: PasskeyConfigRequest, tenantId?: string): Promise; + // (undocumented) + getPasskeyConfig(tenantId?: string): Promise; + // (undocumented) + updatePasskeyConfig(passkeyConfigRequest: PasskeyConfigRequest, tenantId?: string): Promise; +} + +// @public (undocumented) +export interface PasskeyConfigRequest { + // (undocumented) + expectedOrigins?: string[]; +} + // @public export interface PasswordPolicyConfig { constraints?: CustomStrengthOptionsConfig; diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index c4ba2ac811..e7eac78547 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -43,6 +43,7 @@ import { SAMLUpdateAuthProviderRequest } from './auth-config'; import { ProjectConfig, ProjectConfigServerResponse, UpdateProjectConfigRequest } from './project-config'; +import {PasskeyConfig, PasskeyConfigServerResponse, PasskeyConfigRequest} from './passkey-config'; /** Firebase Auth request header. */ const FIREBASE_AUTH_HEADER = { @@ -2070,6 +2071,54 @@ const CREATE_TENANT = new ApiSettings('/tenants', 'POST') } }); +/** Instantiates the getPasskeyConfig endpoint settings. */ +const GET_PASSKEY_CONFIG = new ApiSettings('/passkeyConfig', 'GET') + .setResponseValidator((response: any) => { + // Response should always contain at least the config name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get project config', + ); + } + }); + +/** Instantiates the getPasskeyConfig endpoint settings. */ +const GET_TENANT_PASSKEY_CONFIG = new ApiSettings('/tenants/{tenantId}/passkeyConfig', 'GET') + .setResponseValidator((response: any) => { + // Response should always contain at least the config name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get project config', + ); + } + }); + +/** Instantiates the getPasskeyConfig endpoint settings. */ +const UPDATE_PASSKEY_CONFIG = new ApiSettings('/passkeyConfig?updateMask={updateMask}', 'PATCH') + .setResponseValidator((response: any) => { + // Response should always contain at least the config name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get project config', + ); + } + }); + +/** Instantiates the getPasskeyConfig endpoint settings. */ +const UPDATE_TENANT_PASSKEY_CONFIG = new ApiSettings('/tenant/{tenantId}/passkeyConfig?updateMask={updateMask}', 'PATCH') + .setResponseValidator((response: any) => { + // Response should always contain at least the config name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get project config', + ); + } + }); + /** * Utility for sending requests to Auth server that are Auth instance related. This includes user, tenant, @@ -2245,6 +2294,27 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { return Promise.reject(e); } } + + public getPasskeyConfig(tenantId?: string): Promise { + return this.invokeRequestHandler(this.authResourceUrlBuilder, tenantId? GET_TENANT_PASSKEY_CONFIG: GET_PASSKEY_CONFIG, {}, {}) + .then((response: any) => { + return response as PasskeyConfigServerResponse; + }); + } + + public updatePasskeyConfig(isCreateRequest: boolean, tenantId?: string, options?: PasskeyConfigRequest, rpId?: string): Promise { + try { + const request = PasskeyConfig.buildServerRequest(isCreateRequest, options, rpId); + const updateMask = utils.generateUpdateMask(request); + return this.invokeRequestHandler( + this.authResourceUrlBuilder, tenantId? UPDATE_TENANT_PASSKEY_CONFIG: UPDATE_PASSKEY_CONFIG, request, { updateMask: updateMask.join(',') }) + .then((response: any) => { + return response as PasskeyConfigServerResponse; + }); + } catch (e) { + return Promise.reject(e); + } + } } /** diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 4808fbbdc0..f31ed2dab0 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -20,6 +20,7 @@ import { AuthRequestHandler } from './auth-api-request'; import { TenantManager } from './tenant-manager'; import { BaseAuth } from './base-auth'; import { ProjectConfigManager } from './project-config-manager'; +import { PasskeyConfigManager } from './passkey-config-manager'; /** * Auth service bound to the provided app. @@ -29,6 +30,7 @@ export class Auth extends BaseAuth { private readonly tenantManager_: TenantManager; private readonly projectConfigManager_: ProjectConfigManager; + private readonly passkeyConfigManager_: PasskeyConfigManager; private readonly app_: App; /** @@ -41,6 +43,7 @@ export class Auth extends BaseAuth { this.app_ = app; this.tenantManager_ = new TenantManager(app); this.projectConfigManager_ = new ProjectConfigManager(app); + this.passkeyConfigManager_ = new PasskeyConfigManager(app); } /** @@ -69,4 +72,13 @@ export class Auth extends BaseAuth { public projectConfigManager(): ProjectConfigManager { return this.projectConfigManager_; } + + /** + * Returns the passkey config manager instance. + * + * @returns The passkey config manager instance . + */ + public passkeyConfigManager(): PasskeyConfigManager { + return this.passkeyConfigManager_; + } } diff --git a/src/auth/index.ts b/src/auth/index.ts index a559a706f8..b3f5f954c7 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -142,6 +142,15 @@ export { ProjectConfigManager, } from './project-config-manager'; +export { + PasskeyConfigRequest, + PasskeyConfig, +} from './passkey-config'; + +export { + PasskeyConfigManager, +} from './passkey-config-manager'; + export { DecodedIdToken, DecodedAuthBlockingToken diff --git a/src/auth/passkey-config-manager.ts b/src/auth/passkey-config-manager.ts new file mode 100644 index 0000000000..184f96a8bd --- /dev/null +++ b/src/auth/passkey-config-manager.ts @@ -0,0 +1,50 @@ +/*! + * Copyright 2023 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { App } from '../app'; +import { + AuthRequestHandler, +} from './auth-api-request'; +import {PasskeyConfig, PasskeyConfigClientRequest, PasskeyConfigRequest, PasskeyConfigServerResponse} from './passkey-config'; + + +export class PasskeyConfigManager { + private readonly authRequestHandler: AuthRequestHandler; + + constructor(app: App) { + this.authRequestHandler = new AuthRequestHandler(app); + } + + public getPasskeyConfig(tenantId?: string): Promise { + return this.authRequestHandler.getPasskeyConfig() + .then((response: PasskeyConfigServerResponse) => { + return new PasskeyConfig(response); + }); + } + + public createPasskeyConfig(rpId: string, passkeyConfigRequest: PasskeyConfigRequest, tenantId?: string): Promise { + return this.authRequestHandler.updatePasskeyConfig(true, tenantId, passkeyConfigRequest, rpId) + .then((response: PasskeyConfigClientRequest) => { + return new PasskeyConfig(response); + }) + } + + public updatePasskeyConfig(passkeyConfigRequest: PasskeyConfigRequest, tenantId?: string): Promise { + return this.authRequestHandler.updatePasskeyConfig(false, tenantId, passkeyConfigRequest) + .then((response: PasskeyConfigClientRequest) => { + return new PasskeyConfig(response); + }) + } +} diff --git a/src/auth/passkey-config.ts b/src/auth/passkey-config.ts new file mode 100644 index 0000000000..667e46333d --- /dev/null +++ b/src/auth/passkey-config.ts @@ -0,0 +1,131 @@ +/*! + * Copyright 2023 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as validator from '../utils/validator'; +import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; +import {deepCopy} from '../utils/deep-copy'; + +export interface PasskeyConfigRequest { + expectedOrigins?: string[]; +} + +export interface PasskeyConfigServerResponse { + name?: string; + rpId?: string; + expectedOrigins?: string[]; +} + +export interface PasskeyConfigClientRequest { + rpId?: string; + expectedOrigins?: string[]; +} + + +export class PasskeyConfig { + public readonly name?: string; + public readonly rpId?: string; + public readonly expectedOrigins?: string[]; + + private static validate(isCreateRequest: boolean, passkeyConfigRequest?: PasskeyConfigRequest, rpId?: string) { + if(isCreateRequest && !validator.isNonEmptyString(rpId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `'rpId' must be a valid non-empty string'`, + ); + } + if(!isCreateRequest && typeof rpId !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `'rpId' cannot be changed once created.'`, + ); + } + if(!validator.isNonNullObject(passkeyConfigRequest)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `'passkeyConfigRequest' must be a valid non-empty object.'`, + ); + } + const validKeys = { + expectedOrigins: true, + }; + // Check for unsupported top level attributes. + for (const key in passkeyConfigRequest) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `'${key}' is not a valid PasskeyConfigRequest parameter.`, + ); + } + } + if(!validator.isNonEmptyArray(passkeyConfigRequest.expectedOrigins) || !validator.isNonNullObject(passkeyConfigRequest.expectedOrigins)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `'passkeyConfigRequest.expectedOrigins' must be a valid non-empty array of strings.'`, + ); + } + for(const origin in passkeyConfigRequest.expectedOrigins) { + if(!validator.isString(origin)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `'passkeyConfigRequest.expectedOrigins' must be a valid non-empty array of strings.'`, + ); + } + } + }; + + public static buildServerRequest(isCreateRequest: boolean, passkeyConfigRequest?: PasskeyConfigRequest, rpId?: string): PasskeyConfigClientRequest { + PasskeyConfig.validate(isCreateRequest, passkeyConfigRequest, rpId); + let request: PasskeyConfigClientRequest = {}; + if(isCreateRequest && typeof rpId !== 'undefined') { + request.rpId = rpId; + } + if(typeof request.expectedOrigins !== 'undefined') { + request.expectedOrigins = passkeyConfigRequest?.expectedOrigins; + } + return request; + }; + + constructor(response: PasskeyConfigServerResponse) { + if(typeof response.name !== 'undefined') { + this.name = response.name; + } + if(typeof response.rpId !== 'undefined') { + this.rpId = response.rpId; + }; + if(typeof response.expectedOrigins !== 'undefined') { + this.expectedOrigins = response.expectedOrigins; + } + }; + + public toJSON(): object { + const json = { + name: deepCopy(this.name), + rpId: deepCopy(this.rpId), + expectedOrigins: deepCopy(this.expectedOrigins), + }; + if(typeof json.name === 'undefined') { + delete json.name; + } + if(typeof json.rpId === 'undefined') { + delete json.rpId; + } + if(typeof json.expectedOrigins === 'undefined') { + delete json.expectedOrigins; + } + return json; + } + +}; + diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 7b113b3156..9473ba64f1 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -32,10 +32,11 @@ import { AuthProviderConfig, CreateTenantRequest, DeleteUsersResult, PhoneMultiFactorInfo, TenantAwareAuth, UpdatePhoneMultiFactorInfoRequest, UpdateTenantRequest, UserImportOptions, UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, UserMetadata, MultiFactorConfig, - PasswordPolicyConfig, SmsRegionConfig, + PasswordPolicyConfig, SmsRegionConfig } from '../../lib/auth/index'; import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; +import {PasskeyConfigRequest} from '../../src/auth'; const chalk = require('chalk'); // eslint-disable-line @typescript-eslint/no-var-requires @@ -2197,6 +2198,54 @@ describe('admin.auth', () => { }); }); + describe('Passkey config management operations', () => { + // Define expected passkey configuration + const expectedPasskeyConfig = { + name: `projects/{$projectId}/passkeyConfig`, + rpId: `{$projectId}.firebaseapp.com`, + expectedOrigins: ['app1', 'example.com'], + }; + + // Helper function to reset passkey config to the initial state + async function resetPasskeyConfig() { + const resetRequest = { expectedOrigins: expectedPasskeyConfig.expectedOrigins }; + await getAuth().passkeyConfigManager().updatePasskeyConfig(resetRequest); + } + + // Before each test, reset the passkey config to the initial state + beforeEach(async () => { + await resetPasskeyConfig(); + }); + + it('createPasskeyConfig() should create passkey config with expected passkeyConfig', async () => { + const rpId = `{$projectId}.firebaseapp.com`; + const createRequest = { expectedOrigins: ['app1', 'example.com'] }; + + const createdPasskeyConfig = await getAuth().passkeyConfigManager().createPasskeyConfig(rpId, createRequest); + const passkeyConfigObj = createdPasskeyConfig.toJSON(); + + expect(passkeyConfigObj).to.deep.equal(expectedPasskeyConfig); + }); + + it('getPasskeyConfig() should resolve with expected passkeyConfig', async () => { + const actualPasskeyConfig = await getAuth().passkeyConfigManager().getPasskeyConfig(); + const actualPasskeyConfigObj = actualPasskeyConfig.toJSON(); + + expect(actualPasskeyConfigObj).to.deep.equal(expectedPasskeyConfig); + }); + + it('updatePasskeyConfig() should resolve with updated expectedOrigins', async () => { + const updateRequest = { expectedOrigins: ['app1', 'example.com', 'app2'] }; + const expectedUpdatedPasskeyConfig = { ...expectedPasskeyConfig, expectedOrigins: updateRequest.expectedOrigins }; + + const updatedPasskeyConfig = await getAuth().passkeyConfigManager().updatePasskeyConfig(updateRequest); + const passkeyConfigObj = updatedPasskeyConfig.toJSON(); + + expect(passkeyConfigObj).to.deep.equal(expectedUpdatedPasskeyConfig); + }); + }); + + describe('SAML configuration operations', () => { const authProviderConfig1 = { providerId: randomSamlProviderId(), diff --git a/test/unit/auth/passkey-config.spec.ts b/test/unit/auth/passkey-config.spec.ts new file mode 100644 index 0000000000..f0b103b18d --- /dev/null +++ b/test/unit/auth/passkey-config.spec.ts @@ -0,0 +1,129 @@ +/*! + * Copyright 2023 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { + PasskeyConfig, PasskeyConfigRequest, PasskeyConfigServerResponse, + } from '../../../src/auth/passkey-config'; +import {deepCopy} from '../../../src/utils/deep-copy'; +import {ServerResponse} from 'http'; +import exp from 'constants'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('PasskeyConfig', () => { + const serverResponse: PasskeyConfigServerResponse = { + name: `projects/project-id/passkeyConfig`, + rpId: `project-id.firebaseapp.com`, + expectedOrigins: ['app1', 'example.com'], + }; + const passkeyConfigRequest: PasskeyConfigRequest = { + expectedOrigins: ['app1', 'website.com'], + }; + describe('buildServerRequest', () => { + describe('for a create request', () => { + const invalidRpId = [null, NaN, 0, 1, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidRpId.forEach((rpId) => { + it('should throw on invalid rpId {$rpId}', () => { + expect(PasskeyConfig.buildServerRequest(true, passkeyConfigRequest, rpId as any)).to.throw(`'rpId' must be a valid non-empty string'`); + }); + }); + }); + + describe('for update request', () => { + it('should throw error if rpId is defined', () => { + expect(PasskeyConfig.buildServerRequest(false, passkeyConfigRequest, 'project-id.firebaseapp.com')).to.throw(`'rpId' must be a valid non-empty string'`); + }); + }); + + + describe('for passkey config request', () => { + const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; + nonObjects.forEach((request) => { + it('should throw on invalid PasskeyConfigRequest:' + JSON.stringify(request), () => { + expect(() => { + PasskeyConfig.buildServerRequest(true, request as any); + }).to.throw(`'passkeyConfigRequest' must be a valid non-empty object.'`); + }); + }); + + it('should throw for invalid passkey config request attribute', () => { + const invalidAttributeObject = deepCopy(passkeyConfigRequest) as any; + invalidAttributeObject.invalidAttribute = 'invalid'; + expect(() => { + PasskeyConfig.buildServerRequest(invalidAttributeObject); + }).to.throw(`'invalidAttribute' is not a valid PasskeyConfigRequest parameter.`); + }); + + const invalidExpectedOriginsObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; + invalidExpectedOriginsObjects.forEach((expectedOriginsObject) => { + it('should throw for invalid expected origins values', () => { + let request = deepCopy(passkeyConfigRequest) as any; + request.expectedOrigins = expectedOriginsObject; + expect(() => { + PasskeyConfig.buildServerRequest(true, request as any); + }).to.throw(`'passkeyConfigRequest.expectedOrigins' must be a valid non-empty array of strings.'`); + }); + }); + }); + }); + + describe('constructor', () => { + const passkeyConfig = new PasskeyConfig(serverResponse); + it('should not throw on valid initialization', () => { + expect(() => new PasskeyConfig(serverResponse)).not.to.throw(); + }); + + it('should set readonly properties', () => { + const expectedServerResponse = { + name: `projects/project-id/passkeyConfig`, + rpId: `project-id.firebaseapp.com`, + expectedOrigins: ['app1', 'example.com'], + }; + expect(passkeyConfig.name).to.equal(expectedServerResponse.name); + expect(passkeyConfig.rpId).to.equal(expectedServerResponse.rpId); + expect(passkeyConfig.expectedOrigins).to.equal(expectedServerResponse.expectedOrigins); + }); + + + }); + + describe('toJSON', () => { + it('should return the expected object representation of passkey config', () => { + expect(new PasskeyConfig(serverResponse).toJSON()).to.deep.equal({ + name: deepCopy(serverResponse.name), + rpId: deepCopy(serverResponse).rpId, + expectedOrigins: deepCopy(serverResponse.expectedOrigins), + }); + }); + + it('should not populate optional fields if not available', () => { + const serverResponseOptionalCopy: PasskeyConfigServerResponse = deepCopy(serverResponse); + delete serverResponseOptionalCopy.rpId; + delete serverResponseOptionalCopy.expectedOrigins; + expect(new PasskeyConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({ + name: deepCopy(serverResponse.name), + }); + }); + }); +}); \ No newline at end of file