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
2 changes: 2 additions & 0 deletions docs-devsite/_toc.yaml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions docs-devsite/auth.auth.md
Original file line number Diff line number Diff line change
@@ -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. |
@@ -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.
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
@@ -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. |
1 change: 1 addition & 0 deletions packages/auth-interop-types/index.d.ts
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ export interface FirebaseAuthTokenData {

export interface FirebaseAuthInternal {
getToken(refreshToken?: boolean): Promise<FirebaseAuthTokenData | null>;
getFirebaseToken(): Promise<FirebaseAuthTokenData | null>;
getUid(): string | null;
addAuthTokenListener(fn: (token: string | null) => void): void;
removeAuthTokenListener(fn: (token: string | null) => void): void;
4 changes: 3 additions & 1 deletion packages/auth/src/api/authentication/exchange_token.ts
Original file line number Diff line number Diff line change
@@ -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(
10 changes: 9 additions & 1 deletion packages/auth/src/core/auth/auth_impl.ts
Original file line number Diff line number Diff line change
@@ -37,7 +37,8 @@ import {
NextFn,
Unsubscribe,
PasswordValidationStatus,
TenantConfig
TenantConfig,
FirebaseToken
} from '../../model/public_types';
import {
createSubscribe,
@@ -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;
@@ -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(
63 changes: 62 additions & 1 deletion packages/auth/src/core/auth/firebase_internal.test.ts
Original file line number Diff line number Diff line change
@@ -20,7 +20,11 @@ import { expect, use } from 'chai';
import * as sinon from 'sinon';
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';
@@ -37,6 +41,9 @@ describe('core/auth/firebase_internal', () => {

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

context('getUid', () => {
@@ -215,3 +222,57 @@ 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.getFirebaseToken()).to.be.null;
});

it('returns the id token correctly', async () => {
await regionalAuth._updateFirebaseToken({
token: 'access-token',
expirationTime: now + 300_000
});
expect(await regionalAuthInternal.getFirebaseToken()).to.eql({
accessToken: 'access-token'
});
});

it('logs out the the id token expires in next 30 seconds', async () => {
expect(await regionalAuthInternal.getFirebaseToken()).to.be.null;
});

it('logs out if token has expired', async () => {
await regionalAuth._updateFirebaseToken({
token: 'access-token',
expirationTime: now - 5_000
});
expect(await regionalAuthInternal.getFirebaseToken()).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.getFirebaseToken()).to.null;
expect(regionalAuth.firebaseToken).to.null;
});
});
});
26 changes: 26 additions & 0 deletions packages/auth/src/core/auth/firebase_internal.ts
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@ interface TokenListener {
}

export class AuthInterop implements FirebaseAuthInternal {
private readonly TOKEN_EXPIRATION_BUFFER = 30_000;
private readonly internalListeners: Map<TokenListener, Unsubscribe> =
new Map();

@@ -51,6 +52,27 @@ export class AuthInterop implements FirebaseAuthInternal {
return { accessToken };
}

async getFirebaseToken(): Promise<{ accessToken: string } | null> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't we use the existing getToken method instead of new? If we create new, then wouldn't the existing product logic have to make 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.

In that case we would have to force that getToken method should have forceRefresh as false if regional auth is initialized.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, that would be the case as refreshToken is not supported. Alternatively we can log a warning message and ignore the field for now, but i would prefer throwing error as it makes it more clear.

this.assertAuthConfigured();
await this.auth._initializationPromise;
this.assertRegionalAuthConfigured();
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 };
}

addAuthTokenListener(listener: TokenListener): void {
this.assertAuthConfigured();
if (this.internalListeners.has(listener)) {
@@ -85,6 +107,10 @@ 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();
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
@@ -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';
@@ -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(
@@ -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 () => {
@@ -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
@@ -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;
}

2 changes: 2 additions & 0 deletions packages/auth/src/model/auth.ts
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@ import {
AuthSettings,
Config,
EmulatorConfig,
FirebaseToken,
PasswordPolicy,
PasswordValidationStatus,
PopupRedirectResolver,
@@ -75,6 +76,7 @@ export interface AuthInternal extends Auth {
_initializationPromise: Promise<void> | null;
_persistenceManagerAvailable: Promise<void>;
_updateCurrentUser(user: UserInternal | null): Promise<void>;
_updateFirebaseToken(firebaseToken: FirebaseToken | null): Promise<void>;

_onStorageEvent(): void;

15 changes: 15 additions & 0 deletions packages/auth/src/model/public_types.ts
Original file line number Diff line number Diff line change
@@ -334,6 +334,14 @@ export interface Auth {
* {@link @firebase/app#FirebaseServerApp}.
*/
signOut(): Promise<void>;
/**
* The token response initialized via {@link exchangeToken} endpoint.
*
* @remarks
* This field is only supported for {@link Auth} instance that have defined
* {@link TenantConfig}.
*/
readonly firebaseToken: FirebaseToken | null;
}

/**
@@ -966,6 +974,13 @@ export interface ReactNativeAsyncStorage {
removeItem(key: string): Promise<void>;
}

export interface FirebaseToken {
// The firebase access token (JWT signed by Firebase Auth).
readonly token: string;
// The time when the access token expires.
readonly expirationTime: number;
}

/**
* A user account.
*
Loading
Loading