Skip to content
11 changes: 10 additions & 1 deletion common/api-review/auth.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User | null>, error?: ErrorFn, completed?: CompleteFn): Unsubscribe;
Expand Down Expand Up @@ -364,7 +365,7 @@ export interface EmulatorConfig {

export { ErrorFn }

// @public (undocumented)
// @public
export function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise<string>;

// Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.d.ts
Expand All @@ -388,6 +389,14 @@ export const FactorId: {
// @public
export function fetchSignInMethodsForEmail(auth: Auth, email: string): Promise<string[]>;

// @public (undocumented)
export interface FirebaseToken {
// (undocumented)
readonly expirationTime: number;
// (undocumented)
readonly token: string;
}

// @public
export function getAdditionalUserInfo(userCredential: UserCredential): AdditionalUserInfo | null;

Expand Down
4 changes: 1 addition & 3 deletions packages/auth/src/core/auth/auth_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
async _updateFirebaseToken(
firebaseToken: FirebaseToken | null
): Promise<void> {
if (firebaseToken) {
this.firebaseToken = firebaseToken;
}
this.firebaseToken = firebaseToken;
}

async signOut(): Promise<void> {
Expand Down
82 changes: 81 additions & 1 deletion packages/auth/src/core/auth/firebase_internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -37,6 +43,9 @@ describe('core/auth/firebase_internal', () => {

afterEach(() => {
sinon.restore();
delete (auth as unknown as Record<string, unknown>)[
'_initializationPromise'
];
});

context('getUid', () => {
Expand Down Expand Up @@ -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\./
)
);
});
});
});
30 changes: 30 additions & 0 deletions packages/auth/src/core/auth/firebase_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenListener, Unsubscribe> =
new Map();

Expand All @@ -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;
}
Expand Down Expand Up @@ -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 };
}
}
15 changes: 14 additions & 1 deletion packages/auth/test/helpers/mock_auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ export async function testAuth(
return auth;
}

export async function regionalTestAuth(): Promise<TestAuth> {
export async function regionalTestAuth(
popupRedirectResolver?: PopupRedirectResolver,
persistence = new MockPersistenceLayer(),
skipAwaitOnInit?: boolean
): Promise<TestAuth> {
const tenantConfig = { 'location': 'us', 'tenantId': 'tenant-1' };
const auth: TestAuth = new AuthImpl(
FAKE_APP,
Expand All @@ -135,6 +139,15 @@ export async function regionalTestAuth(): Promise<TestAuth> {
},
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;
}

Expand Down
Loading