Skip to content

Commit

Permalink
Convert to typescript
Browse files Browse the repository at this point in the history
  • Loading branch information
awinograd committed Mar 23, 2020
1 parent 31754d1 commit c0999db
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 33 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"babel-eslint": "11.0.0-beta.0",
"eslint": "^6.8.0",
"eslint-plugin-babel": "^5.3.0",
"eslint-plugin-jest": "^23.8.2"
"eslint-plugin-jest": "^23.8.2",
"typescript": "^3.8.3"
},
"dependencies": {
"@firebase/auth-types": "^0.10.0"
}
}
93 changes: 61 additions & 32 deletions src/main.js → src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,26 @@
* Full documentation for the "identitytoolkit" API can be found here:
* https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts
*/
import type { ActionCodeInfo, UserInfo as FBUser } from '@firebase/auth-types';
import humanReadableErrors from './errors.json';

type User = FBUser & { tokenManager: { idToken: string, refreshToken: string, expiresAt: number } };

type UserCallback = (user: User | null) => void;

type Provider = {
name: string;
scope: unknown;
}

type AuthOptions = {
apiKey: string;
name: string;
providers: Array<Provider | string>;
redirectUri?: string;
storage: Storage;
};

/**
* Settings object for an IDP(Identity Provider).
* @typedef {Object} ProviderOptions
Expand Down Expand Up @@ -36,7 +54,15 @@ import humanReadableErrors from './errors.json';
* @param {Array.<ProviderOptions|string>} options.providers Array of arguments that will be passed to the addProvider method.
*/
export default class Auth {
constructor({ name = 'default', apiKey, redirectUri, providers = [] }) {
apiKey!: string;
listeners: UserCallback[];
name!: string;
providers!: Record<string, unknown>;
redirectUri?: string;
refreshTokenRequest: Promise<unknown> | null = null;
user: User | null;

constructor({ name = 'default', apiKey, redirectUri, providers = [] }: AuthOptions) {
if (!apiKey) throw Error('The argument "apiKey" is required');
if (!Array.isArray(providers)) throw Error('The argument "providers" must be an array');

Expand All @@ -50,7 +76,8 @@ export default class Auth {
* User data if the user is logged in, else its null.
* @type {Object|null}
*/
this.user = JSON.parse(localStorage.getItem(`Auth:User:${apiKey}:${name}`));
const storedUser = localStorage.getItem(`Auth:User:${apiKey}:${name}`);
this.user = storedUser ? JSON.parse(storedUser) : null;

Object.assign(this, {
name,
Expand All @@ -60,7 +87,7 @@ export default class Auth {
});

for (let options of providers) {
const { name, scope } = typeof options === 'string' ? { name: options } : options;
const { name, scope } = typeof options === 'string' ? { name: options, scope: undefined } : options;
this.providers[name] = scope;
}

Expand All @@ -84,7 +111,7 @@ export default class Auth {
* Set up a function that will be called whenever the user state is changed.
* @param {function} cb The function to call when the event is triggered.
*/
listen(cb) {
listen(cb: UserCallback) {
this.listeners.push(cb);

// Return a function to unbind the callback.
Expand All @@ -97,7 +124,7 @@ export default class Auth {
* @param {any} request Body to pass to the request.
* @private
*/
api(endpoint, body) {
api(endpoint: string, body: any) {
const url =
endpoint === 'token'
? `https://securetoken.googleapis.com/v1/token?key=${this.apiKey}`
Expand All @@ -112,7 +139,7 @@ export default class Auth {
// If the response has an error, check to see if we have a human readable version of it,
// and throw that instead.
if (!response.ok) {
throw Error(humanReadableErrors[data.error.message] || data.error.message);
throw Error(humanReadableErrors[data.error.message as keyof typeof humanReadableErrors] || data.error.message);
}

return data;
Expand All @@ -134,7 +161,7 @@ export default class Auth {
* @param {Object} credentials
* @private
*/
persistSession(userData) {
persistSession(userData: User) {
// Persist the session to the local storage.
localStorage.setItem(`Auth:User:${this.apiKey}:${this.name}`, JSON.stringify(userData));
this.user = userData;
Expand All @@ -158,7 +185,7 @@ export default class Auth {
*/
async refreshIdToken() {
// If the idToken didn't expire, return.
if (Date.now() < this.user.tokenManager.expiresAt) return;
if (Date.now() < (this.user?.tokenManager.expiresAt ?? 0)) return;

// If a request for a new token was already made, then wait for it and then return.
if (this.refreshTokenRequest) {
Expand All @@ -173,11 +200,11 @@ export default class Auth {
// anywhere else we don't make more than one request.
this.refreshTokenRequest = this.api('token', {
grant_type: 'refresh_token',
refresh_token: this.user.tokenManager.refreshToken
refresh_token: this.user?.tokenManager.refreshToken
}).then(({ id_token: idToken, refresh_token: refreshToken }) => {
// Merge the new data with the old data and save it locally.
this.persistSession({
...this.user,
...this.user!,
// Rename the data names to match the ones used in the app.
tokenManager: { idToken, refreshToken, expiresAt }
});
Expand All @@ -193,7 +220,7 @@ export default class Auth {
* @param {Request|Object|string} resource the resource to send the request to, or an options object.
* @param {Object} init an options object.
*/
async authorizedRequest(resource, init) {
async authorizedRequest(resource: Request | RequestInfo | string, init: RequestInit) {
const request = resource instanceof Request ? resource : new Request(resource, init);

if (this.user !== null) {
Expand All @@ -208,7 +235,7 @@ export default class Auth {
* Signs in or signs up a user by exchanging a custom Auth token.
* @param {string} token The custom token.
*/
async signInWithCustomToken(token) {
async signInWithCustomToken(token: string) {
// Calculate the expiration date for the idToken.
const expiresAt = Date.now() + 3600 * 1000;
// Try to exchange the Auth Code for an idToken and refreshToken.
Expand All @@ -223,15 +250,17 @@ export default class Auth {
* Will redirect the page to the federated login page.
* @param {oauthFlowOptions|string} options An options object, or a string with the name of the provider.
*/
async signInWithProvider(options) {
async signInWithProvider(options: string | { provider: string; context: unknown, linkAccount: boolean }) {
if (!this.redirectUri)
throw Error('In order to use an Identity provider you should initiate the "Auth" instance with a "redirectUri".');

// Make sure the user is logged in when an "account link" was requested.
if (options.linkAccount) await this.enforceAuth();
if (typeof options === 'object' && options.linkAccount) await this.enforceAuth();

// The options can be a string, or an object, so here we make sure we extract the right data in each case.
const { provider, context, linkAccount } = typeof options === 'string' ? { provider: options } : options;
const { provider, context, linkAccount } = typeof options === 'string'
? { provider: options, context: undefined, linkAccount: undefined }
: options;

// Get an array of the allowed providers names.
const allowedProviders = Object.keys(this.providers);
Expand All @@ -254,7 +283,7 @@ export default class Auth {
// (No docs on this...)
sessionStorage.setItem(`Auth:SessionId:${this.apiKey}:${this.name}`, sessionId);
// Save if this is a fresh log-in or a "link account" request.
linkAccount && sessionStorage.setItem(`Auth:LinkAccount:${this.apiKey}:${this.name}`, true);
linkAccount && sessionStorage.setItem(`Auth:LinkAccount:${this.apiKey}:${this.name}`, 'true');

// Finally - redirect the page to the auth endpoint.
location.href = authUri;
Expand All @@ -280,7 +309,7 @@ export default class Auth {
// Try to exchange the Auth Code for an idToken and refreshToken.
const { idToken, refreshToken, context } = await this.api('signInWithIdp', {
// If this is a "link account" flow, then attach the idToken of the currently logged in account.
idToken: linkAccount ? this.user.tokenManager.idToken : undefined,
idToken: linkAccount ? this.user?.tokenManager.idToken : undefined,
requestUri,
sessionId,
returnSecureToken: true
Expand All @@ -290,7 +319,7 @@ export default class Auth {
await this.fetchProfile({ idToken, refreshToken, expiresAt });

// Remove sensitive data from the URLSearch params in the location bar.
history.replaceState(null, null, location.origin + location.pathname);
history.replaceState(null, '', location.origin + location.pathname);

return context;
}
Expand All @@ -305,14 +334,14 @@ export default class Auth {

// Email Sign-in flow.
if (location.href.match(/[&?]oobCode=/)) {
const oobCode = location.href.match(/[?&]oobCode=([^&]+)/)[1];
const email = location.href.match(/[?&]email=([^&]+)/)[1];
const oobCode = location.href.match(/[?&]oobCode=([^&]+)/)?.[1];
const email = location.href.match(/[?&]email=([^&]+)/)?.[1];
const expiresAt = Date.now() + 3600 * 1000;
const { idToken, refreshToken } = await this.api('signInWithEmailLink', { oobCode, email });
// Now get the user profile.
await this.fetchProfile({ idToken, refreshToken, expiresAt });
// Remove sensitive data from the URLSearch params in the location bar.
history.replaceState(null, null, location.origin + location.pathname);
history.replaceState(null, '', location.origin + location.pathname);
}
}

Expand All @@ -322,7 +351,7 @@ export default class Auth {
* @param {string} [email] The email for the user to create.
* @param {string} [password] The password for the user to create.
*/
async signUp(email, password) {
async signUp(email: string, password: string) {
// Calculate the expiration date for the idToken.
const expiresAt = Date.now() + 3600 * 1000;
const { idToken, refreshToken } = await this.api('signUp', {
Expand All @@ -340,7 +369,7 @@ export default class Auth {
* @param {string} email
* @param {string} password
*/
async signIn(email, password) {
async signIn(email: string, password: string) {
// Calculate the expiration date for the idToken.
const expiresAt = Date.now() + 3600 * 1000;
const { idToken, refreshToken } = await this.api('signInWithPassword', {
Expand All @@ -361,12 +390,12 @@ export default class Auth {
* @param {string} [email] When the `requestType` is `PASSWORD_RESET` you need to provide an email address, else it will be ignored.
* @returns {Promise}
*/
async sendOobCode(requestType, email) {
async sendOobCode(requestType: keyof typeof ActionCodeInfo.Operation, email?: string) {
const verifyEmail = requestType === 'VERIFY_EMAIL';
if (verifyEmail) await this.enforceAuth();

return void this.api('sendOobCode', {
idToken: verifyEmail ? this.user.tokenManager.idToken : undefined,
idToken: verifyEmail ? this.user?.tokenManager.idToken : undefined,
requestType,
email,
continueUrl: this.redirectUri + `?email=${email}`
Expand All @@ -379,7 +408,7 @@ export default class Auth {
* @param {string} code
* @returns {string} The email of the account to which the code was issued.
*/
async resetPassword(oobCode, newPassword) {
async resetPassword(oobCode: string, newPassword: string) {
return (await this.api('resetPassword', { oobCode, newPassword })).email;
}

Expand All @@ -388,7 +417,7 @@ export default class Auth {
* @param {string} email The user's email address.
* @returns {ProvidersForEmailResponse}
*/
async fetchProvidersForEmail(email) {
async fetchProvidersForEmail(email: string) {
const response = await this.api('createAuthUri', { identifier: email, continueUri: location.href });
delete response.kind;
return response;
Expand All @@ -402,7 +431,7 @@ export default class Auth {
async fetchProfile(tokenManager = this.user && this.user.tokenManager) {
!this.user && !tokenManager && (await this.enforceAuth());

const userData = (await this.api('lookup', { idToken: tokenManager.idToken })).users[0];
const userData = (await this.api('lookup', { idToken: tokenManager?.idToken })).users[0];

delete userData.kind;
userData.tokenManager = tokenManager;
Expand All @@ -415,14 +444,14 @@ export default class Auth {
* @param {Object} newData An object with the new data to overwrite.
* @throws Will throw if the user is not signed in.
*/
async updateProfile(newData) {
async updateProfile(newData: User) {
await this.enforceAuth();

// Calculate the expiration date for the idToken.
const expiresAt = Date.now() + 3600 * 1000;
const updatedData = await this.api('update', {
...newData,
idToken: this.user.tokenManager.idToken,
idToken: this.user?.tokenManager.idToken,
returnSecureToken: true
});

Expand All @@ -433,7 +462,7 @@ export default class Auth {
expiresAt
};
} else {
updatedData.tokenManager = this.user.tokenManager;
updatedData.tokenManager = this.user?.tokenManager;
}

delete updatedData.kind;
Expand All @@ -450,7 +479,7 @@ export default class Auth {
async deleteAccount() {
await this.enforceAuth();

await this.api('delete', `{"idToken": "${this.user.tokenManager.idToken}"}`);
await this.api('delete', `{"idToken": "${this.user?.tokenManager.idToken}"}`);

this.signOut();
}
Expand Down
34 changes: 34 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"allowJs": false,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
},
"checkJs": false,
"declaration": false,
"experimentalDecorators": true,
"importHelpers": true,
"jsx": "preserve",
"lib": ["esnext", "dom"],
"module": "commonjs",
"moduleResolution": "node",
"noErrorTruncation": true,
"noEmitHelpers": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"outDir": "build",
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": false,
"strict": true,
"target": "es2018"
},
"exclude": [
"build",
"node_modules"
]
}
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"

"@firebase/auth-types@^0.10.0":
version "0.10.0"
resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.10.0.tgz#9403633e723336055fad4bbf5e4c9fe3c55f8d3f"
integrity sha512-VuW7c+RAk3AYPU0Hxmun3RzXn7fbJDdjQbxvvpRMnQ9zrhk8mH42cY466M0n4e/UGQ+0smlx5BqZII8aYQ5XPg==

"@types/color-name@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
Expand Down Expand Up @@ -1125,6 +1130,11 @@ type-fest@^0.8.1:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==

typescript@^3.8.3:
version "3.8.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061"
integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==

uri-js@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
Expand Down

0 comments on commit c0999db

Please sign in to comment.