Skip to content

Commit d491c11

Browse files
NhienLamprameshj
authored andcommitted
Implement token revocation public api (#7745)
* Implement revokeAccessToken public api (#7541) * Implement revokeAccessToken * Fix token unit tests * Move ENDPOINT.TOKEN to /api/index.ts * Add unit tests * Set tenantId and remove redirectUri * Run doc generation and fix Lint errors * Remove unspecified token type * Clean up demo app * Add changeset * Update changeset * Fix refdoc comments * Run yarn docgen devsite
1 parent ccd3fe4 commit d491c11

File tree

11 files changed

+224
-15
lines changed

11 files changed

+224
-15
lines changed

.changeset/eight-planets-sleep.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@firebase/auth': minor
3+
'firebase': minor
4+
---
5+
6+
[feature] Add sign-in with Apple token revocation support.

common/api-review/auth.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,9 @@ export class RecaptchaVerifier implements ApplicationVerifierInternal {
726726
// @public
727727
export function reload(user: User): Promise<void>;
728728

729+
// @public
730+
export function revokeAccessToken(auth: Auth, token: string): Promise<void>;
731+
729732
// Warning: (ae-forgotten-export) The symbol "FederatedAuthProvider" needs to be exported by the entry point index.d.ts
730733
//
731734
// @public

docs-devsite/auth.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ Firebase Authentication
3535
| [isSignInWithEmailLink(auth, emailLink)](./auth.md#issigninwithemaillink) | Checks if an incoming link is a sign-in with email link suitable for [signInWithEmailLink()](./auth.md#signinwithemaillink)<!-- -->. |
3636
| [onAuthStateChanged(auth, nextOrObserver, error, completed)](./auth.md#onauthstatechanged) | Adds an observer for changes to the user's sign-in state. |
3737
| [onIdTokenChanged(auth, nextOrObserver, error, completed)](./auth.md#onidtokenchanged) | Adds an observer for changes to the signed-in user's ID token. |
38+
<<<<<<< HEAD
3839
| [sendPasswordResetEmail(auth, email, actionCodeSettings)](./auth.md#sendpasswordresetemail) | Sends a password reset email to the given email address. This method does not throw an error when \[Email Enumeration Protection\](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) is enabled. |
40+
=======
41+
| [revokeAccessToken(auth, token)](./auth.md#revokeaccesstoken) | Revokes the given access token. Currently only supports Apple OAuth access tokens. |
42+
| [sendPasswordResetEmail(auth, email, actionCodeSettings)](./auth.md#sendpasswordresetemail) | Sends a password reset email to the given email address. |
43+
>>>>>>> 5f496e401 (Implement token revocation public api (#7745))
3944
| [sendSignInLinkToEmail(auth, email, actionCodeSettings)](./auth.md#sendsigninlinktoemail) | Sends a sign-in email link to the user with the specified email. |
4045
| [setPersistence(auth, persistence)](./auth.md#setpersistence) | Changes the type of persistence on the [Auth](./auth.auth.md#auth_interface) instance for the currently saved <code>Auth</code> session and applies this type of persistence for future sign-in requests, including sign-in with redirect requests. |
4146
| [signInAnonymously(auth)](./auth.md#signinanonymously) | Asynchronously signs in as an anonymous user. |
@@ -598,6 +603,27 @@ export declare function onIdTokenChanged(auth: Auth, nextOrObserver: NextOrObser
598603

599604
[Unsubscribe](./util.md#unsubscribe)
600605

606+
## revokeAccessToken()
607+
608+
Revokes the given access token. Currently only supports Apple OAuth access tokens.
609+
610+
<b>Signature:</b>
611+
612+
```typescript
613+
export declare function revokeAccessToken(auth: Auth, token: string): Promise<void>;
614+
```
615+
616+
### Parameters
617+
618+
| Parameter | Type | Description |
619+
| --- | --- | --- |
620+
| auth | [Auth](./auth.auth.md#auth_interface) | The [Auth](./auth.auth.md#auth_interface) instance. |
621+
| token | string | The Apple OAuth access token. |
622+
623+
<b>Returns:</b>
624+
625+
Promise&lt;void&gt;
626+
601627
## sendPasswordResetEmail()
602628

603629
Sends a password reset email to the given email address. This method does not throw an error when \[Email Enumeration Protection\](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) is enabled.

packages/auth/demo/src/index.js

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ import {
7373
browserPopupRedirectResolver,
7474
connectAuthEmulator,
7575
initializeRecaptchaConfig,
76-
validatePassword
76+
validatePassword,
77+
revokeAccessToken
7778
} from '@firebase/auth';
7879

7980
import { config } from './config';
@@ -1733,13 +1734,58 @@ function logAdditionalUserInfo(response) {
17331734
* Deletes the user account.
17341735
*/
17351736
function onDelete() {
1736-
activeUser()
1737-
['delete']()
1738-
.then(() => {
1739-
log('User successfully deleted.');
1740-
alertSuccess('User successfully deleted.');
1741-
refreshUserData();
1742-
}, onAuthError);
1737+
let isAppleProviderLinked = false;
1738+
1739+
for (const provider of activeUser().providerData) {
1740+
if (provider.providerId == 'apple.com') {
1741+
isAppleProviderLinked = true;
1742+
break;
1743+
}
1744+
}
1745+
1746+
if (isAppleProviderLinked) {
1747+
revokeAppleTokenAndDeleteUser();
1748+
} else {
1749+
activeUser()
1750+
['delete']()
1751+
.then(() => {
1752+
log('User successfully deleted.');
1753+
alertSuccess('User successfully deleted.');
1754+
refreshUserData();
1755+
}, onAuthError);
1756+
}
1757+
}
1758+
1759+
function revokeAppleTokenAndDeleteUser() {
1760+
// Re-auth then revoke the token
1761+
const provider = new OAuthProvider('apple.com');
1762+
provider.addScope('email');
1763+
provider.addScope('name');
1764+
1765+
const auth = getAuth();
1766+
signInWithPopup(auth, provider).then(result => {
1767+
// The signed-in user info.
1768+
const credential = OAuthProvider.credentialFromResult(result);
1769+
const accessToken = credential.accessToken;
1770+
1771+
revokeAccessToken(auth, accessToken)
1772+
.then(() => {
1773+
log('Token successfully revoked.');
1774+
1775+
// Usual user deletion
1776+
activeUser()
1777+
['delete']()
1778+
.then(() => {
1779+
log('User successfully deleted.');
1780+
alertSuccess('User successfully deleted.');
1781+
refreshUserData();
1782+
}, onAuthError);
1783+
})
1784+
.catch(error => {
1785+
log('Failed to revoke token. ', error.message);
1786+
alertError('Failed to revoke token. ', error.message);
1787+
});
1788+
});
17431789
}
17441790

17451791
/**

packages/auth/src/api/authentication/token.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import chaiAsPromised from 'chai-as-promised';
2121

2222
import { FirebaseError, getUA, querystringDecode } from '@firebase/util';
2323

24-
import { HttpHeader } from '../';
24+
import { Endpoint, HttpHeader } from '../';
25+
import { mockEndpoint } from '../../../test/helpers/api/helper';
2526
import { testAuth, TestAuth } from '../../../test/helpers/mock_auth';
2627
import * as fetch from '../../../test/helpers/mock_fetch';
2728
import { ServerError } from '../errors';
28-
import { Endpoint, requestStsToken } from './token';
29+
import { TokenType, requestStsToken, revokeToken } from './token';
2930
import { SDK_VERSION } from '@firebase/app';
3031
import { _getBrowserName } from '../../core/util/browser';
3132

@@ -143,3 +144,63 @@ describe('requestStsToken', () => {
143144
});
144145
});
145146
});
147+
148+
describe('api/authentication/revokeToken', () => {
149+
const request = {
150+
providerId: 'provider-id',
151+
tokenType: TokenType.ACCESS_TOKEN,
152+
token: 'token',
153+
idToken: 'id-token'
154+
};
155+
156+
let auth: TestAuth;
157+
158+
beforeEach(async () => {
159+
auth = await testAuth();
160+
fetch.setUp();
161+
});
162+
163+
afterEach(() => {
164+
fetch.tearDown();
165+
});
166+
167+
it('should POST to the correct endpoint', async () => {
168+
const mock = mockEndpoint(Endpoint.REVOKE_TOKEN, {});
169+
170+
auth.tenantId = 'tenant-id';
171+
await revokeToken(auth, request);
172+
// Currently, backend returns an empty response.
173+
expect(mock.calls[0].request).to.eql({ ...request, tenantId: 'tenant-id' });
174+
expect(mock.calls[0].method).to.eq('POST');
175+
expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq(
176+
'application/json'
177+
);
178+
expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq(
179+
'testSDK/0.0.0'
180+
);
181+
});
182+
183+
it('should handle errors', async () => {
184+
const mock = mockEndpoint(
185+
Endpoint.REVOKE_TOKEN,
186+
{
187+
error: {
188+
code: 400,
189+
message: ServerError.INVALID_IDP_RESPONSE,
190+
errors: [
191+
{
192+
message: ServerError.INVALID_IDP_RESPONSE
193+
}
194+
]
195+
}
196+
},
197+
400
198+
);
199+
200+
await expect(revokeToken(auth, request)).to.be.rejectedWith(
201+
FirebaseError,
202+
'Firebase: The supplied auth credential is malformed or has expired. (auth/invalid-credential).'
203+
);
204+
expect(mock.calls[0].request).to.eql(request);
205+
});
206+
});

packages/auth/src/api/authentication/token.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,19 @@ import { querystring } from '@firebase/util';
2222
import {
2323
_getFinalTarget,
2424
_performFetchWithErrorHandling,
25+
_performApiRequest,
26+
_addTidIfNecessary,
2527
HttpMethod,
26-
HttpHeader
28+
HttpHeader,
29+
Endpoint
2730
} from '../index';
2831
import { FetchProvider } from '../../core/util/fetch_provider';
2932
import { Auth } from '../../model/public_types';
3033
import { AuthInternal } from '../../model/auth';
3134

32-
export const enum Endpoint {
33-
TOKEN = '/v1/token'
35+
export const enum TokenType {
36+
REFRESH_TOKEN = 'REFRESH_TOKEN',
37+
ACCESS_TOKEN = 'ACCESS_TOKEN'
3438
}
3539

3640
/** The server responses with snake_case; we convert to camelCase */
@@ -46,6 +50,16 @@ export interface RequestStsTokenResponse {
4650
refreshToken: string;
4751
}
4852

53+
export interface RevokeTokenRequest {
54+
providerId: string;
55+
tokenType: TokenType;
56+
token: string;
57+
idToken: string;
58+
tenantId?: string;
59+
}
60+
61+
export interface RevokeTokenResponse {}
62+
4963
export async function requestStsToken(
5064
auth: Auth,
5165
refreshToken: string
@@ -85,3 +99,15 @@ export async function requestStsToken(
8599
refreshToken: response.refresh_token
86100
};
87101
}
102+
103+
export async function revokeToken(
104+
auth: Auth,
105+
request: RevokeTokenRequest
106+
): Promise<RevokeTokenResponse> {
107+
return _performApiRequest<RevokeTokenRequest, RevokeTokenResponse>(
108+
auth,
109+
HttpMethod.POST,
110+
Endpoint.REVOKE_TOKEN,
111+
_addTidIfNecessary(auth, request)
112+
);
113+
}

packages/auth/src/api/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ export const enum Endpoint {
6868
WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw',
6969
GET_PROJECT_CONFIG = '/v1/projects',
7070
GET_RECAPTCHA_CONFIG = '/v2/recaptchaConfig',
71-
GET_PASSWORD_POLICY = '/v2/passwordPolicy'
71+
GET_PASSWORD_POLICY = '/v2/passwordPolicy',
72+
TOKEN = '/v1/token',
73+
REVOKE_TOKEN = '/v2/accounts:revokeToken'
7274
}
7375

7476
export const enum RecaptchaClientType {

packages/auth/src/core/auth/auth_impl.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ import { _getInstance } from '../util/instantiator';
6363
import { _getUserLanguage } from '../util/navigator';
6464
import { _getClientVersion } from '../util/version';
6565
import { HttpHeader } from '../../api';
66+
import {
67+
RevokeTokenRequest,
68+
TokenType,
69+
revokeToken
70+
} from '../../api/authentication/token';
6671
import { AuthMiddlewareQueue } from './middleware';
6772
import { RecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha';
6873
import { _logWarn } from '../util/log';
@@ -514,6 +519,26 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
514519
});
515520
}
516521

522+
/**
523+
* Revokes the given access token. Currently only supports Apple OAuth access tokens.
524+
*/
525+
async revokeAccessToken(token: string): Promise<void> {
526+
if (this.currentUser) {
527+
const idToken = await this.currentUser.getIdToken();
528+
// Generalize this to accept other providers once supported.
529+
const request: RevokeTokenRequest = {
530+
providerId: 'apple.com',
531+
tokenType: TokenType.ACCESS_TOKEN,
532+
token,
533+
idToken
534+
};
535+
if (this.tenantId != null) {
536+
request.tenantId = this.tenantId;
537+
}
538+
await revokeToken(this, request);
539+
}
540+
}
541+
517542
toJSON(): object {
518543
return {
519544
apiKey: this.config.apiKey,

packages/auth/src/core/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,19 @@ export function signOut(auth: Auth): Promise<void> {
245245
return getModularInstance(auth).signOut();
246246
}
247247

248+
/**
249+
* Revokes the given access token. Currently only supports Apple OAuth access tokens.
250+
*
251+
* @param auth - The {@link Auth} instance.
252+
* @param token - The Apple OAuth access token.
253+
*
254+
* @public
255+
*/
256+
export function revokeAccessToken(auth: Auth, token: string): Promise<void> {
257+
const authInternal = _castAuth(auth);
258+
return authInternal.revokeAccessToken(token);
259+
}
260+
248261
export { initializeAuth } from './auth/initialize';
249262
export { connectAuthEmulator } from './auth/emulator';
250263

packages/auth/src/core/user/token_manager.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ import { FirebaseError } from '@firebase/util';
2323

2424
import { testAuth, TestAuth } from '../../../test/helpers/mock_auth';
2525
import * as fetch from '../../../test/helpers/mock_fetch';
26-
import { Endpoint } from '../../api/authentication/token';
2726
import { IdTokenResponse } from '../../model/id_token';
2827
import { StsTokenManager, Buffer } from './token_manager';
2928
import { FinalizeMfaResponse } from '../../api/authentication/mfa';
3029
import { makeJWT } from '../../../test/helpers/jwt';
30+
import { Endpoint } from '../../api';
3131

3232
use(chaiAsPromised);
3333

packages/auth/src/model/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,5 @@ export interface AuthInternal extends Auth {
105105
useDeviceLanguage(): void;
106106
signOut(): Promise<void>;
107107
validatePassword(password: string): Promise<PasswordValidationStatus>;
108+
revokeAccessToken(token: string): Promise<void>;
108109
}

0 commit comments

Comments
 (0)