Skip to content

Commit

Permalink
Initial auth object implementation + initializeAuth() (#2932)
Browse files Browse the repository at this point in the history
  • Loading branch information
sam-gc committed Apr 22, 2020
1 parent fdef826 commit 7d821e4
Show file tree
Hide file tree
Showing 6 changed files with 339 additions and 9 deletions.
9 changes: 6 additions & 3 deletions packages-exp/auth-compat-exp/rollup.config.js
Expand Up @@ -59,10 +59,13 @@ const es5Builds = [
*/
{
input: 'index.rn.ts',
output: [{ file: pkg['react-native'], format: 'cjs', sourcemap: true }],
output: [{ file: pkg['react-native'], format: 'cjs', sourcemap: true }],
plugins: es5BuildPlugins,
external: id => [...deps, 'react-native'].some(dep => id === dep || id.startsWith(`${dep}/`))
},
external: id =>
[...deps, 'react-native'].some(
dep => id === dep || id.startsWith(`${dep}/`)
)
}
];

/**
Expand Down
191 changes: 191 additions & 0 deletions packages-exp/auth-exp/src/core/auth/auth_impl.test.ts
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, use } from 'chai';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';

import { FirebaseApp } from '@firebase/app-types-exp';
import { FirebaseError } from '@firebase/util';

import { testUser } from '../../../test/mock_auth';
import { Auth } from '../../model/auth';
import { Persistence } from '../persistence';
import { browserLocalPersistence } from '../persistence/browser';
import { inMemoryPersistence } from '../persistence/in_memory';
import { PersistenceUserManager } from '../persistence/persistence_user_manager';
import { ClientPlatform, getClientVersion } from '../util/version';
import {
DEFAULT_API_HOST,
DEFAULT_API_SCHEME,
initializeAuth
} from './auth_impl';

use(sinonChai);

const FAKE_APP: FirebaseApp = {
name: 'test-app',
options: {
apiKey: 'api-key',
authDomain: 'auth-domain'
},
automaticDataCollectionEnabled: false
};

describe('AuthImpl', () => {
let auth: Auth;
let persistenceStub: sinon.SinonStubbedInstance<Persistence>;

beforeEach(() => {
persistenceStub = sinon.stub(inMemoryPersistence);
auth = initializeAuth(FAKE_APP, { persistence: inMemoryPersistence });
});

afterEach(sinon.restore);

describe('#updateCurrentUser', () => {
it('sets the field on the auth object', async () => {
const user = testUser('uid');
await auth.updateCurrentUser(user);
expect(auth.currentUser).to.eql(user);
});

it('orders async operations correctly', async () => {
const users = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => {
return testUser(`${n}`);
});

persistenceStub.set.callsFake(() => {
return new Promise(resolve => {
// Force into the async flow to make this test actually meaningful
setTimeout(() => resolve(), 1);
});
});

await Promise.all(users.map(u => auth.updateCurrentUser(u)));
for (let i = 0; i < 10; i++) {
expect(persistenceStub.set.getCall(i)).to.have.been.calledWith(
sinon.match.any,
users[i].toPlainObject()
);
}
});

it('setting to null triggers a remove call', async () => {
await auth.updateCurrentUser(null);
expect(persistenceStub.remove).to.have.been.called;
});
});

describe('#signOut', () => {
it('sets currentUser to null, calls remove', async () => {
await auth.updateCurrentUser(testUser('test'));
await auth.signOut();
expect(persistenceStub.remove).to.have.been.called;
expect(auth.currentUser).to.be.null;
});
});

describe('#setPersistence', () => {
it('swaps underlying persistence', async () => {
const newPersistence = browserLocalPersistence;
const newStub = sinon.stub(newPersistence);
persistenceStub.get.returns(
Promise.resolve(testUser('test').toPlainObject())
);

await auth.setPersistence(newPersistence);
expect(persistenceStub.get).to.have.been.called;
expect(persistenceStub.remove).to.have.been.called;
expect(newStub.set).to.have.been.calledWith(
sinon.match.any,
testUser('test').toPlainObject()
);
});
});
});

describe('initializeAuth', () => {
afterEach(sinon.restore);

it('throws an API error if key not provided', () => {
expect(() =>
initializeAuth({
...FAKE_APP,
options: {} // apiKey is missing
})
).to.throw(
FirebaseError,
'Firebase: Your API key is invalid, please check you have copied it correctly. (auth/invalid-api-key).'
);
});

describe('persistence manager creation', () => {
let createManagerStub: sinon.SinonSpy;
beforeEach(() => {
createManagerStub = sinon.spy(PersistenceUserManager, 'create');
});

async function initAndWait(
persistence: Persistence | Persistence[]
): Promise<Auth> {
const auth = initializeAuth(FAKE_APP, { persistence });
// Auth initializes async. We can make sure the initialization is
// flushed by awaiting a method on the queue.
await auth.setPersistence(inMemoryPersistence);
return auth;
}

it('converts single persistence to array', async () => {
const auth = await initAndWait(inMemoryPersistence);
expect(createManagerStub).to.have.been.calledWith(auth, [
inMemoryPersistence
]);
});

it('pulls the user from storage', async () => {
sinon
.stub(inMemoryPersistence, 'get')
.returns(Promise.resolve(testUser('uid').toPlainObject()));
const auth = await initAndWait(inMemoryPersistence);
expect(auth.currentUser!.uid).to.eq('uid');
});

it('calls create with the persistence in order', async () => {
const auth = await initAndWait([
inMemoryPersistence,
browserLocalPersistence
]);
expect(createManagerStub).to.have.been.calledWith(auth, [
inMemoryPersistence,
browserLocalPersistence
]);
});

it('sets auth name and config', async () => {
const auth = await initAndWait(inMemoryPersistence);
expect(auth.name).to.eq(FAKE_APP.name);
expect(auth.config).to.eql({
apiKey: FAKE_APP.options.apiKey,
authDomain: FAKE_APP.options.authDomain,
apiHost: DEFAULT_API_HOST,
apiScheme: DEFAULT_API_SCHEME,
sdkClientVersion: getClientVersion(ClientPlatform.BROWSER)
});
});
});
});
122 changes: 122 additions & 0 deletions packages-exp/auth-exp/src/core/auth/auth_impl.ts
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { getApp } from '@firebase/app-exp';
import { FirebaseApp } from '@firebase/app-types-exp';

import { Auth, Config, Dependencies } from '../../model/auth';
import { User } from '../../model/user';
import { AuthErrorCode } from '../errors';
import { Persistence } from '../persistence';
import { PersistenceUserManager } from '../persistence/persistence_user_manager';
import { assert } from '../util/assert';
import { ClientPlatform, getClientVersion } from '../util/version';

interface AsyncAction {
(): Promise<void>;
}

export const DEFAULT_API_HOST = 'identitytoolkit.googleapis.com';
export const DEFAULT_API_SCHEME = 'https';

class AuthImpl implements Auth {
currentUser: User | null = null;
private operations = Promise.resolve();
private persistenceManager?: PersistenceUserManager;

constructor(
public readonly name: string,
public readonly config: Config,
persistenceHierarchy: Persistence[]
) {
// This promise is intended to float; auth initialization happens in the
// background, meanwhile the auth object may be used by the app.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.queue(async () => {
this.persistenceManager = await PersistenceUserManager.create(
this,
persistenceHierarchy
);

const storedUser = await this.persistenceManager.getCurrentUser();
// TODO: Check redirect user, if not redirect user, call refresh on stored user
if (storedUser) {
await this.directlySetCurrentUser(storedUser);
}
});
}

updateCurrentUser(user: User | null): Promise<void> {
return this.queue(() => this.directlySetCurrentUser(user));
}

signOut(): Promise<void> {
return this.queue(() => this.directlySetCurrentUser(null));
}

setPersistence(persistence: Persistence): Promise<void> {
return this.queue(async () => {
await this.assertedPersistence.setPersistence(persistence);
});
}

/**
* Unprotected (from race conditions) method to set the current user. This
* should only be called from within a queued callback. This is necessary
* because the queue shouldn't rely on another queued callback.
*/
private async directlySetCurrentUser(user: User | null): Promise<void> {
this.currentUser = user;

if (user) {
await this.assertedPersistence.setCurrentUser(user);
} else {
await this.assertedPersistence.removeCurrentUser();
}
}

private queue(action: AsyncAction): Promise<void> {
// In case something errors, the callback still should be called in order
// to keep the promise chain alive
this.operations = this.operations.then(action, action);
return this.operations;
}

private get assertedPersistence(): PersistenceUserManager {
return assert(this.persistenceManager, this.name);
}
}

export function initializeAuth(
app: FirebaseApp = getApp(),
deps?: Dependencies
): Auth {
const persistence = deps?.persistence || [];
const hierarchy = Array.isArray(persistence) ? persistence : [persistence];
const { apiKey, authDomain } = app.options;

// TODO: platform needs to be determined using heuristics
const config: Config = {
apiKey: assert(apiKey, app.name, AuthErrorCode.INVALID_API_KEY),
authDomain,
apiHost: DEFAULT_API_HOST,
apiScheme: DEFAULT_API_SCHEME,
sdkClientVersion: getClientVersion(ClientPlatform.BROWSER)
};

return new AuthImpl(app.name, config, hierarchy);
}
5 changes: 3 additions & 2 deletions packages-exp/auth-exp/src/core/util/assert.ts
Expand Up @@ -19,10 +19,11 @@ import { AUTH_ERROR_FACTORY, AuthErrorCode } from '../errors';

export function assert<T>(
expression: T | null | undefined,
appName: string
appName: string,
code = AuthErrorCode.INTERNAL_ERROR
): T {
if (!expression) {
throw AUTH_ERROR_FACTORY.create(AuthErrorCode.INTERNAL_ERROR, { appName });
throw AUTH_ERROR_FACTORY.create(code, { appName });
}

return expression;
Expand Down
15 changes: 12 additions & 3 deletions packages-exp/auth-exp/src/model/auth.d.ts
Expand Up @@ -15,13 +15,13 @@
* limitations under the License.
*/

import { Persistence } from '../core/persistence';
import { User } from './user';

export type AppName = string;
export type ApiKey = string;
export type AuthDomain = string;

export const DEFAULT_API_HOST = 'identitytoolkit.googleapis.com';
export const DEFAULT_API_SCHEME = 'https';

export interface Config {
apiKey: ApiKey;
apiHost: string;
Expand All @@ -31,6 +31,15 @@ export interface Config {
}

export interface Auth {
currentUser: User | null;
readonly name: AppName;
readonly config: Config;

setPersistence(persistence: Persistence): Promise<void>;
updateCurrentUser(user: User | null): Promise<void>;
signOut(): Promise<void>;
}

export interface Dependencies {
persistence?: Persistence | Persistence[];
}
6 changes: 5 additions & 1 deletion packages-exp/auth-exp/test/mock_auth.ts
Expand Up @@ -31,7 +31,11 @@ export const mockAuth: Auth = {
apiHost: TEST_HOST,
apiScheme: TEST_SCHEME,
sdkClientVersion: 'testSDK/0.0.0'
}
},
currentUser: null,
async setPersistence() {},
async updateCurrentUser() {},
async signOut() {}
};

export function testUser(uid: string, email?: string): User {
Expand Down

0 comments on commit 7d821e4

Please sign in to comment.