diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 8603b7a8ec5..0b1e7d0fb76 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -725,6 +725,9 @@ export class RecaptchaVerifier implements ApplicationVerifierInternal { // @public export function reload(user: User): Promise; +// @public +export function revokeAccessToken(auth: Auth, token: string): Promise; + // Warning: (ae-forgotten-export) The symbol "FederatedAuthProvider" needs to be exported by the entry point index.d.ts // // @public diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 6ff6f007484..dcbe71e80c4 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -35,6 +35,7 @@ Firebase Authentication | [isSignInWithEmailLink(auth, emailLink)](./auth.md#issigninwithemaillink) | Checks if an incoming link is a sign-in with email link suitable for [signInWithEmailLink()](./auth.md#signinwithemaillink). | | [onAuthStateChanged(auth, nextOrObserver, error, completed)](./auth.md#onauthstatechanged) | Adds an observer for changes to the user's sign-in state. | | [onIdTokenChanged(auth, nextOrObserver, error, completed)](./auth.md#onidtokenchanged) | Adds an observer for changes to the signed-in user's ID token. | +| [revokeAccessToken(auth, token)](./auth.md#revokeaccesstoken) | Revokes the given access token. Currently only supports Apple OAuth Access token. | | [sendPasswordResetEmail(auth, email, actionCodeSettings)](./auth.md#sendpasswordresetemail) | Sends a password reset email to the given email address. | | [sendSignInLinkToEmail(auth, email, actionCodeSettings)](./auth.md#sendsigninlinktoemail) | Sends a sign-in email link to the user with the specified email. | | [setPersistence(auth, persistence)](./auth.md#setpersistence) | Changes the type of persistence on the [Auth](./auth.auth.md#auth_interface) instance for the currently saved Auth session and applies this type of persistence for future sign-in requests, including sign-in with redirect requests. | @@ -598,6 +599,27 @@ export declare function onIdTokenChanged(auth: Auth, nextOrObserver: NextOrObser [Unsubscribe](./util.md#unsubscribe) +## revokeAccessToken() + +Revokes the given access token. Currently only supports Apple OAuth Access token. + +Signature: + +```typescript +export declare function revokeAccessToken(auth: Auth, token: string): Promise; +``` + +### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| auth | [Auth](./auth.auth.md#auth_interface) | | +| token | string | | + +Returns: + +Promise<void> + ## sendPasswordResetEmail() Sends a password reset email to the given email address. diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js index c0ac8110135..4fb52ebcd89 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -73,7 +73,8 @@ import { browserPopupRedirectResolver, connectAuthEmulator, initializeRecaptchaConfig, - validatePassword + validatePassword, + revokeAccessToken } from '@firebase/auth'; import { config } from './config'; @@ -1730,13 +1731,58 @@ function logAdditionalUserInfo(response) { * Deletes the user account. */ function onDelete() { - activeUser() - ['delete']() - .then(() => { - log('User successfully deleted.'); - alertSuccess('User successfully deleted.'); - refreshUserData(); - }, onAuthError); + var isAppleProviderLinked = false; + + for (const provider of activeUser().providerData) { + if (provider.providerId == 'apple.com') { + isAppleProviderLinked = true; + break; + } + } + + if (isAppleProviderLinked) { + revokeAppleTokenAndDeleteUser(); + } else { + activeUser() + ['delete']() + .then(() => { + log('User successfully deleted.'); + alertSuccess('User successfully deleted.'); + refreshUserData(); + }, onAuthError); + } +} + +function revokeAppleTokenAndDeleteUser() { + // Re-auth then revoke the token + const provider = new OAuthProvider('apple.com'); + provider.addScope('email'); + provider.addScope('name'); + + const auth = getAuth(); + signInWithPopup(auth, provider).then(result => { + // The signed-in user info. + const user = result.user; + const credential = OAuthProvider.credentialFromResult(result); + const accessToken = credential.accessToken; + + revokeAccessToken(auth, accessToken) + .then(() => { + log('Token successfully revoked.'); + + // Usual user deletion + activeUser() + ['delete']() + .then(() => { + log('User successfully deleted.'); + alertSuccess('User successfully deleted.'); + refreshUserData(); + }, onAuthError); + }) + .catch(error => { + console.log(error.message); + }); + }); } /** diff --git a/packages/auth/src/api/authentication/token.test.ts b/packages/auth/src/api/authentication/token.test.ts index 51f88e4fed7..8fda7604253 100644 --- a/packages/auth/src/api/authentication/token.test.ts +++ b/packages/auth/src/api/authentication/token.test.ts @@ -21,11 +21,12 @@ import chaiAsPromised from 'chai-as-promised'; import { FirebaseError, getUA, querystringDecode } from '@firebase/util'; -import { HttpHeader } from '../'; +import { Endpoint, HttpHeader } from '../'; +import { mockEndpoint } from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as fetch from '../../../test/helpers/mock_fetch'; import { ServerError } from '../errors'; -import { Endpoint, requestStsToken } from './token'; +import { TokenType, requestStsToken, revokeToken } from './token'; import { SDK_VERSION } from '@firebase/app'; import { _getBrowserName } from '../../core/util/browser'; @@ -143,3 +144,63 @@ describe('requestStsToken', () => { }); }); }); + +describe('api/authentication/revokeToken', () => { + const request = { + providerId: 'provider-id', + tokenType: TokenType.ACCESS_TOKEN, + token: 'token', + idToken: 'id-token' + }; + + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + fetch.setUp(); + }); + + afterEach(() => { + fetch.tearDown(); + }); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.REVOKE_TOKEN, {}); + + auth.tenantId = 'tenant-id'; + await revokeToken(auth, request); + // Currently, backend returns an empty response. + expect(mock.calls[0].request).to.eql({ ...request, tenantId: 'tenant-id' }); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( + 'application/json' + ); + expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq( + 'testSDK/0.0.0' + ); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.REVOKE_TOKEN, + { + error: { + code: 400, + message: ServerError.INVALID_IDP_RESPONSE, + errors: [ + { + message: ServerError.INVALID_IDP_RESPONSE + } + ] + } + }, + 400 + ); + + await expect(revokeToken(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The supplied auth credential is malformed or has expired. (auth/invalid-credential).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages/auth/src/api/authentication/token.ts b/packages/auth/src/api/authentication/token.ts index 60eb9b7068f..06342c4c633 100644 --- a/packages/auth/src/api/authentication/token.ts +++ b/packages/auth/src/api/authentication/token.ts @@ -22,15 +22,19 @@ import { querystring } from '@firebase/util'; import { _getFinalTarget, _performFetchWithErrorHandling, + _performApiRequest, + _addTidIfNecessary, HttpMethod, - HttpHeader + HttpHeader, + Endpoint } from '../index'; import { FetchProvider } from '../../core/util/fetch_provider'; import { Auth } from '../../model/public_types'; import { AuthInternal } from '../../model/auth'; -export const enum Endpoint { - TOKEN = '/v1/token' +export const enum TokenType { + REFRESH_TOKEN = 'REFRESH_TOKEN', + ACCESS_TOKEN = 'ACCESS_TOKEN' } /** The server responses with snake_case; we convert to camelCase */ @@ -46,6 +50,16 @@ export interface RequestStsTokenResponse { refreshToken: string; } +export interface RevokeTokenRequest { + providerId: string; + tokenType: TokenType; + token: string; + idToken: string; + tenantId?: string; +} + +export interface RevokeTokenResponse {} + export async function requestStsToken( auth: Auth, refreshToken: string @@ -85,3 +99,15 @@ export async function requestStsToken( refreshToken: response.refresh_token }; } + +export async function revokeToken( + auth: Auth, + request: RevokeTokenRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.REVOKE_TOKEN, + _addTidIfNecessary(auth, request) + ); +} diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index 4a17173099f..2329ea96ec4 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -68,7 +68,9 @@ export const enum Endpoint { WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw', GET_PROJECT_CONFIG = '/v1/projects', GET_RECAPTCHA_CONFIG = '/v2/recaptchaConfig', - GET_PASSWORD_POLICY = '/v2/passwordPolicy' + GET_PASSWORD_POLICY = '/v2/passwordPolicy', + TOKEN = '/v1/token', + REVOKE_TOKEN = '/v2/accounts:revokeToken' } export const enum RecaptchaClientType { diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index a402928ca99..db48642812a 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -63,6 +63,11 @@ import { _getInstance } from '../util/instantiator'; import { _getUserLanguage } from '../util/navigator'; import { _getClientVersion } from '../util/version'; import { HttpHeader } from '../../api'; +import { + RevokeTokenRequest, + TokenType, + revokeToken +} from '../../api/authentication/token'; import { AuthMiddlewareQueue } from './middleware'; import { RecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha'; import { _logWarn } from '../util/log'; @@ -514,6 +519,26 @@ export class AuthImpl implements AuthInternal, _FirebaseService { }); } + /** + * Revokes the given access token. Currently only supports Apple OAuth Access token. + */ + async revokeAccessToken(token: string): Promise { + if (this.currentUser) { + const idToken = await this.currentUser.getIdToken(); + // Generalize this to accept other providers once supported. + const request: RevokeTokenRequest = { + providerId: 'apple.com', + tokenType: TokenType.ACCESS_TOKEN, + token, + idToken + }; + if (this.tenantId != null) { + request.tenantId = this.tenantId; + } + await revokeToken(this, request); + } + } + toJSON(): object { return { apiKey: this.config.apiKey, diff --git a/packages/auth/src/core/index.ts b/packages/auth/src/core/index.ts index 4718ba818a9..14fbcc9d487 100644 --- a/packages/auth/src/core/index.ts +++ b/packages/auth/src/core/index.ts @@ -245,6 +245,14 @@ export function signOut(auth: Auth): Promise { return getModularInstance(auth).signOut(); } +/** + * Revokes the given access token. Currently only supports Apple OAuth Access token. + */ +export function revokeAccessToken(auth: Auth, token: string): Promise { + const authInternal = _castAuth(auth); + return authInternal.revokeAccessToken(token); +} + export { initializeAuth } from './auth/initialize'; export { connectAuthEmulator } from './auth/emulator'; diff --git a/packages/auth/src/core/user/token_manager.test.ts b/packages/auth/src/core/user/token_manager.test.ts index 2baf992d0a7..e1648d4eb32 100644 --- a/packages/auth/src/core/user/token_manager.test.ts +++ b/packages/auth/src/core/user/token_manager.test.ts @@ -23,11 +23,11 @@ import { FirebaseError } from '@firebase/util'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as fetch from '../../../test/helpers/mock_fetch'; -import { Endpoint } from '../../api/authentication/token'; import { IdTokenResponse } from '../../model/id_token'; import { StsTokenManager, Buffer } from './token_manager'; import { FinalizeMfaResponse } from '../../api/authentication/mfa'; import { makeJWT } from '../../../test/helpers/jwt'; +import { Endpoint } from '../../api'; use(chaiAsPromised); diff --git a/packages/auth/src/model/auth.ts b/packages/auth/src/model/auth.ts index 818867dd302..a456b255788 100644 --- a/packages/auth/src/model/auth.ts +++ b/packages/auth/src/model/auth.ts @@ -105,4 +105,5 @@ export interface AuthInternal extends Auth { useDeviceLanguage(): void; signOut(): Promise; validatePassword(password: string): Promise; + revokeAccessToken(token: string): Promise; }