Skip to content

Commit 4f7f840

Browse files
committed
Add mockUserToken support for database emulator.
1 parent 364e336 commit 4f7f840

File tree

6 files changed

+243
-13
lines changed

6 files changed

+243
-13
lines changed

packages/database/src/core/AuthTokenProvider.ts

Lines changed: 8 additions & 5 deletions
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

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ import {
2424
} from '@firebase/app-exp';
2525
import { FirebaseAuthInternalName } from '@firebase/auth-interop-types';
2626
import { Provider } from '@firebase/component';
27-
import { getModularInstance } from '@firebase/util';
27+
import {
28+
getModularInstance,
29+
createMockUserToken,
30+
FirebaseIdToken
31+
} from '@firebase/util';
2832

2933
import {
3034
AuthTokenProvider,
31-
EmulatorAdminTokenProvider,
35+
EmulatorTokenProvider,
3236
FirebaseAuthTokenProvider
3337
} from '../core/AuthTokenProvider';
3438
import { Repo, repoInterrupt, repoResume, repoStart } from '../core/Repo';
@@ -74,7 +78,8 @@ let useRestClient = false;
7478
function repoManagerApplyEmulatorSettings(
7579
repo: Repo,
7680
host: string,
77-
port: number
81+
port: number,
82+
tokenProvider?: AuthTokenProvider
7883
): void {
7984
repo.repoInfo_ = new RepoInfo(
8085
`${host}:${port}`,
@@ -86,8 +91,8 @@ function repoManagerApplyEmulatorSettings(
8691
repo.repoInfo_.includeNamespaceInQueryParams
8792
);
8893

89-
if (repo.repoInfo_.nodeAdmin) {
90-
repo.authTokenProvider_ = new EmulatorAdminTokenProvider();
94+
if (tokenProvider) {
95+
repo.authTokenProvider_ = tokenProvider;
9196
}
9297
}
9398

@@ -135,7 +140,7 @@ export function repoManagerDatabaseFromApp(
135140

136141
const authTokenProvider =
137142
nodeAdmin && isEmulator
138-
? new EmulatorAdminTokenProvider()
143+
? new EmulatorTokenProvider(EmulatorTokenProvider.OWNER)
139144
: new FirebaseAuthTokenProvider(app.name, app.options, authProvider);
140145

141146
validateUrl('Invalid Firebase Database URL', parsedUrl);
@@ -286,11 +291,15 @@ export function getDatabase(
286291
* @param db - The instance to modify.
287292
* @param host - The emulator host (ex: localhost)
288293
* @param port - The emulator port (ex: 8080)
294+
* @param options.mockUserToken - Optional: The mock token to use (for unit testing Security Rules)
289295
*/
290296
export function useDatabaseEmulator(
291297
db: FirebaseDatabase,
292298
host: string,
293-
port: number
299+
port: number,
300+
options: {
301+
mockUserToken?: Partial<FirebaseIdToken>;
302+
} = {}
294303
): void {
295304
db = getModularInstance(db);
296305
db._checkNotDeleted('useEmulator');
@@ -299,8 +308,26 @@ export function useDatabaseEmulator(
299308
'Cannot call useEmulator() after instance has already been initialized.'
300309
);
301310
}
311+
312+
const repo = db._repo;
313+
let tokenProvider: EmulatorTokenProvider | undefined = undefined;
314+
if (repo.repoInfo_.nodeAdmin) {
315+
if (options.mockUserToken) {
316+
fatal(
317+
'mockUserToken is not supported on the Admin SDK. For client access with mock users, please use the "firebase" package instead of "firebase-admin".'
318+
);
319+
}
320+
tokenProvider = new EmulatorTokenProvider(EmulatorTokenProvider.OWNER);
321+
} else if (options.mockUserToken) {
322+
const token = createMockUserToken(
323+
options.mockUserToken,
324+
db.app.options.projectId
325+
);
326+
tokenProvider = new EmulatorTokenProvider(token);
327+
}
328+
302329
// Modify the repo to apply emulator settings
303-
repoManagerApplyEmulatorSettings(db._repo, host, port);
330+
repoManagerApplyEmulatorSettings(repo, host, port, tokenProvider);
304331
}
305332

306333
/**

packages/util/index.node.ts

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
export 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+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
88+
[claim: string]: any;
89+
90+
uid?: never; // Try to catch a common mistake of "uid" (should be "sub" instead).
91+
}
92+
93+
export function createMockUserToken(
94+
token: Partial<FirebaseIdToken>,
95+
projectId?: string
96+
): string {
97+
if (token.uid) {
98+
throw new Error(
99+
'Invalid Firebase token field "uid". Did you mean "sub" (for Firebase Auth User ID)?'
100+
);
101+
}
102+
// Unsecured JWTs use "none" as the algorithm.
103+
const header = {
104+
alg: 'none',
105+
type: 'JWT'
106+
};
107+
108+
const project = projectId || 'fake-project';
109+
const iat = token.iat || 0;
110+
const uid = token.uid || token.user_id;
111+
if (!uid) {
112+
throw new Error("Auth must contain 'sub' or 'user_id' field!");
113+
}
114+
115+
const payload: FirebaseIdToken = {
116+
// Set all required fields to decent defaults
117+
iss: `https://securetoken.google.com/${project}`,
118+
aud: project,
119+
iat,
120+
exp: iat + 3600,
121+
auth_time: iat,
122+
sub: uid,
123+
user_id: uid,
124+
firebase: {
125+
sign_in_provider: 'custom',
126+
identities: {}
127+
},
128+
129+
// Override with user options
130+
...token
131+
};
132+
133+
// Unsecured JWTs use the empty string as a signature.
134+
const signature = '';
135+
return [
136+
base64.encodeString(JSON.stringify(header), /*webSafe=*/ false),
137+
base64.encodeString(JSON.stringify(payload), /*webSafe=*/ false),
138+
signature
139+
].join('.');
140+
}

packages/util/test/emulator.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @license
3+
* Copyright 2017 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+
import { expect } from 'chai';
18+
import { base64 } from '../src/crypt';
19+
import { createMockUserToken, FirebaseIdToken } from '../src/emulator';
20+
21+
// Firebase Auth tokens contain snake_case claims following the JWT standard / convention.
22+
/* eslint-disable camelcase */
23+
24+
describe('createMockUserToken()', () => {
25+
it('creates a well-formed JWT', () => {
26+
const projectId = 'my-project';
27+
const options = { user_id: 'alice' };
28+
29+
const token = createMockUserToken(options, projectId);
30+
const claims = JSON.parse(
31+
base64.decodeString(token.split('.')[1], /*webSafe=*/ false)
32+
);
33+
// We add an 'iat' field.
34+
expect(claims).to.deep.equal({
35+
iss: 'https://securetoken.google.com/' + projectId,
36+
aud: projectId,
37+
iat: 0,
38+
exp: 3600,
39+
auth_time: 0,
40+
sub: 'alice',
41+
user_id: 'alice',
42+
firebase: {
43+
sign_in_provider: 'custom',
44+
identities: {}
45+
}
46+
});
47+
});
48+
49+
it('rejects "uid" field with error', () => {
50+
const options = { uid: 'alice' };
51+
52+
expect(() =>
53+
createMockUserToken((options as unknown) as Partial<FirebaseIdToken>)
54+
).to.throw(
55+
'Invalid Firebase token field "uid". Did you mean "sub" (for Firebase Auth User ID)?'
56+
);
57+
});
58+
});

0 commit comments

Comments
 (0)