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
2 changes: 2 additions & 0 deletions docs-devsite/_toc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ toc:
path: /docs/reference/js/auth.emulatorconfig.md
- title: FacebookAuthProvider
path: /docs/reference/js/auth.facebookauthprovider.md
- title: FirebaseToken
path: /docs/reference/js/auth.firebasetoken.md
- title: GithubAuthProvider
path: /docs/reference/js/auth.githubauthprovider.md
- title: GoogleAuthProvider
Expand Down
13 changes: 13 additions & 0 deletions docs-devsite/auth.auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface Auth
| [config](./auth.auth.md#authconfig) | [Config](./auth.config.md#config_interface) | The [Config](./auth.config.md#config_interface) used to initialize this instance. |
| [currentUser](./auth.auth.md#authcurrentuser) | [User](./auth.user.md#user_interface) \| null | The currently signed-in user (or null). |
| [emulatorConfig](./auth.auth.md#authemulatorconfig) | [EmulatorConfig](./auth.emulatorconfig.md#emulatorconfig_interface) \| null | The current emulator configuration (or null). |
| [firebaseToken](./auth.auth.md#authfirebasetoken) | [FirebaseToken](./auth.firebasetoken.md#firebasetoken_interface) \| null | The token response initialized via [exchangeToken()](./auth.md#exchangetoken_b6b1871) endpoint. |
| [languageCode](./auth.auth.md#authlanguagecode) | string \| null | The [Auth](./auth.auth.md#auth_interface) instance's language code. |
| [name](./auth.auth.md#authname) | string | The name of the app associated with the <code>Auth</code> service instance. |
| [settings](./auth.auth.md#authsettings) | [AuthSettings](./auth.authsettings.md#authsettings_interface) | The [Auth](./auth.auth.md#auth_interface) instance's settings. |
Expand Down Expand Up @@ -87,6 +88,18 @@ The current emulator configuration (or null).
readonly emulatorConfig: EmulatorConfig | null;
```

## Auth.firebaseToken

The token response initialized via [exchangeToken()](./auth.md#exchangetoken_b6b1871) endpoint.

This field is only supported for [Auth](./auth.auth.md#auth_interface) instance that have defined [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface)<!-- -->.

<b>Signature:</b>

```typescript
readonly firebaseToken: FirebaseToken | null;
```

## Auth.languageCode

The [Auth](./auth.auth.md#auth_interface) instance's language code.
Expand Down
40 changes: 40 additions & 0 deletions docs-devsite/auth.firebasetoken.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Project: /docs/reference/js/_project.yaml
Book: /docs/reference/_book.yaml
page_type: reference

{% comment %}
DO NOT EDIT THIS FILE!
This is generated by the JS SDK team, and any local changes will be
overwritten. Changes should be made in the source code at
https://github.com/firebase/firebase-js-sdk
{% endcomment %}

# FirebaseToken interface
<b>Signature:</b>

```typescript
export interface FirebaseToken
```

## Properties

| Property | Type | Description |
| --- | --- | --- |
| [expirationTime](./auth.firebasetoken.md#firebasetokenexpirationtime) | number | |
| [token](./auth.firebasetoken.md#firebasetokentoken) | string | |

## FirebaseToken.expirationTime

<b>Signature:</b>

```typescript
readonly expirationTime: number;
```

## FirebaseToken.token

<b>Signature:</b>

```typescript
readonly token: string;
```
1 change: 1 addition & 0 deletions docs-devsite/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Firebase Authentication
| [ConfirmationResult](./auth.confirmationresult.md#confirmationresult_interface) | A result from a phone number sign-in, link, or reauthenticate call. |
| [Dependencies](./auth.dependencies.md#dependencies_interface) | The dependencies that can be used to initialize an [Auth](./auth.auth.md#auth_interface) instance. |
| [EmulatorConfig](./auth.emulatorconfig.md#emulatorconfig_interface) | Configuration of Firebase Authentication Emulator. |
| [FirebaseToken](./auth.firebasetoken.md#firebasetoken_interface) | |
| [IdTokenResult](./auth.idtokenresult.md#idtokenresult_interface) | Interface representing ID token result obtained from [User.getIdTokenResult()](./auth.user.md#usergetidtokenresult)<!-- -->. |
| [MultiFactorAssertion](./auth.multifactorassertion.md#multifactorassertion_interface) | The base class for asserting ownership of a second factor. |
| [MultiFactorError](./auth.multifactorerror.md#multifactorerror_interface) | The error thrown when the user needs to provide a second factor to sign in successfully. |
Expand Down
4 changes: 3 additions & 1 deletion packages/auth/src/api/authentication/exchange_token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ export interface ExchangeTokenRequest {
}

export interface ExchangeTokenResponse {
// The firebase access token (JWT signed by Firebase Auth).
accessToken: string;
expiresIn?: string;
// The time when the access token expires.
expiresIn: number;
}

export async function exchangeToken(
Expand Down
10 changes: 9 additions & 1 deletion packages/auth/src/core/auth/auth_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ import {
NextFn,
Unsubscribe,
PasswordValidationStatus,
TenantConfig
TenantConfig,
FirebaseToken
} from '../../model/public_types';
import {
createSubscribe,
Expand Down Expand Up @@ -100,6 +101,7 @@ export const enum DefaultConfig {
export class AuthImpl implements AuthInternal, _FirebaseService {
currentUser: User | null = null;
emulatorConfig: EmulatorConfig | null = null;
firebaseToken: FirebaseToken | null = null;
private operations = Promise.resolve();
private persistenceManager?: PersistenceUserManager;
private redirectPersistenceManager?: PersistenceUserManager;
Expand Down Expand Up @@ -455,6 +457,12 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
});
}

async _updateFirebaseToken(
firebaseToken: FirebaseToken | null
): Promise<void> {
this.firebaseToken = firebaseToken;
}

async signOut(): Promise<void> {
if (_isFirebaseServerApp(this.app)) {
return Promise.reject(
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\./
)
);
});
});
});
34 changes: 34 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 @@ -85,11 +95,35 @@ export class AuthInterop implements FirebaseAuthInternal {
);
}

private assertRegionalAuthConfigured(): void {
_assert(this.auth.tenantConfig, AuthErrorCode.OPERATION_NOT_ALLOWED);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this required with new changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed unused methods.


private updateProactiveRefresh(): void {
if (this.internalListeners.size > 0) {
this.auth._startProactiveRefresh();
} else {
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 };
}
}
10 changes: 9 additions & 1 deletion packages/auth/src/core/strategies/exchange_token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
testAuth,
TestAuth
} from '../../../test/helpers/mock_auth';
import * as sinon from 'sinon';
import * as mockFetch from '../../../test/helpers/mock_fetch';
import { HttpHeader, RegionalEndpoint } from '../../api';
import { exchangeToken } from './exhange_token';
Expand All @@ -35,19 +36,23 @@ use(chaiAsPromised);
describe('core/strategies/exchangeToken', () => {
let auth: TestAuth;
let regionalAuth: TestAuth;
let now: number;

beforeEach(async () => {
auth = await testAuth();
regionalAuth = await regionalTestAuth();
mockFetch.setUp();
now = Date.now();
sinon.stub(Date, 'now').returns(now);
});
afterEach(mockFetch.tearDown);
afterEach(() => sinon.restore());

it('should return a valid access token for Regional Auth', async () => {
const mock = mockRegionalEndpointWithParent(
RegionalEndpoint.EXCHANGE_TOKEN,
'projects/test-project-id/locations/us/tenants/tenant-1/idpConfigs/idp-config',
{ accessToken: 'outbound-token', expiresIn: '1000' }
{ accessToken: 'outbound-token', expiresIn: 10 }
);

const accessToken = await exchangeToken(
Expand All @@ -65,6 +70,8 @@ describe('core/strategies/exchangeToken', () => {
expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq(
'application/json'
);
expect(regionalAuth.firebaseToken?.token).to.equal('outbound-token');
expect(regionalAuth.firebaseToken?.expirationTime).to.equal(now + 10_000);
});

it('throws exception for default Auth', async () => {
Expand Down Expand Up @@ -106,5 +113,6 @@ describe('core/strategies/exchangeToken', () => {
expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq(
'application/json'
);
expect(regionalAuth.firebaseToken).is.null;
});
});
7 changes: 6 additions & 1 deletion packages/auth/src/core/strategies/exhange_token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ export async function exchangeToken(
parent: buildParent(auth, idpConfigId),
token: customToken
});
// TODO(sammansi): Write token to the Auth object passed.
if (token) {
await authInternal._updateFirebaseToken({
token: token.accessToken,
expirationTime: Date.now() + token.expiresIn * 1000
});
}
return token.accessToken;
}

Expand Down
Loading
Loading