diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index ef28f329f56..4c1a00d154b 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -88,6 +88,7 @@ export interface Auth { readonly config: Config; readonly currentUser: User | null; readonly emulatorConfig: EmulatorConfig | null; + readonly firebaseToken: FirebaseToken | null; languageCode: string | null; readonly name: string; onAuthStateChanged(nextOrObserver: NextOrObserver, error?: ErrorFn, completed?: CompleteFn): Unsubscribe; @@ -364,7 +365,7 @@ export interface EmulatorConfig { export { ErrorFn } -// @public (undocumented) +// @public export function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise; // Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.d.ts @@ -388,6 +389,14 @@ export const FactorId: { // @public export function fetchSignInMethodsForEmail(auth: Auth, email: string): Promise; +// @public (undocumented) +export interface FirebaseToken { + // (undocumented) + readonly expirationTime: number; + // (undocumented) + readonly token: string; +} + // @public export function getAdditionalUserInfo(userCredential: UserCredential): AdditionalUserInfo | null; diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index 1c377b37332..2651fe43af9 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -460,9 +460,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { async _updateFirebaseToken( firebaseToken: FirebaseToken | null ): Promise { - if (firebaseToken) { - this.firebaseToken = firebaseToken; - } + this.firebaseToken = firebaseToken; } async signOut(): Promise { diff --git a/packages/auth/src/core/auth/firebase_internal.test.ts b/packages/auth/src/core/auth/firebase_internal.test.ts index ff5b94b3f4f..4213d720385 100644 --- a/packages/auth/src/core/auth/firebase_internal.test.ts +++ b/packages/auth/src/core/auth/firebase_internal.test.ts @@ -18,13 +18,19 @@ import { FirebaseError } from '@firebase/util'; import { expect, use } from 'chai'; import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; import chaiAsPromised from 'chai-as-promised'; -import { testAuth, testUser } from '../../../test/helpers/mock_auth'; +import { + regionalTestAuth, + testAuth, + testUser +} from '../../../test/helpers/mock_auth'; import { AuthInternal } from '../../model/auth'; import { UserInternal } from '../../model/user'; import { AuthInterop } from './firebase_internal'; +use(sinonChai); use(chaiAsPromised); describe('core/auth/firebase_internal', () => { @@ -37,6 +43,9 @@ describe('core/auth/firebase_internal', () => { afterEach(() => { sinon.restore(); + delete (auth as unknown as Record)[ + '_initializationPromise' + ]; }); context('getUid', () => { @@ -215,3 +224,74 @@ describe('core/auth/firebase_internal', () => { }); }); }); + +describe('core/auth/firebase_internal - Regional Firebase Auth', () => { + let regionalAuth: AuthInternal; + let regionalAuthInternal: AuthInterop; + let now: number; + beforeEach(async () => { + regionalAuth = await regionalTestAuth(); + regionalAuthInternal = new AuthInterop(regionalAuth); + now = Date.now(); + sinon.stub(Date, 'now').returns(now); + }); + + afterEach(() => { + sinon.restore(); + }); + + context('getFirebaseToken', () => { + it('returns null if firebase token is undefined', async () => { + expect(await regionalAuthInternal.getToken()).to.be.null; + }); + + it('returns the id token correctly', async () => { + await regionalAuth._updateFirebaseToken({ + token: 'access-token', + expirationTime: now + 300_000 + }); + expect(await regionalAuthInternal.getToken()).to.eql({ + accessToken: 'access-token' + }); + }); + + it('logs out the the id token expires in next 30 seconds', async () => { + expect(await regionalAuthInternal.getToken()).to.be.null; + }); + + it('logs out if token has expired', async () => { + await regionalAuth._updateFirebaseToken({ + token: 'access-token', + expirationTime: now - 5_000 + }); + expect(await regionalAuthInternal.getToken()).to.null; + expect(regionalAuth.firebaseToken).to.null; + }); + + it('logs out if token is expiring in next 5 seconds', async () => { + await regionalAuth._updateFirebaseToken({ + token: 'access-token', + expirationTime: now + 5_000 + }); + expect(await regionalAuthInternal.getToken()).to.null; + expect(regionalAuth.firebaseToken).to.null; + }); + + it('logs warning if getToken is called with forceRefresh true', async () => { + sinon.stub(console, 'warn'); + await regionalAuth._updateFirebaseToken({ + token: 'access-token', + expirationTime: now + 300_000 + }); + expect(await regionalAuthInternal.getToken(true)).to.eql({ + accessToken: 'access-token' + }); + expect(console.warn).to.have.been.calledWith( + sinon.match.string, + sinon.match( + /Refresh token is not a valid operation for Regional Auth instance initialized\./ + ) + ); + }); + }); +}); diff --git a/packages/auth/src/core/auth/firebase_internal.ts b/packages/auth/src/core/auth/firebase_internal.ts index 4fad0754375..caf06c49f6a 100644 --- a/packages/auth/src/core/auth/firebase_internal.ts +++ b/packages/auth/src/core/auth/firebase_internal.ts @@ -22,12 +22,14 @@ import { AuthInternal } from '../../model/auth'; import { UserInternal } from '../../model/user'; import { _assert } from '../util/assert'; import { AuthErrorCode } from '../errors'; +import { _logWarn } from '../util/log'; interface TokenListener { (tok: string | null): unknown; } export class AuthInterop implements FirebaseAuthInternal { + private readonly TOKEN_EXPIRATION_BUFFER = 30_000; private readonly internalListeners: Map = new Map(); @@ -43,6 +45,14 @@ export class AuthInterop implements FirebaseAuthInternal { ): Promise<{ accessToken: string } | null> { this.assertAuthConfigured(); await this.auth._initializationPromise; + if (this.auth.tenantConfig) { + if (forceRefresh) { + _logWarn( + 'Refresh token is not a valid operation for Regional Auth instance initialized.' + ); + } + return this.getTokenForRegionalAuth(); + } if (!this.auth.currentUser) { return null; } @@ -92,4 +102,24 @@ export class AuthInterop implements FirebaseAuthInternal { this.auth._stopProactiveRefresh(); } } + + private async getTokenForRegionalAuth(): Promise<{ + accessToken: string; + } | null> { + if (!this.auth.firebaseToken) { + return null; + } + + if ( + !this.auth.firebaseToken.expirationTime || + Date.now() > + this.auth.firebaseToken.expirationTime - this.TOKEN_EXPIRATION_BUFFER + ) { + await this.auth._updateFirebaseToken(null); + return null; + } + + const accessToken = await this.auth.firebaseToken.token; + return { accessToken }; + } } diff --git a/packages/auth/test/helpers/mock_auth.ts b/packages/auth/test/helpers/mock_auth.ts index 68e155a88f4..dee18047fde 100644 --- a/packages/auth/test/helpers/mock_auth.ts +++ b/packages/auth/test/helpers/mock_auth.ts @@ -118,7 +118,11 @@ export async function testAuth( return auth; } -export async function regionalTestAuth(): Promise { +export async function regionalTestAuth( + popupRedirectResolver?: PopupRedirectResolver, + persistence = new MockPersistenceLayer(), + skipAwaitOnInit?: boolean +): Promise { const tenantConfig = { 'location': 'us', 'tenantId': 'tenant-1' }; const auth: TestAuth = new AuthImpl( FAKE_APP, @@ -135,6 +139,15 @@ export async function regionalTestAuth(): Promise { }, tenantConfig ) as TestAuth; + if (skipAwaitOnInit) { + // This is used to verify scenarios where auth flows (like signInWithRedirect) are invoked before auth is fully initialized. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + auth._initializeWithPersistence([persistence], popupRedirectResolver); + } else { + await auth._initializeWithPersistence([persistence], popupRedirectResolver); + } + auth.persistenceLayer = persistence; + auth.settings.appVerificationDisabledForTesting = true; return auth; }