diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 4c1a00d154b..0976da5172d 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -88,13 +88,13 @@ 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; onIdTokenChanged(nextOrObserver: NextOrObserver, error?: ErrorFn, completed?: CompleteFn): Unsubscribe; setPersistence(persistence: Persistence): Promise; readonly settings: AuthSettings; + setTokenRefreshHandler(tokenRefreshHandler: TokenRefreshHandler): void; signOut(): Promise; readonly tenantConfig?: TenantConfig; tenantId: string | null; @@ -389,14 +389,6 @@ 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; @@ -743,6 +735,12 @@ export class RecaptchaVerifier implements ApplicationVerifierInternal { verify(): Promise; } +// @public +export interface RefreshIdpTokenResult { + idpConfigId: string; + idToken: string; +} + // @public export function reload(user: User): Promise; @@ -815,6 +813,11 @@ export interface TenantConfig { tenantId: string; } +// @public +export interface TokenRefreshHandler { + refreshIdpToken(): Promise; +} + // @public export interface TotpMultiFactorAssertion extends MultiFactorAssertion { } diff --git a/docs-devsite/_toc.yaml b/docs-devsite/_toc.yaml index 03af169abf0..561cc3e217b 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -310,8 +310,6 @@ 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 @@ -368,10 +366,14 @@ toc: path: /docs/reference/js/auth.recaptchaparameters.md - title: RecaptchaVerifier path: /docs/reference/js/auth.recaptchaverifier.md + - title: RefreshIdpTokenResult + path: /docs/reference/js/auth.refreshidptokenresult.md - title: SAMLAuthProvider path: /docs/reference/js/auth.samlauthprovider.md - title: TenantConfig path: /docs/reference/js/auth.tenantconfig.md + - title: TokenRefreshHandler + path: /docs/reference/js/auth.tokenrefreshhandler.md - title: TotpMultiFactorAssertion path: /docs/reference/js/auth.totpmultifactorassertion.md - title: TotpMultiFactorGenerator diff --git a/docs-devsite/auth.auth.md b/docs-devsite/auth.auth.md index 6a73129640a..1db61201040 100644 --- a/docs-devsite/auth.auth.md +++ b/docs-devsite/auth.auth.md @@ -28,7 +28,6 @@ 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 Auth service instance. | | [settings](./auth.auth.md#authsettings) | [AuthSettings](./auth.authsettings.md#authsettings_interface) | The [Auth](./auth.auth.md#auth_interface) instance's settings. | @@ -44,6 +43,7 @@ export interface Auth | [onAuthStateChanged(nextOrObserver, error, completed)](./auth.auth.md#authonauthstatechanged) | Adds an observer for changes to the user's sign-in state. | | [onIdTokenChanged(nextOrObserver, error, completed)](./auth.auth.md#authonidtokenchanged) | Adds an observer for changes to the signed-in user's ID token. | | [setPersistence(persistence)](./auth.auth.md#authsetpersistence) | Changes the type of persistence on the Auth instance. | +| [setTokenRefreshHandler(tokenRefreshHandler)](./auth.auth.md#authsettokenrefreshhandler) | Registers a handler for refreshing third-party identity provider (IDP) tokens.When the Firebase access token is expired, the SDK will automatically invoke the provided handler's refreshIdpToken() method to obtain a new IDP token. This new token will then be exchanged for a fresh Firebase token, streamlining the authentication process. | | [signOut()](./auth.auth.md#authsignout) | Signs out the current user. This does not automatically revoke the user's ID token. | | [updateCurrentUser(user)](./auth.auth.md#authupdatecurrentuser) | Asynchronously sets the provided user as [Auth.currentUser](./auth.auth.md#authcurrentuser) on the [Auth](./auth.auth.md#auth_interface) instance. | | [useDeviceLanguage()](./auth.auth.md#authusedevicelanguage) | Sets the current language to the default device/browser preference. | @@ -88,18 +88,6 @@ 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). - -Signature: - -```typescript -readonly firebaseToken: FirebaseToken | null; -``` - ## Auth.languageCode The [Auth](./auth.auth.md#auth_interface) instance's language code. @@ -285,6 +273,44 @@ auth.setPersistence(browserSessionPersistence); ``` +## Auth.setTokenRefreshHandler() + +Registers a handler for refreshing third-party identity provider (IDP) tokens. + +When the Firebase access token is expired, the SDK will automatically invoke the provided handler's `refreshIdpToken()` method to obtain a new IDP token. This new token will then be exchanged for a fresh Firebase token, streamlining the authentication process. + +Signature: + +```typescript +setTokenRefreshHandler(tokenRefreshHandler: TokenRefreshHandler): void; +``` + +#### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| tokenRefreshHandler | [TokenRefreshHandler](./auth.tokenrefreshhandler.md#tokenrefreshhandler_interface) | An object that implements the TokenRefreshHandler interface, providing the logic to refresh the IDP token. | + +Returns: + +void + +### Example + + +```javascript +class TokenRefreshHandlerImpl { + refreshIdpToken() { + // Logic to fetch a new token from your custom IDP. + // Returns a Promise that resolves with a RefreshIdpTokenResult. + } +} + +const tokenRefreshHandler = new TokenRefreshHandlerImpl(); +auth.setTokenRefreshHandler(tokenRefreshHandler); + +``` + ## Auth.signOut() Signs out the current user. This does not automatically revoke the user's ID token. diff --git a/docs-devsite/auth.firebasetoken.md b/docs-devsite/auth.firebasetoken.md deleted file mode 100644 index 7a6a8d1223b..00000000000 --- a/docs-devsite/auth.firebasetoken.md +++ /dev/null @@ -1,40 +0,0 @@ -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 -Signature: - -```typescript -export interface FirebaseToken -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [expirationTime](./auth.firebasetoken.md#firebasetokenexpirationtime) | number | | -| [token](./auth.firebasetoken.md#firebasetokentoken) | string | | - -## FirebaseToken.expirationTime - -Signature: - -```typescript -readonly expirationTime: number; -``` - -## FirebaseToken.token - -Signature: - -```typescript -readonly token: string; -``` diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 8f9a1745dc9..36446a9a0fe 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -118,7 +118,6 @@ 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. | @@ -139,7 +138,9 @@ Firebase Authentication | [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) | A resolver used for handling DOM specific operations like [signInWithPopup()](./auth.md#signinwithpopup_770f816) or [signInWithRedirect()](./auth.md#signinwithredirect_770f816). | | [ReactNativeAsyncStorage](./auth.reactnativeasyncstorage.md#reactnativeasyncstorage_interface) | Interface for a supplied AsyncStorage. | | [RecaptchaParameters](./auth.recaptchaparameters.md#recaptchaparameters_interface) | Interface representing reCAPTCHA parameters.See the [reCAPTCHA docs](https://developers.google.com/recaptcha/docs/display#render_param) for the list of accepted parameters. All parameters are accepted except for sitekey: Firebase Auth provisions a reCAPTCHA for each project and will configure the site key upon rendering.For an invisible reCAPTCHA, set the size key to invisible. | +| [RefreshIdpTokenResult](./auth.refreshidptokenresult.md#refreshidptokenresult_interface) | The result of a third-party IDP token refresh operation.This object contains the new IDP token and the Idp Config ID of the provider that issued it. | | [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) | The tenant config that can be used to initialize a Regional [Auth](./auth.auth.md#auth_interface) instance. | +| [TokenRefreshHandler](./auth.tokenrefreshhandler.md#tokenrefreshhandler_interface) | An interface for handling the refresh of Firebase tokens. | | [TotpMultiFactorAssertion](./auth.totpmultifactorassertion.md#totpmultifactorassertion_interface) | The class for asserting ownership of a TOTP second factor. Provided by [TotpMultiFactorGenerator.assertionForEnrollment()](./auth.totpmultifactorgenerator.md#totpmultifactorgeneratorassertionforenrollment) and [TotpMultiFactorGenerator.assertionForSignIn()](./auth.totpmultifactorgenerator.md#totpmultifactorgeneratorassertionforsignin). | | [TotpMultiFactorInfo](./auth.totpmultifactorinfo.md#totpmultifactorinfo_interface) | The subclass of the [MultiFactorInfo](./auth.multifactorinfo.md#multifactorinfo_interface) interface for TOTP second factors. The factorId of this second factor is [FactorId](./auth.md#factorid).TOTP. | | [User](./auth.user.md#user_interface) | A user account. | diff --git a/docs-devsite/auth.refreshidptokenresult.md b/docs-devsite/auth.refreshidptokenresult.md new file mode 100644 index 00000000000..6b57b573d42 --- /dev/null +++ b/docs-devsite/auth.refreshidptokenresult.md @@ -0,0 +1,48 @@ +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 %} + +# RefreshIdpTokenResult interface +The result of a third-party IDP token refresh operation. + +This object contains the new IDP token and the Idp Config ID of the provider that issued it. + +Signature: + +```typescript +export interface RefreshIdpTokenResult +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [idpConfigId](./auth.refreshidptokenresult.md#refreshidptokenresultidpconfigid) | string | The configuration ID of the third-party identity provider. | +| [idToken](./auth.refreshidptokenresult.md#refreshidptokenresultidtoken) | string | The new Id Token from the 3rd party Identity Provider. | + +## RefreshIdpTokenResult.idpConfigId + +The configuration ID of the third-party identity provider. + +Signature: + +```typescript +idpConfigId: string; +``` + +## RefreshIdpTokenResult.idToken + +The new Id Token from the 3rd party Identity Provider. + +Signature: + +```typescript +idToken: string; +``` diff --git a/docs-devsite/auth.tokenrefreshhandler.md b/docs-devsite/auth.tokenrefreshhandler.md new file mode 100644 index 00000000000..ad7bddfefb0 --- /dev/null +++ b/docs-devsite/auth.tokenrefreshhandler.md @@ -0,0 +1,43 @@ +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 %} + +# TokenRefreshHandler interface +An interface for handling the refresh of Firebase tokens. + +Signature: + +```typescript +export interface TokenRefreshHandler +``` + +## Methods + +| Method | Description | +| --- | --- | +| [refreshIdpToken()](./auth.tokenrefreshhandler.md#tokenrefreshhandlerrefreshidptoken) | Refreshes the third-party IDP token.This method should contain the logic to obtain a new, valid IDP token from your identity provider. | + +## TokenRefreshHandler.refreshIdpToken() + +Refreshes the third-party IDP token. + +This method should contain the logic to obtain a new, valid IDP token from your identity provider. + +Signature: + +```typescript +refreshIdpToken(): Promise; +``` +Returns: + +Promise<[RefreshIdpTokenResult](./auth.refreshidptokenresult.md#refreshidptokenresult_interface)> + +A promise that resolves with a `RefreshIdpTokenResult` object containing the new IDP token and its corresponding Idp Config ID. + diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js index fecf29b2540..8ec2e3c48c0 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -149,6 +149,112 @@ async function getActiveUserBlocking() { } } +class RefreshIdpTokenResult { + idpConfigId; + idToken; +} + +class TokenRefreshHandlerImpl { + /** + * Opens a popup to get a 3P ID token and Config ID from the user. + * @returns {Promise} A promise that resolves with the result object. + */ + refreshIdpToken() { + log('inside here'); + console.log('inside handler - opening popup for 3p token'); + + // This function handles the popup logic and returns the required object + return this.promptForTokenAndConfigId(); + } + + /** + * Opens a Bootstrap modal to ask the user for an ID token and IDP Config ID. + * + * This function dynamically creates a modal, shows it, and waits for + * user input. It returns a Promise that resolves or rejects based + * on the user's action. + * + * @returns {Promise} A promise that resolves with the + * RefreshIdpTokenResult object, or rejects if the user cancels. + */ + promptForTokenAndConfigId() { + // We return a Promise that will be resolved/rejected by the modal's buttons + return new Promise((resolve, reject) => { + // A flag to track if the promise has been settled + let isSubmitted = false; + const modalId = 'third-party-token-modal'; + + // 1. Define Modal HTML with two input fields + const modalHtml = ` + + `; + + // 2. Append modal to body and get a jQuery reference + $('body').append(modalHtml); + const $modal = $(`#${modalId}`); + + // 3. Setup Event Handlers + + // Handle Submit button click + $modal.find('#token-submit-btn').on('click', () => { + isSubmitted = true; + + // Read values from *both* input fields + const configId = $modal.find('#idp-config-id-input-field').val(); + const token = $modal.find('#id-token-input-field').val(); + + $modal.modal('hide'); // Hide the modal + + // Create the result object as requested + const result = new RefreshIdpTokenResult(); + result.idpConfigId = configId; + result.idToken = token; + + resolve(result); // Resolve the promise with the object + }); + + // Handle modal being closed (by 'x', 'Cancel' button, backdrop click, or ESC) + $modal.on('hidden.bs.modal', () => { + $modal.remove(); // Clean up the modal from the DOM + + // If the modal was hidden *without* submitting, reject the promise + if (!isSubmitted) { + reject(new Error('User cancelled token input.')); + } + }); + + // 4. Show the modal + $modal.modal('show'); + }); + } +} + /** * Refreshes the current user data in the UI, displaying a user info box if * a user is signed in, or removing it. @@ -1305,7 +1411,6 @@ function onRefreshToken() { function onSignOut() { setLastUser(auth.currentUser); auth.signOut().then(signOut, onAuthError); - regionalAuth.signOut(); } /** @@ -2092,6 +2197,8 @@ function initApp() { popupRedirectResolver: browserPopupRedirectResolver, tenantConfig: tenantConfig }); + const tokenRefreshHandler = new TokenRefreshHandlerImpl(); + regionalAuth.setTokenRefreshHandler(tokenRefreshHandler); const firebaseTokenStatus = document.getElementById('firebase-token-status'); setTimeout(() => { diff --git a/packages/auth/demo/yarn.lock b/packages/auth/demo/yarn.lock index 8e94243b797..0ddb42a96ea 100644 --- a/packages/auth/demo/yarn.lock +++ b/packages/auth/demo/yarn.lock @@ -28,11 +28,11 @@ tslib "^2.1.0" "@firebase/auth@file:..": - version "1.10.7" + version "1.11.0" dependencies: - "@firebase/component" "0.6.17" - "@firebase/logger" "0.4.4" - "@firebase/util" "1.12.0" + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" tslib "^2.1.0" "@firebase/component@0.6.17": @@ -43,6 +43,14 @@ "@firebase/util" "1.12.0" tslib "^2.1.0" +"@firebase/component@0.7.0": + version "0.7.0" + resolved "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz#3736644fdb6d3572dceae7fdc1c35a8bd3819adc" + integrity sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg== + dependencies: + "@firebase/util" "1.13.0" + tslib "^2.1.0" + "@firebase/logger@*", "@firebase/logger@0.4.4": version "0.4.4" resolved "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz#29e8379d20fd1149349a195ee6deee4573a86f48" @@ -50,6 +58,13 @@ dependencies: tslib "^2.1.0" +"@firebase/logger@0.5.0": + version "0.5.0" + resolved "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz#a9e55b1c669a0983dc67127fa4a5964ce8ed5e1b" + integrity sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g== + dependencies: + tslib "^2.1.0" + "@firebase/util@*", "@firebase/util@1.12.0": version "1.12.0" resolved "https://registry.npmjs.org/@firebase/util/-/util-1.12.0.tgz#202e96cfd832f8dde551232e4868861681b8b89a" @@ -57,6 +72,13 @@ dependencies: tslib "^2.1.0" +"@firebase/util@1.13.0": + version "1.13.0" + resolved "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz#2e9e7569722a1e3fc86b1b4076d5cbfbfa7265d6" + integrity sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ== + dependencies: + tslib "^2.1.0" + "@gar/promisify@^1.0.1": version "1.1.3" resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" diff --git a/packages/auth/src/core/auth/auth_impl.test.ts b/packages/auth/src/core/auth/auth_impl.test.ts index 2f2b35aa29a..3ec648a8c47 100644 --- a/packages/auth/src/core/auth/auth_impl.test.ts +++ b/packages/auth/src/core/auth/auth_impl.test.ts @@ -32,7 +32,7 @@ import { testAuth, testUser } from '../../../test/helpers/mock_auth'; -import { AuthInternal } from '../../model/auth'; +import { AuthInternal, FirebaseToken } from '../../model/auth'; import { UserInternal } from '../../model/user'; import { PersistenceInternal } from '../persistence'; import { inMemoryPersistence } from '../persistence/in_memory'; @@ -47,9 +47,10 @@ import { mockEndpointWithParams } from '../../../test/helpers/api/helper'; import { Endpoint, RecaptchaClientType, RecaptchaVersion } from '../../api'; import * as mockFetch from '../../../test/helpers/mock_fetch'; import { AuthErrorCode } from '../errors'; +import * as exchangeTokenModule from '../strategies/exhange_token'; import { - FirebaseToken, - PasswordValidationStatus + PasswordValidationStatus, + TokenRefreshHandler } from '../../model/public_types'; import { PasswordPolicyImpl } from './password_policy_impl'; import { PersistenceUserManager } from '../persistence/persistence_user_manager'; @@ -302,6 +303,136 @@ describe('core/auth/auth_impl', () => { }); }); + describe('#setTokenRefreshHandler', () => { + it('sets the tokenRefreshHandler on the auth object', () => { + const handler: TokenRefreshHandler = { + refreshIdpToken: async () => ({ idToken: 'a', idpConfigId: 'b' }) + }; + auth.setTokenRefreshHandler(handler); + expect((auth as any).tokenRefreshHandler).to.eq(handler); + }); + }); + + describe('#getFirebaseAccessToken', () => { + let exchangeTokenStub: sinon.SinonStub; + let mockToken: FirebaseToken; + let expiredMockToken: FirebaseToken; + let tokenRefreshHandler: TokenRefreshHandler; + const tokenKey = `firebase:persistence-token:${FAKE_APP.options.apiKey!}:${ + FAKE_APP.name + }`; + + beforeEach(() => { + exchangeTokenStub = sinon + .stub(exchangeTokenModule, 'exchangeToken') + .resolves(); + + mockToken = { + token: 'test-token', + expirationTime: Date.now() + 300000 // 5 minutes from now + }; + expiredMockToken = { + token: 'expired-test-token', + expirationTime: Date.now() - 1000 // 1 second ago + }; + tokenRefreshHandler = { + refreshIdpToken: sinon.stub().resolves({ + idToken: 'new-id-token', + idpConfigId: 'test-idp' + }) + }; + // Reset cached token and persistence before each test + (auth as any).firebaseToken = null; + persistenceStub._get.withArgs(tokenKey).resolves(null); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should return the existing token if it is valid', async () => { + persistenceStub._get.withArgs(tokenKey).resolves(mockToken as any); + const token = await auth.getFirebaseAccessToken(); + expect(token).to.eql('test-token'); + expect(exchangeTokenStub).not.to.have.been.called; + }); + + it('should return null if the token is expired and no token refresh handler is set', async () => { + persistenceStub._get.withArgs(tokenKey).resolves(expiredMockToken as any); + const token = await auth.getFirebaseAccessToken(); + expect(token).to.be.null; + expect(exchangeTokenStub).not.to.have.been.called; + }); + + it('should refresh the token if it is expired and a token refresh handler is set', async () => { + persistenceStub._get.withArgs(tokenKey).resolves(expiredMockToken as any); + auth.setTokenRefreshHandler(tokenRefreshHandler); + + exchangeTokenStub.callsFake(async () => { + // When exchangeToken is called, simulate that the new token is persisted. + persistenceStub._get.withArgs(tokenKey).resolves(mockToken as any); + }); + + const token = await auth.getFirebaseAccessToken(); + + expect(tokenRefreshHandler.refreshIdpToken).to.have.been.calledOnce; + expect(exchangeTokenStub).to.have.been.calledWith( + auth, + 'test-idp', + 'new-id-token' + ); + expect(token).to.eql('test-token'); + }); + + it('should force refresh the token when forceRefresh is true', async () => { + persistenceStub._get.withArgs(tokenKey).resolves(mockToken as any); + auth.setTokenRefreshHandler(tokenRefreshHandler); + + exchangeTokenStub.callsFake(async () => { + persistenceStub._get.withArgs(tokenKey).resolves(mockToken as any); + }); + + await auth.getFirebaseAccessToken(true); + + expect(tokenRefreshHandler.refreshIdpToken).to.have.been.calledOnce; + expect(exchangeTokenStub).to.have.been.calledWith( + auth, + 'test-idp', + 'new-id-token' + ); + }); + + it('should return null and log an error if token refresh fails', async () => { + const consoleErrorStub = sinon.stub(console, 'error'); + persistenceStub._get.withArgs(tokenKey).resolves(expiredMockToken as any); + (tokenRefreshHandler.refreshIdpToken as sinon.SinonStub).rejects( + new Error('refresh failed') + ); + auth.setTokenRefreshHandler(tokenRefreshHandler); + const token = await auth.getFirebaseAccessToken(); + expect(token).to.be.null; + expect(consoleErrorStub).to.have.been.calledWith( + 'Token refresh failed:', + sinon.match.instanceOf(Error) + ); + }); + + it('should return null and log an error if the refreshed token is invalid', async () => { + const consoleErrorStub = sinon.stub(console, 'error'); + persistenceStub._get.withArgs(tokenKey).resolves(expiredMockToken as any); + (tokenRefreshHandler.refreshIdpToken as sinon.SinonStub).resolves({ + idToken: 'new-id-token' + }); // Missing idpConfigId + auth.setTokenRefreshHandler(tokenRefreshHandler); + const token = await auth.getFirebaseAccessToken(); + expect(token).to.be.null; + expect(consoleErrorStub).to.have.been.calledWith( + 'Token refresh failed:', + sinon.match.instanceOf(FirebaseError) + ); + }); + }); + describe('#signOut', () => { it('sets currentUser to null, calls remove', async () => { await auth._updateCurrentUser(testUser(auth, 'test')); @@ -318,7 +449,7 @@ describe('core/auth/auth_impl', () => { await regionalAuth._updateFirebaseToken(token); await regionalAuth.signOut(); expect(persistenceStub._remove).to.have.been.called; - expect(regionalAuth.firebaseToken).to.be.null; + expect(await regionalAuth.getFirebaseAccessToken()).to.be.null; }); it('is blocked if a beforeAuthStateChanged callback throws', async () => { await auth._updateCurrentUser(testUser(auth, 'test')); diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index f9e0950def5..67831d7ce35 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -38,7 +38,8 @@ import { Unsubscribe, PasswordValidationStatus, TenantConfig, - FirebaseToken + RefreshIdpTokenResult, + TokenRefreshHandler } from '../../model/public_types'; import { createSubscribe, @@ -49,7 +50,7 @@ import { Subscribe } from '@firebase/util'; -import { AuthInternal, ConfigInternal } from '../../model/auth'; +import { AuthInternal, ConfigInternal, FirebaseToken } from '../../model/auth'; import { PopupRedirectResolverInternal } from '../../model/popup_redirect'; import { UserInternal } from '../../model/user'; import { @@ -85,6 +86,7 @@ import { PasswordPolicyInternal } from '../../model/password_policy'; import { PasswordPolicyImpl } from './password_policy_impl'; import { getAccountInfo } from '../../api/account_management/account'; import { UserImpl } from '../user/user_impl'; +import { exchangeToken } from '../strategies/exhange_token'; interface AsyncAction { (): Promise; @@ -101,7 +103,8 @@ export const enum DefaultConfig { export class AuthImpl implements AuthInternal, _FirebaseService { currentUser: User | null = null; emulatorConfig: EmulatorConfig | null = null; - firebaseToken: FirebaseToken | null = null; + private firebaseToken: FirebaseToken | null = null; + private tokenRefreshHandler?: TokenRefreshHandler; private operations = Promise.resolve(); private persistenceManager?: PersistenceUserManager; private redirectPersistenceManager?: PersistenceUserManager; @@ -112,6 +115,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { private redirectUser: UserInternal | null = null; private isProactiveRefreshEnabled = false; private readonly EXPECTED_PASSWORD_POLICY_SCHEMA_VERSION: number = 1; + private readonly TOKEN_EXPIRATION_BUFFER = 30_000; // Any network calls will set this to true and prevent subsequent emulator // initialization @@ -210,6 +214,10 @@ export class AuthImpl implements AuthInternal, _FirebaseService { return this._initializationPromise; } + setTokenRefreshHandler(tokenRefreshHandler: TokenRefreshHandler): void { + this.tokenRefreshHandler = tokenRefreshHandler; + } + /** * If the persistence is changed in another window, the user manager will let us know */ @@ -240,6 +248,42 @@ export class AuthImpl implements AuthInternal, _FirebaseService { await this._updateCurrentUser(user, /* skipBeforeStateCallbacks */ true); } + async getFirebaseAccessToken(forceRefresh?: boolean): Promise { + const firebaseAccessToken = + (await this.persistenceManager?.getFirebaseToken()) ?? null; + + if ( + firebaseAccessToken && + this.isFirebaseAccessTokenValid(firebaseAccessToken) && + !forceRefresh + ) { + this.firebaseToken = firebaseAccessToken; + this.firebaseTokenSubscription.next(this.firebaseToken); + return firebaseAccessToken.token; + } + + if (firebaseAccessToken && this.tokenRefreshHandler) { + // Resets the Firebase Access Token to null i.e. logs out the user. + await this._updateFirebaseToken(null); + try { + // Awaits for the callback method to execute. The callback method + // is responsible for performing the exchangeToken(auth, valid3pIdpToken) + const result: RefreshIdpTokenResult = + await this.tokenRefreshHandler.refreshIdpToken(); + _assert( + result.idToken && result.idpConfigId, + AuthErrorCode.INVALID_CREDENTIAL + ); + await exchangeToken(this, result.idpConfigId, result.idToken); + return this.getFirebaseAccessToken(false); + } catch (error) { + console.error('Token refresh failed:', error); + return null; + } + } + return null; + } + private async initializeCurrentUserFromIdToken( idToken: string ): Promise { @@ -405,6 +449,18 @@ export class AuthImpl implements AuthInternal, _FirebaseService { return this.directlySetCurrentUser(user); } + private isFirebaseAccessTokenValid( + firebaseToken: FirebaseToken | null + ): boolean { + if (!firebaseToken || !firebaseToken.expirationTime) { + return false; + } + + return ( + Date.now() < firebaseToken.expirationTime - this.TOKEN_EXPIRATION_BUFFER + ); + } + private async initializeFirebaseToken(): Promise { this.firebaseToken = (await this.persistenceManager?.getFirebaseToken()) ?? null; diff --git a/packages/auth/src/core/auth/firebase_internal.test.ts b/packages/auth/src/core/auth/firebase_internal.test.ts index 4213d720385..6932b436427 100644 --- a/packages/auth/src/core/auth/firebase_internal.test.ts +++ b/packages/auth/src/core/auth/firebase_internal.test.ts @@ -18,14 +18,18 @@ import { FirebaseError } from '@firebase/util'; import { expect, use } from 'chai'; import * as sinon from 'sinon'; +import * as mockFetch from '../../../test/helpers/mock_fetch'; import sinonChai from 'sinon-chai'; import chaiAsPromised from 'chai-as-promised'; import { regionalTestAuth, + regionalTestAuthWithTokenRefreshHandler, testAuth, testUser } from '../../../test/helpers/mock_auth'; +import { RegionalEndpoint } from '../../api'; +import { mockRegionalEndpointWithParent } from '../../../test/helpers/api/helper'; import { AuthInternal } from '../../model/auth'; import { UserInternal } from '../../model/user'; import { AuthInterop } from './firebase_internal'; @@ -227,17 +231,32 @@ describe('core/auth/firebase_internal', () => { describe('core/auth/firebase_internal - Regional Firebase Auth', () => { let regionalAuth: AuthInternal; + let regionalAuthWithRefreshToken: AuthInternal; let regionalAuthInternal: AuthInterop; + let regionalAuthWithRefreshTokenInternal: AuthInterop; let now: number; beforeEach(async () => { regionalAuth = await regionalTestAuth(); regionalAuthInternal = new AuthInterop(regionalAuth); + regionalAuthWithRefreshToken = + await regionalTestAuthWithTokenRefreshHandler(); + regionalAuthWithRefreshTokenInternal = new AuthInterop( + regionalAuthWithRefreshToken + ); now = Date.now(); sinon.stub(Date, 'now').returns(now); + mockFetch.setUp(); + mockRegionalEndpointWithParent( + RegionalEndpoint.EXCHANGE_TOKEN, + 'projects/test-project-id/locations/us/tenants/tenant-1/idpConfigs/idp-config', + 'test-api-key', + { accessToken: 'access-token-new', expiresIn: 10_000 } + ); }); afterEach(() => { sinon.restore(); + mockFetch.tearDown(); }); context('getFirebaseToken', () => { @@ -265,7 +284,8 @@ describe('core/auth/firebase_internal - Regional Firebase Auth', () => { expirationTime: now - 5_000 }); expect(await regionalAuthInternal.getToken()).to.null; - expect(regionalAuth.firebaseToken).to.null; + const firebaseToken = await regionalAuth.getFirebaseAccessToken(); + expect(firebaseToken).to.null; }); it('logs out if token is expiring in next 5 seconds', async () => { @@ -274,24 +294,36 @@ describe('core/auth/firebase_internal - Regional Firebase Auth', () => { expirationTime: now + 5_000 }); expect(await regionalAuthInternal.getToken()).to.null; - expect(regionalAuth.firebaseToken).to.null; + const firebaseToken = await regionalAuth.getFirebaseAccessToken(); + expect(firebaseToken).to.null; }); - it('logs warning if getToken is called with forceRefresh true', async () => { - sinon.stub(console, 'warn'); - await regionalAuth._updateFirebaseToken({ + it('returns refreshIdToken if getToken is called with forceRefresh true', async () => { + await regionalAuthWithRefreshToken._updateFirebaseToken({ token: 'access-token', - expirationTime: now + 300_000 + expirationTime: now + 30_000 }); - expect(await regionalAuthInternal.getToken(true)).to.eql({ - accessToken: 'access-token' + expect(await regionalAuthWithRefreshTokenInternal.getToken(true)).to.eql({ + accessToken: 'access-token-new' }); - expect(console.warn).to.have.been.calledWith( - sinon.match.string, - sinon.match( - /Refresh token is not a valid operation for Regional Auth instance initialized\./ - ) - ); + }); + + it('returns refreshIdToken if current idToken is expired', async () => { + await regionalAuthWithRefreshToken._updateFirebaseToken({ + token: 'access-token', + expirationTime: now - 5_000 + }); + expect(await regionalAuthWithRefreshTokenInternal.getToken()).to.eql({ + accessToken: 'access-token-new' + }); + }); + + it('returns null if current idToken is expired and tokenRefreshHandler is not implemented', async () => { + await regionalAuth._updateFirebaseToken({ + token: 'access-token', + expirationTime: now - 5_000 + }); + expect(await regionalAuthInternal.getToken()).to.eql(null); }); }); }); diff --git a/packages/auth/src/core/auth/firebase_internal.ts b/packages/auth/src/core/auth/firebase_internal.ts index caf06c49f6a..4396283bb5f 100644 --- a/packages/auth/src/core/auth/firebase_internal.ts +++ b/packages/auth/src/core/auth/firebase_internal.ts @@ -22,7 +22,6 @@ 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; @@ -46,12 +45,8 @@ export class AuthInterop implements FirebaseAuthInternal { 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(); + const accessToken = await this.getTokenForRegionalAuth(forceRefresh); + return accessToken ? { accessToken } : null; } if (!this.auth.currentUser) { return null; @@ -103,23 +98,11 @@ export class AuthInterop implements FirebaseAuthInternal { } } - 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; - } + private async getTokenForRegionalAuth( + forceRefresh?: boolean + ): Promise { + const firebaseToken = await this.auth.getFirebaseAccessToken(forceRefresh); - const accessToken = await this.auth.firebaseToken.token; - return { accessToken }; + return firebaseToken; } } diff --git a/packages/auth/src/core/persistence/persistence_user_manager.ts b/packages/auth/src/core/persistence/persistence_user_manager.ts index 3981a60efe1..21c230bf9e5 100644 --- a/packages/auth/src/core/persistence/persistence_user_manager.ts +++ b/packages/auth/src/core/persistence/persistence_user_manager.ts @@ -16,8 +16,7 @@ */ import { getAccountInfo } from '../../api/account_management/account'; -import { ApiKey, AppName, AuthInternal } from '../../model/auth'; -import { FirebaseToken } from '../../model/public_types'; +import { ApiKey, AppName, AuthInternal, FirebaseToken } from '../../model/auth'; import { UserInternal } from '../../model/user'; import { PersistedBlob, PersistenceInternal } from '../persistence'; import { UserImpl } from '../user/user_impl'; diff --git a/packages/auth/src/core/strategies/exchange_token.test.ts b/packages/auth/src/core/strategies/exchange_token.test.ts index ab05e0985b1..e284f26f1d4 100644 --- a/packages/auth/src/core/strategies/exchange_token.test.ts +++ b/packages/auth/src/core/strategies/exchange_token.test.ts @@ -53,7 +53,7 @@ describe('core/strategies/exchangeToken', () => { RegionalEndpoint.EXCHANGE_TOKEN, 'projects/test-project-id/locations/us/tenants/tenant-1/idpConfigs/idp-config', 'test-api-key', - { accessToken: 'outbound-token', expiresIn: 10 } + { accessToken: 'outbound-token', expiresIn: 10_000 } ); const accessToken = await exchangeToken( @@ -72,8 +72,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); + const firebaseToken = await regionalAuth.getFirebaseAccessToken(); + expect(firebaseToken).to.equal('outbound-token'); }); it('throws exception for default Auth', async () => { @@ -117,6 +117,7 @@ describe('core/strategies/exchangeToken', () => { expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( 'application/json' ); - expect(regionalAuth.firebaseToken).is.null; + const accessToken = await regionalAuth.getFirebaseAccessToken(); + expect(accessToken).is.null; }); }); diff --git a/packages/auth/src/model/auth.ts b/packages/auth/src/model/auth.ts index ea9f4b203d2..5d77f0f1fa6 100644 --- a/packages/auth/src/model/auth.ts +++ b/packages/auth/src/model/auth.ts @@ -20,7 +20,6 @@ import { AuthSettings, Config, EmulatorConfig, - FirebaseToken, PasswordPolicy, PasswordValidationStatus, PopupRedirectResolver, @@ -58,6 +57,16 @@ export interface ConfigInternal extends Config { clientPlatform: ClientPlatform; } +/** + * @internal + */ +export interface FirebaseToken { + // The firebase access token (JWT signed by Firebase Auth). + readonly token: string; + // The time in milliseconds when the access token expires. + readonly expirationTime: number; +} + /** * UserInternal and AuthInternal reference each other, so both of them are included in the public typings. * In order to exclude them, we mark them as internal explicitly. @@ -67,6 +76,7 @@ export interface ConfigInternal extends Config { export interface AuthInternal extends Auth { currentUser: User | null; emulatorConfig: EmulatorConfig | null; + getFirebaseAccessToken(forceRefresh?: boolean): Promise; _agentRecaptchaConfig: RecaptchaConfig | null; _tenantRecaptchaConfigs: Record; _projectPasswordPolicy: PasswordPolicy | null; diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index 1643d8f0997..4ebce229f12 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -118,6 +118,44 @@ export interface ParsedToken { */ export type NextOrObserver = NextFn | Observer; +/** + * An interface for handling the refresh of Firebase tokens. + * + * @public + */ +export interface TokenRefreshHandler { + /** + * Refreshes the third-party IDP token. + * + * This method should contain the logic to obtain a new, valid IDP token from + * your identity provider. + * + * @returns A promise that resolves with a `RefreshIdpTokenResult` object + * containing the new IDP token and its corresponding Idp Config ID. + */ + refreshIdpToken(): Promise; +} + +/** + * The result of a third-party IDP token refresh operation. + * + * This object contains the new IDP token and the Idp Config ID of the + * provider that issued it. + * + * @public + */ +export interface RefreshIdpTokenResult { + /** + * The configuration ID of the third-party identity provider. + */ + idpConfigId: string; + + /** + * The new Id Token from the 3rd party Identity Provider. + */ + idToken: string; +} + /** * Interface for an `Auth` error. * @@ -210,6 +248,32 @@ export interface Auth { * @param persistence - The {@link Persistence} to use. */ setPersistence(persistence: Persistence): Promise; + /** + * Registers a handler for refreshing third-party identity provider (IDP) tokens. + * + * When the Firebase access token is expired, the SDK will automatically invoke the + * provided handler's `refreshIdpToken()` method to obtain a new IDP token. This new + * token will then be exchanged for a fresh Firebase token, streamlining the + * authentication process. + * + * @example + * ```javascript + * class TokenRefreshHandlerImpl { + * refreshIdpToken() { + * // Logic to fetch a new token from your custom IDP. + * // Returns a Promise that resolves with a RefreshIdpTokenResult. + * } + * } + * + * const tokenRefreshHandler = new TokenRefreshHandlerImpl(); + * auth.setTokenRefreshHandler(tokenRefreshHandler); + * ``` + * + * @param tokenRefreshHandler - An object that implements the `TokenRefreshHandler` + * interface, providing the logic to refresh the IDP token. + */ + setTokenRefreshHandler(tokenRefreshHandler: TokenRefreshHandler): void; + /** * The {@link Auth} instance's language code. * @@ -334,14 +398,6 @@ export interface Auth { * {@link @firebase/app#FirebaseServerApp}. */ signOut(): Promise; - /** - * 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; } /** @@ -974,13 +1030,6 @@ export interface ReactNativeAsyncStorage { removeItem(key: string): Promise; } -export interface FirebaseToken { - // The firebase access token (JWT signed by Firebase Auth). - readonly token: string; - // The time in milliseconds when the access token expires. - readonly expirationTime: number; -} - /** * A user account. * diff --git a/packages/auth/test/helpers/mock_auth.ts b/packages/auth/test/helpers/mock_auth.ts index dee18047fde..9c114331bad 100644 --- a/packages/auth/test/helpers/mock_auth.ts +++ b/packages/auth/test/helpers/mock_auth.ts @@ -18,7 +18,11 @@ import { FirebaseApp } from '@firebase/app'; import { Provider } from '@firebase/component'; import { AppCheckTokenResult } from '@firebase/app-check-interop-types'; -import { PopupRedirectResolver } from '../../src/model/public_types'; +import { + PopupRedirectResolver, + TokenRefreshHandler, + RefreshIdpTokenResult +} from '../../src/model/public_types'; import { debugErrorMap } from '../../src'; import { AuthImpl } from '../../src/core/auth/auth_impl'; @@ -118,6 +122,30 @@ export async function testAuth( return auth; } +class TokenRefreshHandlerImpl implements TokenRefreshHandler { + async refreshIdpToken(): Promise { + // Fetch the token for their custom Idp configured... + return { + idToken: 'new-token', // The new token string + idpConfigId: 'idp-config' // The ID for your IdP + }; + } +} + +export async function regionalTestAuthWithTokenRefreshHandler( + popupRedirectResolver?: PopupRedirectResolver, + persistence = new MockPersistenceLayer(), + skipAwaitOnInit?: boolean +): Promise { + const regionalAuth = await regionalTestAuth( + popupRedirectResolver, + persistence, + skipAwaitOnInit + ); + regionalAuth.setTokenRefreshHandler(new TokenRefreshHandlerImpl()); + return regionalAuth; +} + export async function regionalTestAuth( popupRedirectResolver?: PopupRedirectResolver, persistence = new MockPersistenceLayer(),