Skip to content

Commit

Permalink
refactor(auth): enable multiple social providers (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
gustavohenke committed Sep 27, 2020
1 parent 63e4180 commit ecb462a
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 63 deletions.
3 changes: 2 additions & 1 deletion auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe("#createUserWithEmailAndPassword()", () => {
expect(auth.store.add).toHaveBeenCalledWith({
email: "foo@bar.com",
password: "password",
providerId: firebase.auth.EmailAuthProvider.PROVIDER_ID,
});
});

Expand Down Expand Up @@ -54,7 +55,7 @@ describe("#onAuthStateChange()", () => {
expect(listener).toHaveBeenCalledWith(credential.user);
});

it("doesn't invokes the listener after it's disposed", async () => {
it("doesn't invoke the listener after it's disposed", async () => {
const auth = new MockAuth(app);
const listener = jest.fn();
const disposer = auth.onAuthStateChanged(listener);
Expand Down
74 changes: 24 additions & 50 deletions auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SocialSignInMock } from "./social-signin-mock";
import { User } from "./user";
import { UserStore } from "./user-store";
import { AuthSettings } from "./auth-settings";
import { UserCredential, UserCredentialOptions } from "./user-credential";

export type AuthStateChangeListener = (user: firebase.User | null) => void;

Expand All @@ -13,7 +14,6 @@ export class MockAuth implements firebase.auth.Auth {
public settings: firebase.auth.AuthSettings = new AuthSettings();
public tenantId: string | null;
public readonly store = new UserStore();
private readonly socialSignIns = new Set<SocialSignInMock>();
private readonly authStateEvents = new Set<AuthStateChangeListener>();

constructor(public readonly app: MockApp) {}
Expand All @@ -38,8 +38,9 @@ export class MockAuth implements firebase.auth.Auth {
throw new Error("auth/email-already-in-use");
}

const user = this.store.add({ email, password });
return this.signIn(user);
const { providerId } = new firebase.auth.EmailAuthProvider();
const user = this.store.add({ email, password, providerId });
return this.signIn(user, { isNewUser: true });
}

fetchProvidersForEmail(email: string): Promise<any> {
Expand All @@ -60,7 +61,7 @@ export class MockAuth implements firebase.auth.Auth {

mockSocialSignIn(provider: firebase.auth.AuthProvider) {
const mock = new SocialSignInMock(provider.providerId);
this.socialSignIns.add(mock);
this.store.addSocialMock(mock);
return mock;
}

Expand Down Expand Up @@ -105,19 +106,26 @@ export class MockAuth implements firebase.auth.Auth {

private signIn(
user: User,
additionalUserInfo: firebase.auth.AdditionalUserInfo | null = null
options: UserCredentialOptions
): Promise<firebase.auth.UserCredential> {
this.currentUser = user;
this.authStateEvents.forEach((listener) => {
listener(user);
});

return Promise.resolve<firebase.auth.UserCredential>({
user,
additionalUserInfo,
credential: null,
operationType: "signIn",
});
return Promise.resolve(new UserCredential(user, "signIn", options));
}

private async signInWithSocial(provider: firebase.auth.AuthProvider) {
const mockResponse = await this.store.consumeSocialMock(provider);
let user = this.store.findByProviderAndEmail(mockResponse.email, provider.providerId);
if (user) {
return this.signIn(user, { isNewUser: false });
}

// user didn't exist, so it's created and then signed in
user = this.store.add({ ...mockResponse, providerId: provider.providerId });
return this.signIn(user, { isNewUser: true });
}

signInAndRetrieveDataWithCredential(credential: firebase.auth.AuthCredential): Promise<any> {
Expand All @@ -126,11 +134,11 @@ export class MockAuth implements firebase.auth.Auth {

signInAnonymously(): Promise<firebase.auth.UserCredential> {
if (this.currentUser && this.currentUser.isAnonymous) {
return this.signIn(this.currentUser);
return this.signIn(this.currentUser, { isNewUser: false });
}

const user = this.store.add({ isAnonymous: true });
return this.signIn(user);
return this.signIn(user, { isNewUser: true });
}

signInWithCredential(credential: firebase.auth.AuthCredential): Promise<any> {
Expand All @@ -152,7 +160,7 @@ export class MockAuth implements firebase.auth.Auth {
return Promise.reject(new Error("auth/wrong-password"));
}

return this.signIn(user);
return this.signIn(user, { isNewUser: false });
}

signInWithEmailLink(
Expand All @@ -169,42 +177,8 @@ export class MockAuth implements firebase.auth.Auth {
throw new Error("Method not implemented.");
}

async signInWithPopup(
provider: firebase.auth.AuthProvider
): Promise<firebase.auth.UserCredential> {
const mock = Array.from(this.socialSignIns.values()).find(
(mock) => mock.type === provider.providerId
);

if (!mock) {
throw new Error("No mock response set.");
}

// Mock is used, then it must go
this.socialSignIns.delete(mock);

const data = await mock.response;
let user = this.store.findByEmail(data.email);
if (user) {
if (user.providerId !== provider.providerId) {
throw new Error("auth/account-exists-with-different-credential");
}

return this.signIn(user, {
isNewUser: false,
providerId: provider.providerId,
profile: null,
username: data.email,
});
}

user = this.store.add({ ...data, providerId: provider.providerId });
return this.signIn(user, {
isNewUser: true,
providerId: provider.providerId,
profile: null,
username: data.email,
});
signInWithPopup(provider: firebase.auth.AuthProvider): Promise<firebase.auth.UserCredential> {
return this.signInWithSocial(provider);
}

signInWithRedirect(provider: firebase.auth.AuthProvider): Promise<void> {
Expand Down
26 changes: 26 additions & 0 deletions auth/user-credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { User } from "./user";

export interface UserCredentialOptions {
isNewUser: boolean;
}

export class UserCredential implements firebase.auth.UserCredential {
readonly additionalUserInfo: firebase.auth.AdditionalUserInfo | null = null;
readonly credential = null;

constructor(
readonly user: User,
readonly operationType: "signIn" | "link" | "reauthenticate",
{ isNewUser }: UserCredentialOptions
) {
if (!user.isAnonymous) {
this.additionalUserInfo = {
isNewUser,
profile: null,
// providerId should be at the right value in the user object already.
providerId: user.providerId,
username: user.email,
};
}
}
}
66 changes: 57 additions & 9 deletions auth/user-store.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,75 @@
import { User, UserSchema } from "./user";
import { User, UserSchema, UserInfo } from "./user";
import { SocialSignInMock } from "./social-signin-mock";

export class UserStore {
private nextId = 0;
private idStore = new Map<string, UserSchema>();
private emailStore = new Map<string, UserSchema>();
private store = new Map<string, UserSchema>();
private readonly socialMocks: SocialSignInMock[] = [];

/**
* Adds a user to this store with the given data.
* The new user is assigned a new ID and is returned.
*/
add(data: Partial<UserSchema>): User {
const uid = ++this.nextId + "";
const user = new User({ ...data, uid }, this);
const user = new User(
{
...data,
// If the user is being created with a provider ID, then its data is coming from that provider.
providerData: data.providerId ? [new UserInfo(uid, data.providerId, data)] : [],
uid,
},
this
);

const schema = user.toJSON() as UserSchema;
this.idStore.set(schema.uid, schema);
schema.email && this.emailStore.set(schema.email, schema);
this.store.set(schema.uid, schema);
return user;
}

addSocialMock(mock: SocialSignInMock) {
this.socialMocks.push(mock);
}

/**
* Finds the mock response for a given AuthProvider, removes it from the list of mocks
* and returns its response.
*
* If a mock for that AuthProvider is not set, then this method throws.
*/
consumeSocialMock(provider: firebase.auth.AuthProvider) {
const index = this.socialMocks.findIndex((mock) => mock.type === provider.providerId);
if (index === -1) {
throw new Error("No mock response set.");
}

// Mock is used, then it must go
const mock = this.socialMocks[index];
this.socialMocks.splice(index, 1);

return mock.response;
}

findByEmail(email: string): User | undefined {
const schema = this.emailStore.get(email);
return schema ? new User(schema, this) : undefined;
const schema = [...this.store.values()].find((user) => user.email === email);
return schema && new User(schema, this);
}

findByProviderAndEmail(email: string, providerId: string): User | undefined {
const user = this.findByEmail(email);
if (!user) {
return undefined;
}

if (user.providerData.some((info) => info.providerId === providerId)) {
return new User({ ...user.toJSON(), providerId }, this);
}

throw new Error("auth/account-exists-with-different-credential");
}

update(id: string, data: Partial<UserSchema>) {
const schema = this.idStore.get(id);
const schema = this.store.get(id);
if (!schema) {
return;
}
Expand Down
24 changes: 21 additions & 3 deletions auth/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface UserSchema {
metadata: firebase.auth.UserMetadata;
password?: string;
phoneNumber: string | null;
providerData: (firebase.UserInfo | null)[];
providerData: UserInfo[];
refreshToken: string;
displayName: string | null;
email: string | null;
Expand All @@ -17,7 +17,26 @@ export interface UserSchema {
tenantId: string | null;
}

export class UserInfo implements firebase.UserInfo {
readonly displayName: string | null;
readonly email: string | null;
readonly phoneNumber: string | null;
readonly photoURL: string | null;

constructor(
readonly uid: string,
readonly providerId: string,
rest: Partial<Omit<firebase.UserInfo, "uid" | "providerId">>
) {
this.displayName = rest.displayName ?? null;
this.email = rest.email ?? null;
this.phoneNumber = rest.phoneNumber ?? null;
this.photoURL = rest.photoURL ?? null;
}
}

export class User implements firebase.User, UserSchema {
readonly uid: string;
displayName: string | null;
email: string | null;
emailVerified: boolean;
Expand All @@ -26,11 +45,10 @@ export class User implements firebase.User, UserSchema {
password?: string;
phoneNumber: string | null;
photoURL: string | null;
providerData: (firebase.UserInfo | null)[];
providerData: UserInfo[];
providerId: string;
refreshToken: string;
tenantId: string | null;
uid: string;
multiFactor: firebase.User.MultiFactorUser;

constructor(data: Partial<UserSchema>, private readonly store: UserStore) {
Expand Down

0 comments on commit ecb462a

Please sign in to comment.