Created
February 15, 2023 07:02
-
-
Save ITJesse/0fbb45ad06aaf24f4da0cf585de39b8b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Full documentation for the "identitytoolkit" API can be found here: | |
* https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts | |
*/ | |
/** | |
* Settings object for an IDP(Identity Provider). | |
* @typedef {Object} ProviderOptions | |
* @property {string} options.name The name of the provider in lowercase. | |
* @property {string} [options.scope] The scopes for the IDP, this is optional and defaults to "openid email". | |
*/ | |
/** | |
* Object response from a "fetchProvidersForEmail" request. | |
* @typedef {Object} ProvidersForEmailResponse | |
* @property {Array.<string>} allProviders All providers the user has once used to do federated login | |
* @property {boolean} registered All sign-in methods this user has used. | |
* @property {string} sessionId Session ID which should be passed in the following verifyAssertion request | |
* @property {Array.<string>} signinMethods All sign-in methods this user has used. | |
*/ | |
/** | |
* Setting object for the "startOauthFlow" method. | |
* @typedef {Object} oauthFlowOptions | |
* @property {string} provider Name of the provider to use. | |
* @property {string} [context] A string that will be returned after the Oauth flow is finished, should be used to retain context. | |
* @property {boolean} [linkAccount = false] Whether to link this oauth account with the current account. defaults to false. | |
*/ | |
const inBrowser = typeof window !== 'undefined' | |
const hasStorageApi = inBrowser && 'localStorage' in window | |
const hasListener = inBrowser && 'addEventListener' in window | |
interface StorageAPI { | |
getItem(key: string): Promise<string | null> | |
setItem(key: string, value: string): Promise<void> | |
remove(key: string): Promise<void> | |
} | |
const storageApi: StorageAPI = { | |
async setItem(k, v) { | |
if (hasStorageApi) window.localStorage.setItem(k, v) | |
}, | |
async getItem(k) { | |
if (hasStorageApi) return window.localStorage.getItem(k) | |
return null | |
}, | |
async remove(k) { | |
if (hasStorageApi) window.localStorage.removeItem(k) | |
}, | |
} | |
interface AuthArgs { | |
name?: string | |
apiKey: string | |
redirectUri?: string | |
storage?: StorageAPI | |
} | |
export interface ProviderUserInfo { | |
displayName: string | |
federatedId: string | |
photoUrl: string | |
providerId: string | |
rawId: string | |
screenName: string | |
} | |
export interface AuthUser { | |
createdAt: string | |
displayName?: string | |
lastLoginAt: string | |
validSince?: string | |
lastRefreshAt: string | |
localId: string | |
photoUrl?: string | |
email?: string | |
providerUserInfo?: Array<ProviderUserInfo> | |
screenName?: string | |
tokenManager: { | |
expiresAt: number | |
idToken: string | |
refreshToken: string | |
} | |
} | |
type OnAuthChanged = (user: AuthUser | null) => void | |
interface SignInOptions { | |
provider: string | |
oauthScope?: string | |
context?: any | |
linkAccount?: boolean | |
backToUrl?: string | |
} | |
/** | |
* Encapsulates authentication flow logic. | |
* @param {Object} options Options object. | |
* @param {string} options.apiKey The firebase API key | |
* @param {string} options.redirectUri The redirect URL used by OAuth providers. | |
* @param {Array.<ProviderOptions|string>} options.providers Array of arguments that will be passed to the addProvider method. | |
*/ | |
export default class Auth { | |
storage: StorageAPI | |
name: string | |
apiKey: string | |
user: AuthUser | null = null | |
listeners: Array<OnAuthChanged> = [] | |
redirectUri: string | undefined | |
private _ref?: Promise<any> | |
constructor({ name, apiKey, redirectUri, storage }: AuthArgs) { | |
if (typeof apiKey !== 'string') | |
throw Error('The argument "apiKey" is required') | |
this.apiKey = apiKey | |
this.storage = storage ?? storageApi | |
this.name = name ?? 'default' | |
this.redirectUri = redirectUri | |
this.storage.getItem(this.sKey('User')).then((user) => { | |
this.setState(user ? JSON.parse(user) : null, false) | |
if (this.user) | |
this.refreshIdToken().catch((e) => { | |
if ( | |
e.message === 'TOKEN_EXPIRED' || | |
e.message === 'INVALID_ID_TOKEN' | |
) { | |
return this.signOut() | |
} | |
throw e | |
}) | |
}) | |
// Because this library is used in react native, outside the browser as well, | |
// we need to first check if this environment supports `addEventListener` on the window. | |
hasListener && | |
window.addEventListener('storage', (e) => { | |
// This code will run if localStorage for this user | |
// data was updated from a different browser window. | |
if (e.key !== this.sKey('User') || !e.newValue) return | |
this.setState(JSON.parse(e.newValue), false) | |
}) | |
} | |
/** | |
* Emits an event and triggers all of the listeners. | |
* @param {string} name The name of the event to trigger. | |
* @param {any} data The data you want to pass to the event listeners. | |
* @private | |
*/ | |
emit() { | |
this.listeners.forEach((cb) => cb(this.user)) | |
} | |
/** | |
* 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. | |
* @returns {function} function that will unsubscribe your callback from being called. | |
*/ | |
listen(cb: OnAuthChanged): () => void { | |
this.listeners.push(cb) | |
// Return a function to unbind the callback. | |
return () => { | |
this.listeners = this.listeners.filter((fn) => fn !== cb) | |
} | |
} | |
/** | |
* Generates a unique storage key for this app. | |
* @private | |
*/ | |
sKey(key: string) { | |
return `Auth:${key}:${this.apiKey}:${this.name}` | |
} | |
/** | |
* Make post request to a specific endpoint, and return the response. | |
* @param {string} endpoint The name of the endpoint. | |
* @param {any} request Body to pass to the request. | |
* @private | |
*/ | |
async api<T = any>(endpoint: string, body: any) { | |
const url = | |
endpoint === 'token' | |
? `https://securetoken.googleapis.com/v1/token?key=${this.apiKey}` | |
: `https://identitytoolkit.googleapis.com/v1/accounts:${endpoint}?key=${this.apiKey}` | |
const response = await fetch(url, { | |
method: 'POST', | |
body: typeof body === 'string' ? body : JSON.stringify(body), | |
}) | |
const data = await response.json() | |
// If the response returned an error, try to get a Firebase error code/message. | |
// Sometimes the error codes are joined with an explanation, we don't need that(its a bug). | |
// So we remove the unnecessary part. | |
if (!response.ok) { | |
const code = data.error.message.replace(/: [\w ,.'"()]+$/, '') | |
throw Error(code) | |
} | |
// Add a hidden date property to the returned object. | |
// Used mostly to calculate the expiration date for tokens. | |
const date = response.headers.get('date') | |
if (date) | |
Object.defineProperty(data, 'expiresAt', { | |
value: Date.parse(date) + 3600 * 1000, | |
}) | |
return data as T | |
} | |
/** | |
* Makes sure the user is logged in and has up-to-date credentials. | |
* @throws Will throw if the user is not logged in. | |
* @private | |
*/ | |
async enforceAuth() { | |
if (!this.user) | |
throw Error('The user must be logged-in to use this method.') | |
return this.refreshIdToken() // Won't do anything if the token is valid. | |
} | |
/** | |
* Updates the user data in the localStorage. | |
* @param {Object} userData the new user data. | |
* @param {boolean} [updateStorage = true] Whether to update local storage or not. | |
* @private | |
*/ | |
async setState(userData: AuthUser | null, persist = true, emit = true) { | |
this.user = userData | |
if (persist) { | |
if (userData) { | |
this.storage.setItem(this.sKey('User'), JSON.stringify(userData)) | |
} else { | |
this.storage.remove(this.sKey('User')) | |
} | |
} | |
emit && this.emit() | |
} | |
/** | |
* Sign out the currently signed in user. | |
* Removes all data stored in the storage that's associated with the user. | |
*/ | |
signOut() { | |
return this.setState(null) | |
} | |
/** | |
* Refreshes the idToken by using the locally stored refresh token | |
* only if the idToken has expired. | |
*/ | |
async refreshIdToken() { | |
if (!this.user) return this.emit() | |
const user = this.user | |
// If the idToken didn't expire, return. | |
if (Date.now() < user.tokenManager.expiresAt) return this.emit() | |
// If a request for a new token was already made, then wait for it and then return. | |
if (this._ref) { | |
return void (await this._ref) | |
} | |
try { | |
// Save the promise so that if this function is called | |
// anywhere else we don't make more than one request. | |
this._ref = this.api('token', { | |
grant_type: 'refresh_token', | |
refresh_token: user.tokenManager.refreshToken, | |
}).then((data) => { | |
const tokenManager = { | |
idToken: data.id_token, | |
refreshToken: data.refresh_token, | |
expiresAt: data.expiresAt, | |
} | |
return this.setState({ ...user, tokenManager }, true, false) | |
}) | |
await this._ref | |
await this.fetchProfile() | |
} finally { | |
this._ref = undefined | |
} | |
} | |
/** | |
* Uses native fetch, but adds authorization headers otherwise the API is exactly the same as native fetch. | |
* @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: string | Request, init?: RequestInit) { | |
const request = | |
resource instanceof Request ? resource : new Request(resource, init) | |
if (this.user) { | |
await this.refreshIdToken() // Won't do anything if the token didn't expire yet. | |
request.headers.set( | |
'Authorization', | |
`Bearer ${this.user.tokenManager.idToken}`, | |
) | |
} | |
return fetch(request) | |
} | |
/** | |
* Signs in or signs up a user by exchanging a custom Auth token. | |
* @param {string} token The custom token. | |
*/ | |
async signInWithCustomToken(token: string) { | |
// Try to exchange the Auth Code for an idToken and refreshToken. | |
// And then get the user profile. | |
return await this.fetchProfile( | |
await this.api('signInWithCustomToken', { | |
token, | |
returnSecureToken: true, | |
}), | |
) | |
} | |
/** | |
* Start auth flow of a federated Id provider. | |
* 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: string | SignInOptions) { | |
if (!this.redirectUri) | |
throw Error( | |
'In order to use an Identity provider you should initiate the "Auth" instance with a "redirectUri".', | |
) | |
// The options can be a string, or an object, so here we make sure we extract the right data in each case. | |
const { | |
provider, | |
oauthScope, | |
context, | |
linkAccount, | |
backToUrl, | |
}: SignInOptions = | |
typeof options !== 'string' ? options : { provider: options } | |
// Make sure the user is logged in when an "account link" was requested. | |
if (linkAccount) await this.enforceAuth() | |
const redirectUrl = new URL(this.redirectUri) | |
if (backToUrl) redirectUrl.searchParams.set('backToUrl', backToUrl) | |
// Get the url and other data necessary for the authentication. | |
const { authUri, sessionId } = await this.api('createAuthUri', { | |
continueUri: redirectUrl.toString(), | |
authFlowType: 'CODE_FLOW', | |
providerId: provider, | |
oauthScope, | |
context, | |
}) | |
// Save the sessionId that we just received in the local storage. | |
// Is required to finish the auth flow, I believe this is used to mitigate CSRF attacks. | |
// (No docs on this...) | |
await this.storage.setItem(this.sKey('SessionId'), sessionId) | |
// Save if this is a fresh log-in or a "link account" request. | |
linkAccount && | |
(await this.storage.setItem(this.sKey('LinkAccount'), 'true')) | |
// Finally - redirect the page to the auth endpoint. | |
location.assign(authUri) | |
} | |
/** | |
* Signs in or signs up a user using credentials from an Identity Provider (IdP) after a redirect. | |
* Will fail silently if the URL doesn't have a "code" search param. | |
* @param [requestUri] The request URI with the authorization code, state etc. from the IdP. | |
*/ | |
async finishProviderSignIn(requestUri = location.href) { | |
if (!requestUri.includes('state=')) return | |
// Get the sessionId we received before the redirect from storage. | |
const sessionId = await this.storage.getItem(this.sKey('SessionId')) | |
// Get the indication if this was a "link account" request. | |
const linkAccount = await this.storage.getItem(this.sKey('LinkAccount')) | |
// Check for the edge case in which the user signed out before completing the linkAccount | |
// Request. | |
if (linkAccount && !this.user) { | |
throw Error( | |
'Request to "Link account" was made, but user is no longer signed-in', | |
) | |
} | |
await this.storage.remove(this.sKey('LinkAccount')) | |
// Try to exchange the Auth Code for an idToken and refreshToken. | |
const { idToken, refreshToken, expiresAt } = 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, | |
requestUri, | |
sessionId, | |
returnSecureToken: true, | |
}, | |
) | |
// 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, '', location.origin + location.pathname) | |
return this.user | |
} | |
/** | |
* Handles all sign in flows that complete via redirects. | |
* Fails silently if no redirect was detected. | |
*/ | |
async handleSignInRedirect() { | |
if (!inBrowser) return | |
// Oauth Federated Identity Provider flow. | |
if (location.href.match(/[&?]code=/)) return this.finishProviderSignIn() | |
// Email Sign-in flow. | |
const href = location.href | |
if (!href) return | |
if (href.match(/[&?]oobCode=/)) { | |
let m = href.match(/[?&]oobCode=([^&]+)/) | |
const oobCode = m && m.length > 0 ? m[1] : undefined | |
m = href.match(/[?&]email=([^&]+)/) | |
const email = m && m.length > 0 ? m[1] : undefined | |
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, '', location.origin + location.pathname) | |
} | |
} | |
/** | |
* Signs up with email and password or anonymously when no arguments are passed. | |
* Automatically signs the user in on completion. | |
* @param {string} [email] The email for the user to create. | |
* @param {string} [password] The password for the user to create. | |
*/ | |
async signUp(email?: string, password?: string) { | |
// Sign up and then retrieve the user profile and persists the session. | |
return await this.fetchProfile( | |
await this.api('signUp', { | |
email, | |
password, | |
returnSecureToken: true, | |
}), | |
) | |
} | |
/** | |
* Signs in a user with email and password. | |
* @param {string} email | |
* @param {string} password | |
*/ | |
async signIn(email: string, password: string) { | |
// Sign up and then retrieve the user profile and persists the session. | |
return await this.fetchProfile( | |
await this.api('signInWithPassword', { | |
email, | |
password, | |
returnSecureToken: true, | |
}), | |
) | |
} | |
/** | |
* Sends an out-of-band confirmation code for an account. | |
* Can be used to reset a password, to verify an email address and send a Sign-in email link. | |
* The `email` argument is not needed only when verifying an email(In that case it will be completely ignored, even if specified), otherwise it is required. | |
* @param {'PASSWORD_RESET'|'VERIFY_EMAIL'|'EMAIL_SIGNIN'} requestType The type of out-of-band (OOB) code to send. | |
* @param {string} [email] When the `requestType` is `PASSWORD_RESET` or `EMAIL_SIGNIN` you need to provide an email address. | |
* @returns {Promise} | |
*/ | |
async sendOobCode( | |
requestType: 'PASSWORD_RESET' | 'VERIFY_EMAIL' | 'EMAIL_SIGNIN', | |
email?: string, | |
): Promise<void> { | |
const verifyEmail = requestType === 'VERIFY_EMAIL' | |
if (verifyEmail) { | |
await this.enforceAuth() | |
email = this.user?.email | |
} | |
await this.api('sendOobCode', { | |
idToken: verifyEmail ? this.user?.tokenManager?.idToken : undefined, | |
requestType, | |
email, | |
continueUrl: this.redirectUri + `?email=${email}`, | |
}) | |
return | |
} | |
/** | |
* Sets a new password by using a reset code. | |
* Can also be used to very oobCode by not passing a password. | |
* @param {string} code | |
* @returns {string} The email of the account to which the code was issued. | |
*/ | |
async resetPassword(oobCode: string, newPassword: string): Promise<string> { | |
return (await this.api('resetPassword', { oobCode, newPassword })).email | |
} | |
// /** | |
// * Returns info about all providers associated with a specified email. | |
// * @param {string} email The user's email address. | |
// * @returns {ProvidersForEmailResponse} | |
// */ | |
// async fetchProvidersForEmail(email: string) { | |
// const response = await this.api('createAuthUri', { | |
// identifier: email, | |
// continueUri: location.href, | |
// }) | |
// delete response.kind | |
// return response | |
// } | |
/** | |
* Gets the user data from the server, and updates the local caches. | |
* @param [tokenManager] Only when not logged in. | |
* @throws Will throw if the user is not signed in. | |
*/ | |
async fetchProfile(tokenManager = this.user?.tokenManager) { | |
if (!tokenManager) await this.enforceAuth() | |
if (!tokenManager) return | |
const { users } = await this.api<{ users: Array<AuthUser> }>('lookup', { | |
idToken: tokenManager.idToken, | |
}) | |
if (Array.isArray(users) && users.length > 0) { | |
const userData = users[0] | |
if ('kind' in userData) delete (userData as any)['kind'] | |
userData.tokenManager = tokenManager | |
await this.setState(userData) | |
} | |
} | |
// /** | |
// * Update user's profile. | |
// * @param {Object} newData An object with the new data to overwrite. | |
// * @throws Will throw if the user is not signed in. | |
// */ | |
// async updateProfile(newData) { | |
// await this.enforceAuth() | |
// // Calculate the expiration date for the idToken. | |
// const updatedData = await this.api('update', { | |
// ...newData, | |
// idToken: this.user.tokenManager.idToken, | |
// returnSecureToken: true, | |
// }) | |
// const { idToken, refreshToken, expiresAt } = updatedData | |
// if (updatedData.idToken) { | |
// updatedData.tokenManager = { idToken, refreshToken, expiresAt } | |
// } else { | |
// updatedData.tokenManager = this.user.tokenManager | |
// } | |
// delete updatedData.kind | |
// delete updatedData.idToken | |
// delete updatedData.refreshToken | |
// await this.setState(updatedData) | |
// } | |
// /** | |
// * Deletes the currently logged in account and logs out. | |
// * @throws Will throw if the user is not signed in. | |
// */ | |
// async deleteAccount() { | |
// await this.enforceAuth() | |
// await this.api( | |
// 'delete', | |
// `{"idToken": "${this.user?.tokenManager.idToken}"}`, | |
// ) | |
// this.signOut() | |
// } | |
async deleteAccountByToken(token: string) { | |
await this.api('delete', `{"idToken": "${token}"}`) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment