Skip to content

Implement getToken method for Regional Auth Interop #9061

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 4, 2025
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