Skip to content

Commit 7cd0b5d

Browse files
authored
Force refresh token if RPC fails with "Unauthenticated" error (#940)
"Unauthenticated" is presumed to mean that token is expired (which might happen if local clock is wrong) and retried, subject to the usual backoff logic.
1 parent 63e1414 commit 7cd0b5d

File tree

6 files changed

+129
-25
lines changed

6 files changed

+129
-25
lines changed

packages/firestore/src/api/credentials.ts

+21-6
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,14 @@ export type UserListener = (user: User) => void;
7272
* listening for changes.
7373
*/
7474
export interface CredentialsProvider {
75+
/** Requests a token for the current user. */
76+
getToken(): Promise<Token | null>;
77+
7578
/**
76-
* Requests a token for the current user, optionally forcing a refreshed
77-
* token to be fetched.
79+
* Marks the last retrieved token as invalid, making the next GetToken request
80+
* force-refresh the token.
7881
*/
79-
getToken(forceRefresh: boolean): Promise<Token | null>;
82+
invalidateToken(): void;
8083

8184
/**
8285
* Specifies a listener to be notified of user changes (sign-in / sign-out).
@@ -99,10 +102,12 @@ export class EmptyCredentialsProvider implements CredentialsProvider {
99102

100103
constructor() {}
101104

102-
getToken(forceRefresh: boolean): Promise<Token | null> {
105+
getToken(): Promise<Token | null> {
103106
return Promise.resolve<Token | null>(null);
104107
}
105108

109+
invalidateToken(): void {}
110+
106111
setUserChangeListener(listener: UserListener): void {
107112
assert(!this.userListener, 'Can only call setUserChangeListener() once.');
108113
this.userListener = listener;
@@ -138,6 +143,8 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
138143
/** The User listener registered with setUserChangeListener(). */
139144
private userListener: UserListener | null = null;
140145

146+
private forceRefresh = false;
147+
141148
constructor(private readonly app: FirebaseApp) {
142149
// We listen for token changes but all we really care about is knowing when
143150
// the uid may have changed.
@@ -160,7 +167,7 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
160167
);
161168
}
162169

163-
getToken(forceRefresh: boolean): Promise<Token | null> {
170+
getToken(): Promise<Token | null> {
164171
assert(
165172
this.tokenListener != null,
166173
'getToken cannot be called after listener removed.'
@@ -170,6 +177,8 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
170177
// fail (with an ABORTED error) if there is a user change while the request
171178
// is outstanding.
172179
const initialUserCounter = this.userCounter;
180+
const forceRefresh = this.forceRefresh;
181+
this.forceRefresh = false;
173182
return (this.app as _FirebaseApp).INTERNAL.getToken(forceRefresh).then(
174183
tokenData => {
175184
// Cancel the request since the user changed while the request was
@@ -195,6 +204,10 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
195204
);
196205
}
197206

207+
invalidateToken(): void {
208+
this.forceRefresh = true;
209+
}
210+
198211
setUserChangeListener(listener: UserListener): void {
199212
assert(!this.userListener, 'Can only call setUserChangeListener() once.');
200213
this.userListener = listener;
@@ -285,7 +298,7 @@ export class FirstPartyCredentialsProvider implements CredentialsProvider {
285298
);
286299
}
287300

288-
getToken(forceRefresh: boolean): Promise<Token | null> {
301+
getToken(): Promise<Token | null> {
289302
return Promise.resolve(new FirstPartyToken(this.gapi, this.sessionIndex));
290303
}
291304

@@ -297,6 +310,8 @@ export class FirstPartyCredentialsProvider implements CredentialsProvider {
297310
}
298311

299312
removeUserChangeListener(): void {}
313+
314+
invalidateToken(): void {}
300315
}
301316

302317
/**

packages/firestore/src/remote/datastore.ts

+27-12
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { MaybeDocument } from '../model/document';
2121
import { DocumentKey } from '../model/document_key';
2222
import { Mutation, MutationResult } from '../model/mutation';
2323
import { assert } from '../util/assert';
24+
import { Code, FirestoreError } from '../util/error';
2425
import { AsyncQueue } from '../util/async_queue';
2526

2627
import { Connection } from './connection';
@@ -110,24 +111,38 @@ export class Datastore {
110111

111112
/** Gets an auth token and invokes the provided RPC. */
112113
private invokeRPC<Req, Resp>(rpcName: string, request: Req): Promise<Resp> {
113-
// TODO(mikelehen): Retry (with backoff) on token failures?
114-
return this.credentials.getToken(/*forceRefresh=*/ false).then(token => {
115-
return this.connection.invokeRPC<Req, Resp>(rpcName, request, token);
116-
});
114+
return this.credentials
115+
.getToken()
116+
.then(token => {
117+
return this.connection.invokeRPC<Req, Resp>(rpcName, request, token);
118+
})
119+
.catch((error: FirestoreError) => {
120+
if (error.code === Code.UNAUTHENTICATED) {
121+
this.credentials.invalidateToken();
122+
}
123+
throw error;
124+
});
117125
}
118126

119127
/** Gets an auth token and invokes the provided RPC with streamed results. */
120128
private invokeStreamingRPC<Req, Resp>(
121129
rpcName: string,
122130
request: Req
123131
): Promise<Resp[]> {
124-
// TODO(mikelehen): Retry (with backoff) on token failures?
125-
return this.credentials.getToken(/*forceRefresh=*/ false).then(token => {
126-
return this.connection.invokeStreamingRPC<Req, Resp>(
127-
rpcName,
128-
request,
129-
token
130-
);
131-
});
132+
return this.credentials
133+
.getToken()
134+
.then(token => {
135+
return this.connection.invokeStreamingRPC<Req, Resp>(
136+
rpcName,
137+
request,
138+
token
139+
);
140+
})
141+
.catch((error: FirestoreError) => {
142+
if (error.code === Code.UNAUTHENTICATED) {
143+
this.credentials.invalidateToken();
144+
}
145+
throw error;
146+
});
132147
}
133148
}

packages/firestore/src/remote/persistent_stream.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,10 @@ export abstract class PersistentStream<
331331
'Using maximum backoff delay to prevent overloading the backend.'
332332
);
333333
this.backoff.resetToMax();
334+
} else if (error && error.code === Code.UNAUTHENTICATED) {
335+
// "unauthenticated" error means the token was rejected. Try force refreshing it in case it
336+
// just expired.
337+
this.credentialsProvider.invalidateToken();
334338
}
335339

336340
// Clean up the underlying stream because we are no longer interested in events.
@@ -384,7 +388,7 @@ export abstract class PersistentStream<
384388

385389
this.state = PersistentStreamState.Auth;
386390

387-
this.credentialsProvider.getToken(/*forceRefresh=*/ false).then(
391+
this.credentialsProvider.getToken().then(
388392
token => {
389393
// Normally we'd have to schedule the callback on the AsyncQueue.
390394
// However, the following calls are safe to be called outside the
@@ -478,7 +482,8 @@ export abstract class PersistentStream<
478482
});
479483
}
480484

481-
private handleStreamClose(error?: FirestoreError): Promise<void> {
485+
// Visible for tests
486+
handleStreamClose(error?: FirestoreError): Promise<void> {
482487
assert(this.isStarted(), "Can't handle server close on non-started stream");
483488
log.debug(LOG_TAG, `close with error: ${error}`);
484489

packages/firestore/src/remote/rpc_error.ts

-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ export function isPermanentError(code: Code): boolean {
6060
case Code.UNAVAILABLE:
6161
// Unauthenticated means something went wrong with our token and we need
6262
// to retry with new credentials which will happen automatically.
63-
// TODO(b/37325376): Give up after second unauthenticated error.
6463
case Code.UNAUTHENTICATED:
6564
return false;
6665
case Code.INVALID_ARGUMENT:

packages/firestore/test/integration/remote/stream.test.ts

+67-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import { expect } from 'chai';
18+
import { EmptyCredentialsProvider, Token } from '../../../src/api/credentials';
1819
import { SnapshotVersion } from '../../../src/core/snapshot_version';
1920
import { MutationResult } from '../../../src/model/mutation';
2021
import {
@@ -29,10 +30,10 @@ import {
2930
WatchTargetChange
3031
} from '../../../src/remote/watch_change';
3132
import { AsyncQueue, TimerId } from '../../../src/util/async_queue';
33+
import { Code, FirestoreError } from '../../../src/util/error';
3234
import { Deferred } from '../../../src/util/promise';
3335
import { setMutation } from '../../util/helpers';
3436
import { withTestDatastore } from '../util/internal_helpers';
35-
import { FirestoreError } from '@firebase/firestore-types';
3637

3738
/**
3839
* StreamEventType combines the events that can be observed by the
@@ -152,6 +153,24 @@ describe('Watch Stream', () => {
152153
});
153154
});
154155

156+
class MockCredentialsProvider extends EmptyCredentialsProvider {
157+
private states: string[] = [];
158+
159+
get observedStates(): string[] {
160+
return this.states;
161+
}
162+
163+
getToken(): Promise<Token | null> {
164+
this.states.push('getToken');
165+
return super.getToken();
166+
}
167+
168+
invalidateToken(): void {
169+
this.states.push('invalidateToken');
170+
super.invalidateToken();
171+
}
172+
}
173+
155174
describe('Write Stream', () => {
156175
let streamListener: StreamStatusListener;
157176

@@ -262,4 +281,51 @@ describe('Write Stream', () => {
262281
});
263282
}, queue);
264283
});
284+
285+
it('force refreshes auth token on receiving unauthenticated error', () => {
286+
const queue = new AsyncQueue();
287+
const credentials = new MockCredentialsProvider();
288+
289+
return withTestDatastore(
290+
ds => {
291+
const writeStream = ds.newPersistentWriteStream();
292+
writeStream.start(streamListener);
293+
return streamListener
294+
.awaitCallback('open')
295+
.then(() => {
296+
// Simulate callback from GRPC with an unauthenticated error -- this should invalidate
297+
// the token.
298+
writeStream.handleStreamClose(
299+
new FirestoreError(Code.UNAUTHENTICATED, '')
300+
);
301+
return streamListener.awaitCallback('close');
302+
})
303+
.then(() => {
304+
writeStream.start(streamListener);
305+
return streamListener.awaitCallback('open');
306+
})
307+
.then(() => {
308+
// Simulate a different error -- token should not be invalidated this time.
309+
writeStream.handleStreamClose(
310+
new FirestoreError(Code.UNAVAILABLE, '')
311+
);
312+
return streamListener.awaitCallback('close');
313+
})
314+
.then(() => {
315+
writeStream.start(streamListener);
316+
return streamListener.awaitCallback('open');
317+
})
318+
.then(() => {
319+
expect(credentials.observedStates).to.deep.equal([
320+
'getToken',
321+
'invalidateToken',
322+
'getToken',
323+
'getToken'
324+
]);
325+
});
326+
},
327+
queue,
328+
credentials
329+
);
330+
});
265331
});

packages/firestore/test/integration/util/internal_helpers.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import * as firestore from '@firebase/firestore-types';
1919
import { DatabaseId, DatabaseInfo } from '../../../src/core/database_info';
2020
import { Datastore } from '../../../src/remote/datastore';
2121

22-
import { EmptyCredentialsProvider } from '../../../src/api/credentials';
22+
import {
23+
CredentialsProvider,
24+
EmptyCredentialsProvider
25+
} from '../../../src/api/credentials';
2326
import { PlatformSupport } from '../../../src/platform/platform';
2427
import { AsyncQueue } from '../../../src/util/async_queue';
2528
import { DEFAULT_SETTINGS, DEFAULT_PROJECT_ID } from './helpers';
@@ -41,7 +44,8 @@ export function getDefaultDatabaseInfo(): DatabaseInfo {
4144

4245
export function withTestDatastore(
4346
fn: (datastore: Datastore) => Promise<void>,
44-
queue?: AsyncQueue
47+
queue?: AsyncQueue,
48+
credentialsProvider?: CredentialsProvider
4549
): Promise<void> {
4650
const databaseInfo = getDefaultDatabaseInfo();
4751
return PlatformSupport.getPlatform()
@@ -53,7 +57,7 @@ export function withTestDatastore(
5357
const datastore = new Datastore(
5458
queue || new AsyncQueue(),
5559
conn,
56-
new EmptyCredentialsProvider(),
60+
credentialsProvider || new EmptyCredentialsProvider(),
5761
serializer
5862
);
5963

0 commit comments

Comments
 (0)