diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index d921458bec..c719ed7b09 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; } @@ -824,6 +825,36 @@ export interface OIDCUpdateAuthProviderRequest { responseType?: OAuthResponseType; } +// @public +export class PasskeyConfig { + readonly expectedOrigins?: string[]; + readonly name?: string; + readonly rpId?: string; + toJSON(): object; +} + +// @public +export class PasskeyConfigManager { + createPasskeyConfig(passkeyConfigRequest: PasskeyConfigRequest, tenantId?: string): Promise; + getPasskeyConfig(tenantId?: string): Promise; + updatePasskeyConfig(passkeyConfigRequest: PasskeyConfigRequest, tenantId?: string): Promise; +} + +// @public +export interface PasskeyConfigRequest { + expectedOrigins?: string[]; + // (undocumented) + rpId?: string; +} + +// @public +export class PasskeyInfo { + readonly credentialId: string; + readonly displayName?: string; + readonly name: string; + toJSON(): object; +} + // @public export interface PasswordPolicyConfig { constraints?: CustomStrengthOptionsConfig; @@ -1123,6 +1154,7 @@ export class UserRecord { readonly emailVerified: boolean; readonly metadata: UserMetadata; readonly multiFactor?: MultiFactorSettings; + readonly passkeyInfo?: PasskeyInfo[]; readonly passwordHash?: string; readonly passwordSalt?: string; readonly phoneNumber?: string; diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 9fd535777c..022642e7ec 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 = { @@ -1611,9 +1612,9 @@ export abstract class AbstractAuthRequestHandler { public getEmailActionLink( requestType: string, email: string, actionCodeSettings?: ActionCodeSettings, newEmail?: string): Promise { - let request = { - requestType, - email, + let request = { + requestType, + email, returnOobLink: true, ...(typeof newEmail !== 'undefined') && { newEmail }, }; @@ -2070,6 +2071,55 @@ const CREATE_TENANT = new ApiSettings('/tenants', 'POST') } }); +/** Instantiates the GET_PASSKEY_CONFIG endpoint settings. */ +const GET_PASSKEY_CONFIG = new ApiSettings('/passkeyConfig', 'GET') + .setResponseValidator((response: any) => { + // Validate the response for GET_PASSKEY_CONFIG. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get passkey config', + ); + } + }); + +/** Instantiates the GET_TENANT_PASSKEY_CONFIG endpoint settings. */ +const GET_TENANT_PASSKEY_CONFIG = new ApiSettings('/tenants/{tenantId}/passkeyConfig', 'GET') + .setResponseValidator((response: any) => { + // Validate the response for GET_TENANT_PASSKEY_CONFIG. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get tenant passkey config', + ); + } + }); + +/** Instantiates the UPDATE_PASSKEY_CONFIG endpoint settings. */ +const UPDATE_PASSKEY_CONFIG = new ApiSettings('/passkeyConfig?updateMask={updateMask}', 'PATCH') + .setResponseValidator((response: any) => { + // Validate the response for UPDATE_PASSKEY_CONFIG. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update passkey config', + ); + } + }); + +/** Instantiates the UPDATE_TENANT_PASSKEY_CONFIG endpoint settings. */ +const UPDATE_TENANT_PASSKEY_CONFIG = new ApiSettings( + '/tenants/{tenantId}/passkeyConfig?updateMask={updateMask}', 'PATCH') + .setResponseValidator((response: any) => { + // Validate the response for UPDATE_TENANT_PASSKEY_CONFIG. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update tenant passkey config', + ); + } + }); + /** * Utility for sending requests to Auth server that are Auth instance related. This includes user, tenant, @@ -2245,6 +2295,44 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { return Promise.reject(e); } } + + public getPasskeyConfig(tenantId?: string): Promise { + if (tenantId) { + return this.invokeRequestHandler(this.authResourceUrlBuilder, GET_TENANT_PASSKEY_CONFIG, {}, { tenantId }) + .then((response: any) => { + return response as PasskeyConfigServerResponse; + }); + } else { + return this.invokeRequestHandler(this.authResourceUrlBuilder, GET_PASSKEY_CONFIG, {}, {}) + .then((response: any) => { + return response as PasskeyConfigServerResponse; + }); + } + } + + public updatePasskeyConfig(isCreateRequest: boolean, tenantId?: string, + options?: PasskeyConfigRequest): Promise { + try { + const request = PasskeyConfig.buildServerRequest(isCreateRequest, options); + const updateMask = utils.generateUpdateMask(request); + if (tenantId) { + return this.invokeRequestHandler( + this.authResourceUrlBuilder, UPDATE_TENANT_PASSKEY_CONFIG, request, + { tenantId, updateMask: updateMask.join(',') }) + .then((response: any) => { + return response as PasskeyConfigServerResponse; + }); + } else { + return this.invokeRequestHandler( + this.authResourceUrlBuilder, 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 f350b28837..d6a9a810ed 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 @@ -163,6 +172,7 @@ export { UserInfo, UserMetadata, UserRecord, + PasskeyInfo, } from './user-record'; export { diff --git a/src/auth/passkey-config-manager.ts b/src/auth/passkey-config-manager.ts new file mode 100644 index 0000000000..decad12709 --- /dev/null +++ b/src/auth/passkey-config-manager.ts @@ -0,0 +1,85 @@ +/*! + * 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'; + +/** + * Manages Passkey configuration for a Firebase app. + */ +export class PasskeyConfigManager { + private readonly authRequestHandler: AuthRequestHandler; + + /** + * Initializes a PasskeyConfigManager instance for a specified FirebaseApp. + * + * @param app - The Firebase app associated with this PasskeyConfigManager instance. + * + * @constructor + * @internal + */ + constructor(app: App) { + this.authRequestHandler = new AuthRequestHandler(app); + } + + /** + * Retrieves the Passkey Configuration. + * + * @param tenantId - (optional) The tenant ID if querying passkeys on a specific tenant. + * @returns A promise fulfilled with the passkey configuration. + */ + public getPasskeyConfig(tenantId?: string): Promise { + return this.authRequestHandler.getPasskeyConfig(tenantId) + .then((response: PasskeyConfigServerResponse) => { + return new PasskeyConfig(response); + }); + } + + /** + * Creates a new passkey configuration. + * + * @param passkeyConfigRequest - Configuration details for the passkey. + * @param tenantId - (optional) The tenant ID for which the passkey config is created. + * @returns A promise fulfilled with the newly created passkey configuration. + */ + public createPasskeyConfig(passkeyConfigRequest: PasskeyConfigRequest, tenantId?: string): Promise { + return this.authRequestHandler.updatePasskeyConfig(true, tenantId, passkeyConfigRequest) + .then((response: PasskeyConfigClientRequest) => { + return new PasskeyConfig(response); + }); + } + + /** + * Updates an existing passkey configuration. + * + * @param passkeyConfigRequest - Updated configuration details for the passkey. + * @param tenantId - (optional) The tenant ID for which the passkey config is updated. + * @returns A promise fulfilled with the updated passkey configuration. + */ + 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..0dad65a7a8 --- /dev/null +++ b/src/auth/passkey-config.ts @@ -0,0 +1,189 @@ +/*! + * 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'; + +/** + * Interface representing the properties to update in a passkey config. + */ +export interface PasskeyConfigRequest { + rpId?: string; + /** + * An array of website or app origins. Only challenges signed + * from these origins will be allowed for signing in with passkeys. + */ + expectedOrigins?: string[]; +} + +/** + * Response received from the server when retrieving, creating, or updating the passkey config. + */ +export interface PasskeyConfigServerResponse { + name?: string; + rpId?: string; + expectedOrigins?: string[]; +} + +/** + * Request for creating or updating the passkey config on the server. + */ +export interface PasskeyConfigClientRequest { + rpId?: string; + expectedOrigins?: string[]; +} + +/** + * Configuration for signing in users using passkeys. + */ +export class PasskeyConfig { + /** + * The name of the PasskeyConfig resource. + */ + public readonly name?: string; + /** + * The relying party ID for passkey verifications. + * This cannot be changed once created. + */ + public readonly rpId?: string; + /** + * The allowed website or app origins. + * Only challenges signed from these origins will be allowed for signing in with passkeys. + */ + public readonly expectedOrigins?: string[]; + + /** + * Validates a passkey config request object and throws an error on failure. + * @param isCreateRequest - A boolean indicating if it's a create request or not. + * @param passkeyConfigRequest - Passkey config to be set. + * @param rpId - (optional) Relying party ID if it's a create request. + * @throws FirebaseAuthError - If validation fails. + * + * @internal + */ + private static validate(isCreateRequest: boolean, passkeyConfigRequest?: PasskeyConfigRequest): void { + // Validation for creating a new PasskeyConfig. + if (isCreateRequest && !validator.isNonEmptyString(passkeyConfigRequest?.rpId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "'rpId' must be a non-empty string.", + ); + } + // // Validation for updating an existing PasskeyConfig. + // 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 not be null.", + ); + } + const validKeys = { + rpId: true, + 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)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "'passkeyConfigRequest.expectedOrigins' must contain at least one item.", + ); + } + for (const origin of passkeyConfigRequest.expectedOrigins) { + if (!validator.isNonEmptyString(origin)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "'passkeyConfigRequest.expectedOrigins' cannot contain empty strings.", + ); + } + } + } + + /** + * Build a server request for a Passkey Config object. + * @param isCreateRequest - A boolean stating if it's a create request. + * @param passkeyConfigRequest - Passkey config to be updated. + * @returns The equivalent server request. + * @throws FirebaseAuthError - If validation fails. + * + * @internal + */ + public static buildServerRequest(isCreateRequest: boolean, + passkeyConfigRequest?: PasskeyConfigRequest): PasskeyConfigClientRequest { + PasskeyConfig.validate(isCreateRequest, passkeyConfigRequest); + const request: PasskeyConfigClientRequest = {}; + if (typeof passkeyConfigRequest?.rpId !== 'undefined') { + request.rpId = passkeyConfigRequest.rpId; + } + if (typeof passkeyConfigRequest?.expectedOrigins !== 'undefined') { + request.expectedOrigins = passkeyConfigRequest.expectedOrigins; + } + return request; + } + + /** + * The Passkey Config object constructor. + * @param response - The server-side response used to initialize the Passkey Config object. + * @constructor + * + * @internal + */ + 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; + } + } + + /** + * Returns a JSON-serializable representation of this object. + * @returns A JSON-serializable representation of this object. + */ + public toJSON(): object { + const json = { + name: this.name, + rpId: 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/src/auth/user-record.ts b/src/auth/user-record.ts index 5b00151401..e94f020cf1 100644 --- a/src/auth/user-record.ts +++ b/src/auth/user-record.ts @@ -56,6 +56,12 @@ export interface TotpInfoResponse { [key: string]: unknown; } +export interface PasskeyInfoResponse { + name: string; + credentialId: string; + displayName?: string; +} + export interface ProviderUserInfoResponse { rawId: string; displayName?: string; @@ -81,6 +87,7 @@ export interface GetAccountInfoUserResponse { tenantId?: string; providerUserInfo?: ProviderUserInfoResponse[]; mfaInfo?: MultiFactorInfoResponse[]; + passkeyInfo?: PasskeyInfoResponse[]; createdAt?: string; lastLoginAt?: string; lastRefreshAt?: string; @@ -357,6 +364,55 @@ export class MultiFactorSettings { } } +/** + * Interface representing a user-enrolled passkey. + */ +export class PasskeyInfo { + /** + * The name of the user. + */ + public readonly name: string; + /** + * Identifier for the registered credential. + */ + public readonly credentialId: string; + /** + * The human-readable name of the user, intended for display. + */ + public readonly displayName?: string; + + /** + * Initializes the PasskeyInfo object using the server side response. + * + * @param response - The server side response. + * @constructor + * @internal + */ + constructor(response: PasskeyInfoResponse) { + if (!isNonNullObject(response)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid passkey info response'); + } + utils.addReadonlyGetter(this, 'name', response.name); + utils.addReadonlyGetter(this, 'credentialId', response.credentialId); + utils.addReadonlyGetter(this, 'displayName', response.displayName); + } + + /** + * Returns a JSON-serializable representation of this passkey info object. + * + * @returns A JSON-serializable representation of this passkey info object. + */ + public toJSON(): object { + return { + name: this.name, + credentialId: this.credentialId, + displayName: this.displayName, + }; + } +} + /** * Represents a user's metadata. */ @@ -582,6 +638,11 @@ export class UserRecord { */ public readonly multiFactor?: MultiFactorSettings; + /** + * Passkey-related properties for the current user, if available. + */ + public readonly passkeyInfo?: PasskeyInfo[]; + /** * @param response - The server side response returned from the getAccountInfo * endpoint. @@ -637,6 +698,15 @@ export class UserRecord { if (multiFactor.enrolledFactors.length > 0) { utils.addReadonlyGetter(this, 'multiFactor', multiFactor); } + if (response.passkeyInfo) { + const passkeys: PasskeyInfo[] = []; + response.passkeyInfo.forEach((passkey) => { + passkeys.push(new PasskeyInfo(passkey)); + }); + if (passkeys.length > 0) { + utils.addReadonlyGetter(this, 'passkeyInfo', passkeys); + } + } } /** @@ -664,6 +734,12 @@ export class UserRecord { if (this.multiFactor) { json.multiFactor = this.multiFactor.toJSON(); } + if (this.passkeyInfo) { + json.passkeyInfo = []; + this.passkeyInfo.forEach((passkey) => { + json.passkeyInfo.push(passkey.toJSON()); + }) + } json.providerData = []; for (const entry of this.providerData) { // Convert each provider data to json. diff --git a/test/unit/auth/passkey-config-manager.spec.ts b/test/unit/auth/passkey-config-manager.spec.ts new file mode 100644 index 0000000000..be7235cd58 --- /dev/null +++ b/test/unit/auth/passkey-config-manager.spec.ts @@ -0,0 +1,256 @@ +/*! + * 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. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import * as mocks from '../../resources/mocks'; +import { FirebaseApp } from '../../../src/app/firebase-app'; +import { AuthRequestHandler } from '../../../src/auth/auth-api-request'; +import { AuthClientErrorCode, FirebaseAuthError } from '../../../src/utils/error'; +import { PasskeyConfigManager } from '../../../src/auth/passkey-config-manager'; +import { + PasskeyConfig, PasskeyConfigServerResponse, PasskeyConfigRequest, +} from '../../../src/auth/passkey-config'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('PasskeyConfigManager', () => { + let mockApp: FirebaseApp; + let passkeyConfigManager: PasskeyConfigManager; + let nullAccessTokenPasskeyConfigManager: PasskeyConfigManager; + let malformedAccessTokenPasskeyConfigManager: PasskeyConfigManager; + let rejectedPromiseAccessTokenPasskeyConfigManager: PasskeyConfigManager; + const GET_CONFIG_RESPONSE: PasskeyConfigServerResponse = { + name: 'projects/project-id/passkeyConfig', + rpId: 'project-id.firebaseapp.com', + expectedOrigins: ['app1', 'example.com'], + }; + + before(() => { + mockApp = mocks.app(); + passkeyConfigManager = new PasskeyConfigManager(mockApp); + nullAccessTokenPasskeyConfigManager = new PasskeyConfigManager( + mocks.appReturningNullAccessToken()); + malformedAccessTokenPasskeyConfigManager = new PasskeyConfigManager( + mocks.appReturningMalformedAccessToken()); + rejectedPromiseAccessTokenPasskeyConfigManager = new PasskeyConfigManager( + mocks.appRejectedWhileFetchingAccessToken()); + }); + + after(() => { + return mockApp.delete(); + }); + + describe('getPasskeyConfig()', () => { + const expectedPasskeyConfig = new PasskeyConfig(GET_CONFIG_RESPONSE); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenPasskeyConfigManager.getPasskeyConfig() + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenPasskeyConfigManager.getPasskeyConfig() + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenPasskeyConfigManager.getPasskeyConfig() + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Passkey Config on success', () => { + // Stub getPasskeyConfig to return expected result. + const stub = sinon.stub(AuthRequestHandler.prototype, 'getPasskeyConfig') + .returns(Promise.resolve(GET_CONFIG_RESPONSE)); + stubs.push(stub); + return passkeyConfigManager.getPasskeyConfig() + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce; + // Confirm expected project config returned. + expect(result).to.deep.equal(expectedPasskeyConfig); + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub getConfig to throw a backend error. + const stub = sinon.stub(AuthRequestHandler.prototype, 'getPasskeyConfig') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return passkeyConfigManager.getPasskeyConfig() + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce; + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('createPasskeyConfig()', () => { + const rpId = 'project-id.firebaseapp.com'; + const expectedOrigins: string[] = ['app1', 'example.com'] + const passkeyConfigRequest: PasskeyConfigRequest = { + rpId: rpId, + expectedOrigins: expectedOrigins , + }; + const expectedPasskeyConfig = new PasskeyConfig(GET_CONFIG_RESPONSE); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to create the config provided.'); + // Stubs used to simulate underlying API calls. + const stubs: sinon.SinonStub[] = []; + afterEach(() => { + sinon.restore(); + }); + + it('should be rejected given no passkeyConfigOptions', () => { + return (passkeyConfigManager as any).createPasskeyConfig(null as unknown as PasskeyConfigRequest) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenPasskeyConfigManager.createPasskeyConfig(passkeyConfigRequest) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenPasskeyConfigManager.createPasskeyConfig(passkeyConfigRequest) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenPasskeyConfigManager.createPasskeyConfig(passkeyConfigRequest) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a PasskeyConfig on createPasskeyConfig request success', () => { + // Stub createPasskeyConfig to return expected result. + const stub = sinon.stub(AuthRequestHandler.prototype, 'updatePasskeyConfig') + .returns(Promise.resolve(GET_CONFIG_RESPONSE)); + stubs.push(stub); + return passkeyConfigManager.createPasskeyConfig(passkeyConfigRequest) + .then((actualPasskeyConfig) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(true, undefined, passkeyConfigRequest); + // Confirm expected Passkey Config object returned. + expect(actualPasskeyConfig).to.deep.equal(expectedPasskeyConfig); + }); + }); + + it('should throw an error when createPasskeyConfig returns an error', () => { + // Stub createPasskeyConfig to throw a backend error. + const stub = sinon.stub(AuthRequestHandler.prototype, 'updatePasskeyConfig') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return passkeyConfigManager.createPasskeyConfig(passkeyConfigRequest) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(true, undefined, passkeyConfigRequest); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('updatePasskeyConfig()', () => { + const passkeyConfigOptions: PasskeyConfigRequest = { + expectedOrigins: ['app1', 'example.com', 'app2'], + }; + const expectedPasskeyConfig = new PasskeyConfig(GET_CONFIG_RESPONSE); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to update the config provided.'); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no passkeyConfigOptions', () => { + return (passkeyConfigManager as any).updatePasskeyConfig(null as unknown as PasskeyConfigRequest) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenPasskeyConfigManager.updatePasskeyConfig(passkeyConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenPasskeyConfigManager.updatePasskeyConfig(passkeyConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenPasskeyConfigManager.updatePasskeyConfig(passkeyConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a PasskeyConfig on updatePasskeyConfig request success', () => { + // Stub updatePasskeyConfig to return expected result. + const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updatePasskeyConfig') + .returns(Promise.resolve(GET_CONFIG_RESPONSE)); + stubs.push(updateConfigStub); + return passkeyConfigManager.updatePasskeyConfig(passkeyConfigOptions) + .then((actualPasskeyConfig) => { + // Confirm underlying API called with expected parameters. + expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(false, undefined, passkeyConfigOptions); + // Confirm expected Project Config object returned. + expect(actualPasskeyConfig).to.deep.equal(expectedPasskeyConfig); + }); + }); + + it('should throw an error when updatePasskeyConfig returns an error', () => { + // Stub updatePasskeyConfig to throw a backend error. + const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updatePasskeyConfig') + .returns(Promise.reject(expectedError)); + stubs.push(updateConfigStub); + return passkeyConfigManager.updatePasskeyConfig(passkeyConfigOptions) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(false, undefined, passkeyConfigOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); +}); diff --git a/test/unit/auth/passkey-config.spec.ts b/test/unit/auth/passkey-config.spec.ts new file mode 100644 index 0000000000..f292919f6d --- /dev/null +++ b/test/unit/auth/passkey-config.spec.ts @@ -0,0 +1,142 @@ +/*! + * 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, PasskeyConfigClientRequest +} from '../../../src/auth/passkey-config'; +import { deepCopy } from '../../../src/utils/deep-copy'; + +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 = { + rpId: 'project-id.firebaseapp.com', + expectedOrigins: ['app1', 'example.com'], + }; + describe('buildServerRequest', () => { + describe('for a create request', () => { + it('should create a client request with valid params', () => { + const expectedRequest: PasskeyConfigClientRequest = { + rpId: passkeyConfigRequest.rpId, + expectedOrigins: passkeyConfigRequest.expectedOrigins, + }; + expect(PasskeyConfig.buildServerRequest(true, passkeyConfigRequest)).to.deep.equal(expectedRequest); + }); + + const invalidRpId = [null, NaN, 0, 1, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidRpId.forEach((rpId) => { + it(`should throw on invalid rpId ${rpId}`, () => { + const passkeyConfigRequestWithInvalidRpId: PasskeyConfigRequest = { + rpId: rpId as any, + expectedOrigins: passkeyConfigRequest.expectedOrigins, + }; + expect(() => PasskeyConfig.buildServerRequest(true, passkeyConfigRequestWithInvalidRpId)).to.throw( + '\'rpId\' must be a valid non-empty string'); + }); + }); + }); + + describe('for update request', () => { + it('should create a client request with valid params', () => { + const expectedRequest: PasskeyConfigClientRequest = { + rpId: passkeyConfigRequest.rpId, + expectedOrigins: passkeyConfigRequest.expectedOrigins, + }; + expect(PasskeyConfig.buildServerRequest(false, passkeyConfigRequest)).to.deep.equal(expectedRequest); + }); + }); + + 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(false, 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(false, 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', () => { + const request = deepCopy(passkeyConfigRequest) as any; + request.expectedOrigins = expectedOriginsObject; + expect(() => { + PasskeyConfig.buildServerRequest(false, 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.deep.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), + }); + }); + }); +}); diff --git a/test/unit/auth/user-record.spec.ts b/test/unit/auth/user-record.spec.ts index dc332c13b9..40e7b8da99 100644 --- a/test/unit/auth/user-record.spec.ts +++ b/test/unit/auth/user-record.spec.ts @@ -21,7 +21,7 @@ import * as chaiAsPromised from 'chai-as-promised'; import { deepCopy } from '../../../src/utils/deep-copy'; import { - GetAccountInfoUserResponse, ProviderUserInfoResponse, MultiFactorInfoResponse, TotpMultiFactorInfo, + GetAccountInfoUserResponse, ProviderUserInfoResponse, MultiFactorInfoResponse, TotpMultiFactorInfo, PasskeyInfo, } from '../../../src/auth/user-record'; import { UserInfo, UserMetadata, UserRecord, MultiFactorSettings, MultiFactorInfo, PhoneMultiFactorInfo, @@ -100,6 +100,22 @@ function getValidUserResponse(tenantId?: string): GetAccountInfoUserResponse { phoneInfo: '+16505556789', }, ], + passkeyInfo: [ + { + name: 'name1@google.com', + credentialId: 'credentialId1', + displayName: 'passkey1', + }, + { + name: 'name2@google.com', + credentialId: 'credentialId2', + displayName: 'passkey2', + }, + { + name: 'name3@google.com', + credentialId: 'credentialId3', + } + ] }; if (typeof tenantId !== 'undefined') { response.tenantId = tenantId; @@ -185,6 +201,23 @@ function getUserJSON(tenantId?: string): object { }, ], }, + passkeyInfo: [ + { + name: 'name1@google.com', + credentialId: 'credentialId1', + displayName: 'passkey1', + }, + { + name: 'name2@google.com', + credentialId: 'credentialId2', + displayName: 'passkey2', + }, + { + name: 'name3@google.com', + credentialId: 'credentialId3', + displayName: undefined, + } + ] }; } @@ -663,6 +696,66 @@ describe('MultiFactorSettings', () => { }); }); +describe('PasskeyInfo', () => { + const passkeyInfoData = { + name: 'John Doe', + credentialId: 'credential123', + displayName: 'john.doe@example.com', + }; + const passkeyInfo = new PasskeyInfo(passkeyInfoData); + + describe('constructor', () => { + it('should create a PasskeyInfo object with valid data', () => { + expect(passkeyInfo).to.be.an.instanceOf(PasskeyInfo); + }); + + it('should throw when missing required fields', () => { + expect(() => { + return new PasskeyInfo(null as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid passkey info response'); + }); + }); + + describe('getters', () => { + it('should return the expected name', () => { + expect(passkeyInfo.name).to.equal(passkeyInfoData.name); + }); + + it('should throw when modifying readonly name property', () => { + expect(() => { + (passkeyInfo as any).name = 'Modified Name'; + }).to.throw(Error); + }); + + it('should return the expected credentialId', () => { + expect(passkeyInfo.credentialId).to.equal(passkeyInfoData.credentialId); + }); + + it('should throw when modifying readonly credentialId property', () => { + expect(() => { + (passkeyInfo as any).credentialId = 'modifiedCredential'; + }).to.throw(Error); + }); + + it('should return the expected displayName', () => { + expect(passkeyInfo.displayName).to.equal(passkeyInfoData.displayName); + }); + + it('should throw when modifying readonly displayName property', () => { + expect(() => { + (passkeyInfo as any).displayName = 'modifiedDisplayName'; + }).to.throw(Error); + }); + }); + + describe('toJSON', () => { + it('should return the expected JSON object', () => { + expect(passkeyInfo.toJSON()).to.deep.equal(passkeyInfoData); + }); + }); +}); + + describe('UserInfo', () => { describe('constructor', () => { it('should throw when an empty object is provided', () => {