Skip to content

Commit ac4ad08

Browse files
Add mockUserToken support for database emulator. (#4792)
* Add mockUserToken support for database emulator. * Add compact API. * Add generated API file. * Change default project ID to demo-project. * Fix db.useEmulator * Create sweet-monkeys-warn.md * Update packages/database/src/exp/Database.ts Co-authored-by: Sebastian Schmidt <[email protected]> * Update packages/util/test/emulator.test.ts Co-authored-by: Sebastian Schmidt <[email protected]> * Fix sub field name. * Remove optional in jsdocs. * Create loud-feet-jump.md * Update loud-feet-jump.md * Make sub/user_id required in typing. * Update error messages to contain mockUserToken. * Add API changes in md. * Update error message for uid field. * Update loud-feet-jump.md * Change custom claim typing to unknown. Co-authored-by: Sebastian Schmidt <[email protected]>
1 parent 16102f0 commit ac4ad08

File tree

9 files changed

+271
-18
lines changed

9 files changed

+271
-18
lines changed

.changeset/loud-feet-jump.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@firebase/database": minor
3+
"firebase": minor
4+
"@firebase/util": minor
5+
---
6+
7+
Add mockUserToken support for database emulator.

common/api-review/database.api.md

+5-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
55
```ts
66

7+
import { EmulatorMockTokenOptions } from '@firebase/util';
78
import { FirebaseApp } from '@firebase/app';
89

9-
// @public (undocumented)
10+
// @public
1011
export function child(parent: Reference, path: string): Reference;
1112

1213
// @public
@@ -229,7 +230,9 @@ export type Unsubscribe = () => void;
229230
export function update(ref: Reference, values: object): Promise<void>;
230231

231232
// @public
232-
export function useDatabaseEmulator(db: FirebaseDatabase, host: string, port: number): void;
233+
export function useDatabaseEmulator(db: FirebaseDatabase, host: string, port: number, options?: {
234+
mockUserToken?: EmulatorMockTokenOptions;
235+
}): void;
233236

234237

235238
```

packages/database/src/api/Database.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818

1919
import { FirebaseApp } from '@firebase/app-types';
2020
import { FirebaseService } from '@firebase/app-types/private';
21-
import { validateArgCount, Compat } from '@firebase/util';
21+
import {
22+
validateArgCount,
23+
Compat,
24+
EmulatorMockTokenOptions
25+
} from '@firebase/util';
2226

2327
import {
2428
FirebaseDatabase as ExpDatabase,
@@ -58,9 +62,16 @@ export class Database implements FirebaseService, Compat<ExpDatabase> {
5862
*
5963
* @param host - the emulator host (ex: localhost)
6064
* @param port - the emulator port (ex: 8080)
65+
* @param options.mockUserToken - the mock auth token to use for unit testing Security Rules
6166
*/
62-
useEmulator(host: string, port: number): void {
63-
useDatabaseEmulator(this._delegate, host, port);
67+
useEmulator(
68+
host: string,
69+
port: number,
70+
options: {
71+
mockUserToken?: EmulatorMockTokenOptions;
72+
} = {}
73+
): void {
74+
useDatabaseEmulator(this._delegate, host, port, options);
6475
}
6576

6677
/**

packages/database/src/core/AuthTokenProvider.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -109,20 +109,23 @@ export class FirebaseAuthTokenProvider implements AuthTokenProvider {
109109
}
110110
}
111111

112-
/* Auth token provider that the Admin SDK uses to connect to the Emulator. */
113-
export class EmulatorAdminTokenProvider implements AuthTokenProvider {
114-
private static EMULATOR_AUTH_TOKEN = 'owner';
112+
/* AuthTokenProvider that supplies a constant token. Used by Admin SDK or mockUserToken with emulators. */
113+
export class EmulatorTokenProvider implements AuthTokenProvider {
114+
/** A string that is treated as an admin access token by the RTDB emulator. Used by Admin SDK. */
115+
static OWNER = 'owner';
116+
117+
constructor(private accessToken: string) {}
115118

116119
getToken(forceRefresh: boolean): Promise<FirebaseAuthTokenData> {
117120
return Promise.resolve({
118-
accessToken: EmulatorAdminTokenProvider.EMULATOR_AUTH_TOKEN
121+
accessToken: this.accessToken
119122
});
120123
}
121124

122125
addTokenChangeListener(listener: (token: string | null) => void): void {
123126
// Invoke the listener immediately to match the behavior in Firebase Auth
124127
// (see packages/auth/src/auth.js#L1807)
125-
listener(EmulatorAdminTokenProvider.EMULATOR_AUTH_TOKEN);
128+
listener(this.accessToken);
126129
}
127130

128131
removeTokenChangeListener(listener: (token: string | null) => void): void {}

packages/database/src/exp/Database.ts

+35-8
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,16 @@ import {
2525
} from '@firebase/app-exp';
2626
import { FirebaseAuthInternalName } from '@firebase/auth-interop-types';
2727
import { Provider } from '@firebase/component';
28-
import { getModularInstance } from '@firebase/util';
28+
import {
29+
getModularInstance,
30+
createMockUserToken,
31+
EmulatorMockTokenOptions
32+
} from '@firebase/util';
2933

3034
import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider';
3135
import {
3236
AuthTokenProvider,
33-
EmulatorAdminTokenProvider,
37+
EmulatorTokenProvider,
3438
FirebaseAuthTokenProvider
3539
} from '../core/AuthTokenProvider';
3640
import { Repo, repoInterrupt, repoResume, repoStart } from '../core/Repo';
@@ -76,7 +80,8 @@ let useRestClient = false;
7680
function repoManagerApplyEmulatorSettings(
7781
repo: Repo,
7882
host: string,
79-
port: number
83+
port: number,
84+
tokenProvider?: AuthTokenProvider
8085
): void {
8186
repo.repoInfo_ = new RepoInfo(
8287
`${host}:${port}`,
@@ -88,8 +93,8 @@ function repoManagerApplyEmulatorSettings(
8893
repo.repoInfo_.includeNamespaceInQueryParams
8994
);
9095

91-
if (repo.repoInfo_.nodeAdmin) {
92-
repo.authTokenProvider_ = new EmulatorAdminTokenProvider();
96+
if (tokenProvider) {
97+
repo.authTokenProvider_ = tokenProvider;
9398
}
9499
}
95100

@@ -138,7 +143,7 @@ export function repoManagerDatabaseFromApp(
138143

139144
const authTokenProvider =
140145
nodeAdmin && isEmulator
141-
? new EmulatorAdminTokenProvider()
146+
? new EmulatorTokenProvider(EmulatorTokenProvider.OWNER)
142147
: new FirebaseAuthTokenProvider(app.name, app.options, authProvider);
143148

144149
validateUrl('Invalid Firebase Database URL', parsedUrl);
@@ -295,11 +300,15 @@ export function getDatabase(
295300
* @param db - The instance to modify.
296301
* @param host - The emulator host (ex: localhost)
297302
* @param port - The emulator port (ex: 8080)
303+
* @param options.mockUserToken - the mock auth token to use for unit testing Security Rules
298304
*/
299305
export function useDatabaseEmulator(
300306
db: FirebaseDatabase,
301307
host: string,
302-
port: number
308+
port: number,
309+
options: {
310+
mockUserToken?: EmulatorMockTokenOptions;
311+
} = {}
303312
): void {
304313
db = getModularInstance(db);
305314
db._checkNotDeleted('useEmulator');
@@ -308,8 +317,26 @@ export function useDatabaseEmulator(
308317
'Cannot call useEmulator() after instance has already been initialized.'
309318
);
310319
}
320+
321+
const repo = db._repoInternal;
322+
let tokenProvider: EmulatorTokenProvider | undefined = undefined;
323+
if (repo.repoInfo_.nodeAdmin) {
324+
if (options.mockUserToken) {
325+
fatal(
326+
'mockUserToken is not supported by the Admin SDK. For client access with mock users, please use the "firebase" package instead of "firebase-admin".'
327+
);
328+
}
329+
tokenProvider = new EmulatorTokenProvider(EmulatorTokenProvider.OWNER);
330+
} else if (options.mockUserToken) {
331+
const token = createMockUserToken(
332+
options.mockUserToken,
333+
db.app.options.projectId
334+
);
335+
tokenProvider = new EmulatorTokenProvider(token);
336+
}
337+
311338
// Modify the repo to apply emulator settings
312-
repoManagerApplyEmulatorSettings(db._repoInternal, host, port);
339+
repoManagerApplyEmulatorSettings(repo, host, port, tokenProvider);
313340
}
314341

315342
/**

packages/util/index.node.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export * from './src/crypt';
2525
export * from './src/constants';
2626
export * from './src/deepCopy';
2727
export * from './src/deferred';
28+
export * from './src/emulator';
2829
export * from './src/environment';
2930
export * from './src/errors';
3031
export * from './src/json';

packages/util/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export * from './src/crypt';
2020
export * from './src/constants';
2121
export * from './src/deepCopy';
2222
export * from './src/deferred';
23+
export * from './src/emulator';
2324
export * from './src/environment';
2425
export * from './src/errors';
2526
export * from './src/json';

packages/util/src/emulator.ts

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @license
3+
* Copyright 2021 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { base64 } from './crypt';
19+
20+
// Firebase Auth tokens contain snake_case claims following the JWT standard / convention.
21+
/* eslint-disable camelcase */
22+
23+
export type FirebaseSignInProvider =
24+
| 'custom'
25+
| 'email'
26+
| 'password'
27+
| 'phone'
28+
| 'anonymous'
29+
| 'google.com'
30+
| 'facebook.com'
31+
| 'github.com'
32+
| 'twitter.com'
33+
| 'microsoft.com'
34+
| 'apple.com';
35+
36+
interface FirebaseIdToken {
37+
// Always set to https://securetoken.google.com/PROJECT_ID
38+
iss: string;
39+
40+
// Always set to PROJECT_ID
41+
aud: string;
42+
43+
// The user's unique id
44+
sub: string;
45+
46+
// The token issue time, in seconds since epoch
47+
iat: number;
48+
49+
// The token expiry time, normally 'iat' + 3600
50+
exp: number;
51+
52+
// The user's unique id, must be equal to 'sub'
53+
user_id: string;
54+
55+
// The time the user authenticated, normally 'iat'
56+
auth_time: number;
57+
58+
// The sign in provider, only set when the provider is 'anonymous'
59+
provider_id?: 'anonymous';
60+
61+
// The user's primary email
62+
email?: string;
63+
64+
// The user's email verification status
65+
email_verified?: boolean;
66+
67+
// The user's primary phone number
68+
phone_number?: string;
69+
70+
// The user's display name
71+
name?: string;
72+
73+
// The user's profile photo URL
74+
picture?: string;
75+
76+
// Information on all identities linked to this user
77+
firebase: {
78+
// The primary sign-in provider
79+
sign_in_provider: FirebaseSignInProvider;
80+
81+
// A map of providers to the user's list of unique identifiers from
82+
// each provider
83+
identities?: { [provider in FirebaseSignInProvider]?: string[] };
84+
};
85+
86+
// Custom claims set by the developer
87+
[claim: string]: unknown;
88+
89+
uid?: never; // Try to catch a common mistake of "uid" (should be "sub" instead).
90+
}
91+
92+
export type EmulatorMockTokenOptions = ({ user_id: string } | { sub: string }) &
93+
Partial<FirebaseIdToken>;
94+
95+
export function createMockUserToken(
96+
token: EmulatorMockTokenOptions,
97+
projectId?: string
98+
): string {
99+
if (token.uid) {
100+
throw new Error(
101+
'The "uid" field is no longer supported by mockUserToken. Please use "sub" instead for Firebase Auth User ID.'
102+
);
103+
}
104+
// Unsecured JWTs use "none" as the algorithm.
105+
const header = {
106+
alg: 'none',
107+
type: 'JWT'
108+
};
109+
110+
const project = projectId || 'demo-project';
111+
const iat = token.iat || 0;
112+
const sub = token.sub || token.user_id;
113+
if (!sub) {
114+
throw new Error("mockUserToken must contain 'sub' or 'user_id' field!");
115+
}
116+
117+
const payload: FirebaseIdToken = {
118+
// Set all required fields to decent defaults
119+
iss: `https://securetoken.google.com/${project}`,
120+
aud: project,
121+
iat,
122+
exp: iat + 3600,
123+
auth_time: iat,
124+
sub,
125+
user_id: sub,
126+
firebase: {
127+
sign_in_provider: 'custom',
128+
identities: {}
129+
},
130+
131+
// Override with user options
132+
...token
133+
};
134+
135+
// Unsecured JWTs use the empty string as a signature.
136+
const signature = '';
137+
return [
138+
base64.encodeString(JSON.stringify(header), /*webSafe=*/ false),
139+
base64.encodeString(JSON.stringify(payload), /*webSafe=*/ false),
140+
signature
141+
].join('.');
142+
}

0 commit comments

Comments
 (0)