import { LitElement, html, css, customElement, property, TemplateResult } from 'lit-element'; import { SignInResult } from './signin-result'; import { SignInProvider } from './signin-provider'; import { FederatedCredential } from './federated-credential'; import { ProviderInfo } from './provider-info'; //Exported public api. export { SignInResult } from './signin-result'; export interface PwaAuth { appearance?: "button" | "list" | "none"; signInButtonText?: string; microsoftButtonText?: string; googleButtonText?: string; facebookButtonText?: string; appleButtonText?: string; appleRedirectUri?: string | undefined | null; microsoftKey?: string | undefined | null; googleKey?: string | undefined | null; facebookKey?: string | undefined | null; appleKey?: string | undefined | null; credentialMode?: "none" | "silent" | "prompt"; menuOpened?: boolean; menuPlacement?: "start" | "end"; disabled?: boolean; iconLoading?: string; requireNewAccessToken?: boolean; signIn?: (providerName: ProviderName) => Promise<SignInResult> } export type ProviderName = "Microsoft" | "Google" | "Facebook" | "Apple"; type StoredAccessToken = { token: string | null; expiration: Date | null, providerData: Object | null | undefined }; @customElement('pwa-auth') export class PwaAuthImpl extends LitElement implements PwaAuth { @property({ type: String, reflect: true }) appearance: "button" | "list" | "none" = "button"; @property({ type: String }) signInButtonText = "Sign in"; @property({ type: String }) microsoftButtonText = "Sign in with Microsoft"; @property({ type: String }) googleButtonText = "Sign in with Google"; @property({ type: String }) facebookButtonText = "Sign in with Facebook"; @property({ type: String }) appleButtonText = "Sign in with Apple"; @property({ type: String }) appleRedirectUri: string | undefined | null; @property({ type: String }) microsoftKey: string | undefined | null; @property({ type: String }) googleKey: string | undefined | null; @property({ type: String }) facebookKey: string | undefined | null; @property({ type: String }) appleKey: string | undefined | null; @property({ type: String }) credentialMode: "none" | "silent" | "prompt" = "silent"; @property({ type: Boolean }) menuOpened = false; @property({ type: String, reflect: true }) menuPlacement: "start" | "end" = "start"; @property({ type: Boolean }) disabled = false; @property({ type: String }) iconLoading: "lazy" | "eager" = "lazy"; @property({ type: Boolean }) requireNewAccessToken = false; // If true, user always goes through OAuth flow to acquire a new access token. If false, user can sign-in using a stored credential with possibly stale access token. readonly providers: ProviderInfo[] = [ { name: "Microsoft", url: "https://graph.microsoft.com", getKey: () => this.microsoftKey, getButtonText: () => this.microsoftButtonText, getIconUrl: () => this.getMicrosoftIconUrl(), import: (key: string) => this.importMicrosoftProvider(key), btnClass: "microsoft-btn", buttonPartName: "microsoftButton", containerPartName: "microsoftContainer", iconPartName: "microsoftIcon", signIn: () => this.signIn("Microsoft") }, { name: "Google", url: "https://account.google.com", getKey: () => this.googleKey, getButtonText: () => this.googleButtonText, getIconUrl: () => this.getGoogleIconUrl(), import: (key: string) => this.importGoogleProvider(key), btnClass: "google-btn", buttonPartName: "googleButton", containerPartName: "googleContainer", iconPartName: "googleIcon", signIn: () => this.signIn("Google") }, { name: "Facebook", url: "https://www.facebook.com", getKey: () => this.facebookKey, getButtonText: () => this.facebookButtonText, getIconUrl: () => this.getFacebookIconUrl(), import: (key: string) => this.importFacebookProvider(key), btnClass: "facebook-btn", buttonPartName: "facebookButton", containerPartName: "facebookContainer", iconPartName: "facebookIcon", signIn: () => this.signIn("Facebook") }, { name: "Apple", url: "https://appleid.apple.com", getKey: () => this.appleKey, getButtonText: () => this.appleButtonText, getIconUrl: () => this.getAppleIconUrl(), import: (key: string) => this.importAppleProvider(key), btnClass: "apple-btn", buttonPartName: "appleButton", containerPartName: "appleContainer", iconPartName: "appleIcon", signIn: () => this.signIn("Apple") }, ]; static readonly assetBaseUrl = "https://cdn.jsdelivr.net/npm/@pwabuilder/pwaauth@latest/assets"; static readonly authTokenLocalStoragePrefix = "pwa-auth-token"; static styles = css` :host { display: inline-block; } button { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } :host([appearance="list"]) .provider { width: 200px; } :host([appearance="list"]) .provider + .provider { margin-top: 10px; } :host([appearance="list"]) .provider button { display: block; width: 100%; padding: 10px; cursor: pointer; border-radius: 2px; border-width: 0; text-align: left; } :host([appearance="list"]) .provider button img { vertical-align: middle; margin-right: 10px; margin-left: 5px; } :host([appearance="list"]) .google-btn { background-color: white; border: 1px solid rgb(192, 192, 192); } :host([appearance="list"]) .google-btn:hover { background-color: rgb(245, 245, 246); } :host([appearance="list"]) .microsoft-btn { color: white; background-color: rgb(84, 84, 84); } :host([appearance="list"]) .microsoft-btn:hover { background-color: rgb(47, 51, 55); } :host([appearance="list"]) .facebook-btn { color: white; background-color: #385499; } :host([appearance="list"]) .facebook-btn:hover { background-color: #314a86; } :host([appearance="list"]) .apple-btn { background-color: black; color: white; } .signin-btn { background-color: rgb(225, 230, 234); border: 1px solid rgb(220, 224, 229); color: rgb(33, 37, 41); border-radius: 4px; padding: 12px; transition: all 0.15s ease-in-out; outline: none; cursor: pointer; } .signin-btn:hover:not(:disabled) { background-color: rgb(220, 224, 228); border-color: rgb(212, 218, 223); } .signin-btn:focus { background-color: rgb(219, 225, 230); border-color: rgb(212, 218, 224); box-shadow: rgba(216, 217, 219, 0.1) 0 0 0 3.2px; } .signin-btn:active { background-color: rgb(210, 214, 218); border-color: rgb(202, 208, 213); } .signin-btn:disabled { color: rgba(16, 16, 16, 0.3); } .dropdown { position: relative; display: inline-block; } .dropdown .menu { position: absolute; top: 100%; left: 0; z-index: 1000; display: none; float: left; min-width: 10rem; padding: .5rem 0; margin: .125rem 0 0; font-size: 1rem; background-color: white; background-clip: padding-box; border: 1px solid rgba(0,0,0,.15); border-radius: .25rem; cursor: pointer; } .dropdown .menu.open { display: block; transform: translate3d(0px, 38px, 0px); top: 0; left: 0; animation-name: dropdown-animation; animation-duration: 300ms; } .dropdown .menu.open.align-end { left: auto; right: 0; } .dropdown .menu button { background-color: transparent; white-space: nowrap; border: none; outline: none; padding: 8px 24px 8px 24px; cursor: pointer; width: 100%; text-align: left; } .dropdown .menu button:hover { background-color: rgb(245, 246, 247); } .dropdown .menu button:active { background-color: rgb(240, 241, 242); } .dropdown .menu button img { vertical-align: middle; margin-right: 10px; } .provider-error { background-color: rgb(220, 53, 69); color: white; padding: 20px; } @keyframes dropdown-animation { from { opacity: 0; } to { opacity: 1; } } @media(prefers-reduced-motion: reduce) { .dropdown .menu.open { animation: none; } } `; firstUpdated() { // If we're on Safari, we need to load dependencies up front to avoid Safari // blocking the first OAuth popup. See https://github.com/pwa-builder/pwa-auth/issues/3 if (this.isWebKit()) { this.disabled = true; this.loadAllDependencies() .finally(() => this.disabled = false); } } render() { if (!this.hasAnyKey) { return this.renderNoKeysError(); } if (this.appearance === "list") { return this.renderListButtons(); } if (this.appearance === "button") { return this.renderLoginButton(); } return super.render(); } /** * Starts the sign-in process using the specified provider. * @param providerName The name provider to sign-in with. Must be "Microsoft", "Google", "Facebook", or "Apple" */ public signIn(providerName: ProviderName): Promise<SignInResult> { const provider = this.providers.find(p => p.name === providerName); if (!provider) { const errorMessage = "Unable to sign-in because of unsupported provider"; console.error(errorMessage, providerName); return Promise.reject(errorMessage + " " + providerName); } return this.signInWithProvider(provider) .then(result => this.signInCompleted(result)); } private getMicrosoftIconUrl(): string { if (this.appearance === "button") { return `${PwaAuthImpl.assetBaseUrl}/microsoft-icon-button.svg`; } return `${PwaAuthImpl.assetBaseUrl}/microsoft-icon-list.svg`; } private getGoogleIconUrl(): string { return `${PwaAuthImpl.assetBaseUrl}/google-icon.svg`; } private getFacebookIconUrl(): string { if (this.appearance === "button") { return `${PwaAuthImpl.assetBaseUrl}/facebook-icon-button.svg`; } return `${PwaAuthImpl.assetBaseUrl}/facebook-icon-list.svg`; } private getAppleIconUrl(): string { if (this.appearance === "button") { return `${PwaAuthImpl.assetBaseUrl}/apple-icon-button.svg`; } return `${PwaAuthImpl.assetBaseUrl}/apple-icon-list.svg`; } private renderLoginButton(): TemplateResult { return html` <div class="dropdown" @focusout="${this.dropdownFocusOut}"> <button class="signin-btn" part="signInButton" ?disabled=${this.disabled} @click="${this.signInClicked}"> ${this.signInButtonText} </button> <div class="menu ${this.menuOpened ? "open" : ""} ${this.menuPlacement === "end" ? "align-end" : ""}" part="dropdownMenu"> ${this.renderListButtons()} </div> </div> `; } private renderListButtons(): TemplateResult { return html` ${this.providers .filter(provider => !!provider.getKey()) .map(provider => html` <div class="provider" part="${provider.containerPartName}"> <button class="${provider.btnClass}" ?disabled=${this.disabled} part="${provider.buttonPartName}" @click="${provider.signIn}"> <img part="${provider.iconPartName}" loading="${this.iconLoading}" width="20px" height="20px" src="${provider.getIconUrl()}" /> ${provider.getButtonText()} </button> </div> `)} `; } private renderNoKeysError(): TemplateResult { return html`<div class="provider-error"><strong>❌ No available sign-ins</strong><br><em>To enable sign-in, pass a Microsoft key, Google key, Facebook, or Apple key to the <pwa-auth> component.</em><br><pre><pwa-auth microsoftkey="..."></pwa-auth></pre></div>`; } private dropdownFocusOut(e: FocusEvent) { // Close the dropdown if the focus is no longer within it. if (this.menuOpened) { const dropdown = this.shadowRoot?.querySelector(".dropdown"); const dropdownContainsFocus = dropdown?.matches(":focus-within"); if (!dropdownContainsFocus) { this.menuOpened = false; } } } private get hasAnyKey(): boolean { return this.providers.some(p => !!p.getKey()); } private async signInClicked() { // Are we configured to use browser credentials (the new CredentialStore API)? // If so, go ahead and sign in with whatever stored credential we have. if (this.credentialMode === "none") { this.toggleMenu(); } else { const signedInCreds = await this.tryAutoSignIn(); if (!signedInCreds) { // There was no stored credential to sign in with. Just show the menu. this.toggleMenu(); } } } private toggleMenu() { this.menuOpened = !this.menuOpened; } private signInWithProvider(provider: ProviderInfo) { const key = provider.getKey(); if (!key) { return Promise.reject("No key specified"); } if (this.disabled) { return Promise.reject("Sign-in already in progress, rejecting new sign-in attempt"); } this.disabled = true; this.menuOpened = false; return this.trySignInWithStoredCredential(provider.url) .then(storedCredSignInResult => { // Did we sign in with a stored credential? Good, we're done. if (storedCredSignInResult) { return storedCredSignInResult; } // Couldn't sign in with stored credential. // Kick off the provider-specified OAuth flow. return provider.import(key) .then(p => p.signIn()) .catch(error => { // If the provider sends back an error, consider that a SignInResult const providerError: SignInResult = { error: error, provider: provider.name }; return providerError; }) }) .finally(() => this.disabled = false); } private signInCompleted(signIn: SignInResult): SignInResult { this.rehydrateAccessToken(signIn); this.dispatchEvent(new CustomEvent("signin-completed", { detail: signIn })); this.tryStoreCredential(signIn); return signIn; } private importMicrosoftProvider(key: string): Promise<SignInProvider> { return import("./microsoft-provider") .then(module => new module.MicrosoftProvider(key)); } private importGoogleProvider(key: string): Promise<SignInProvider> { return import("./google-provider") .then(module => new module.GoogleProvider(key)); } private importFacebookProvider(key: string): Promise<SignInProvider> { return import ("./facebook-provider") .then(module => new module.FacebookProvider(key)); } private importAppleProvider(key: string): Promise<SignInProvider> { return import ("./apple-provider") .then(module => new module.AppleProvider(key, this.appleRedirectUri)); } private tryStoreCredential(signIn: SignInResult) { // Use the new Credential Management API to store the credential, allowing for automatic sign-in next time the user visits the page. // https://developers.google.com/web/fundamentals/security/credential-management/ const federatedCredentialCtor = window["FederatedCredential"]; if (signIn.email && federatedCredentialCtor) { try { const cred = new federatedCredentialCtor({ id: signIn.email, provider: this.providers.find(p => p.name === signIn.provider)?.url || signIn.provider, name: signIn.name || "", iconURL: signIn.imageUrl || "" }); navigator.credentials.store(cred); } catch (error) { console.error("Unable to store federated credential", error); } } } private async tryAutoSignIn(): Promise<FederatedCredential | null> { // Use the new Credential Management API to login the user automatically. // https://developers.google.com/web/fundamentals/security/credential-management/ // Bail if we don't support Credential Management if (!window["FederatedCredential"]) { return null; } // Bail if we're forcing OAuth flow. if (this.requireNewAccessToken) { return null; } let credential: FederatedCredential | null = null; if (this.credentialMode === "prompt") { // Let the user choose. // The browser brings up the native "choose your sign in" dialog. credential = await this.getStoredCredential("required", this.providers.map(p => p.url)); } else if (this.credentialMode === "silent") { // Go through the available providers and find one that the user has logged in with. for (let i = 0; i < this.providers.length; i++) { const provider = this.providers[i]; credential = await this.getStoredCredential("silent", [provider.url]); if (credential) { break; } } } if (credential) { const loginResult = this.credentialToSignInResult(credential); this.signInCompleted(loginResult); } return credential; } private trySignInWithStoredCredential(providerUrl: string): Promise<SignInResult | null> { return this.getStoredCredential("silent", [providerUrl]) .catch(error => console.warn("Error attempting to sign-in with stored credential", error)) .then(credential => credential ? this.credentialToSignInResult(credential) : null); } private getStoredCredential(mediation: string, providerUrls: string[]): Promise<FederatedCredential | null> { // Bail if we don't support Credential Management if (!window["FederatedCredential"]) { return Promise.resolve(null); } // Bail if we're not allowed to use stored credential. if (this.requireNewAccessToken) { return Promise.resolve(null); } const credOptions: any = { mediation: mediation, federated: { providers: providerUrls } }; return navigator.credentials.get(credOptions); } private credentialToSignInResult(cred: FederatedCredential): SignInResult { return { name: cred.name, email: cred.id, providerData: null, imageUrl: cred.iconURL, error: null, provider: this.getProviderNameFromUrl(cred.provider!) as ProviderName }; } private getProviderNameFromUrl(url: string): ProviderName { const provider = this.providers.find(p => p.url === url); if (!provider) { console.warn("Unable to find provider matching URL", url); return "Microsoft"; } return provider.name; } private isWebKit(): boolean { // As of April 2020, Webkit-based browsers wrongfully blocks // the OAuth popup due to lazy-loading the auth library(s). const isIOS = !!navigator.userAgent.match(/ipad|iphone/i); // everything is WebKit on iOS const isSafari = !!navigator.vendor && navigator.vendor.includes("Apple"); return isIOS || isSafari; } private loadAllDependencies(): Promise<any> { const dependencyLoadTasks = this.providers .filter(p => !!p.getKey()) .map(p => p.import(p.getKey()!).then(p => p.loadDependencies())); return Promise.all(dependencyLoadTasks) .catch(error => console.error("Error loading dependencies", error)); } private tryUpdateStoredTokenInfo(signIn: SignInResult) { const localStorageKey = this.getAuthTokenLocalStorageKeyName(signIn.provider); const storedToken: StoredAccessToken = { token: signIn.accessToken || null, expiration: signIn.accessTokenExpiration || null, providerData: signIn.providerData }; try { localStorage.setItem(localStorageKey, JSON.stringify(storedToken)); } catch (error) { console.warn("Unable to store auth token in local storage", localStorageKey, signIn, error); } } private tryReadStoredTokenInfo(providerName: ProviderName): StoredAccessToken | null { const localStorageKey = this.getAuthTokenLocalStorageKeyName(providerName); try { const tokenJson = localStorage.getItem(localStorageKey); return tokenJson ? JSON.parse(tokenJson) : null; } catch (error) { console.warn("Unable to read auth token from local storage", localStorageKey, error); return null; } } private getAuthTokenLocalStorageKeyName(providerName: string): string { return `${PwaAuthImpl.authTokenLocalStoragePrefix}-${providerName}`; } private rehydrateAccessToken(signIn: SignInResult) { if (signIn.accessToken) { // If the user signed in with OAuth flow just now, we already have the auth token. // Store it for later. this.tryUpdateStoredTokenInfo(signIn); } else { // We don't have an access token, meaning we signed-in with a stored credential. // Thus, we'll fetch it from local storage. const tokenInfo = this.tryReadStoredTokenInfo(signIn.provider); if (tokenInfo) { signIn.accessToken = tokenInfo.token; signIn.accessTokenExpiration = tokenInfo.expiration; signIn.providerData = tokenInfo.providerData; } } } }