aws-amplify / amplify-js

A declarative JavaScript library for application development using cloud services.
https://docs.amplify.aws/lib/q/platform/js
Apache License 2.0
9.44k stars 2.13k forks source link

Chrome Browser Extension Support (Auth) #9260

Open revmischa opened 2 years ago

revmischa commented 2 years ago

Before opening, please confirm:

JavaScript Framework

React, Not applicable

Amplify APIs

Authentication

Amplify Categories

auth

Environment information

``` # Put output below this line ```

Describe the bug

I am building a chrome extension that uses Cognito to authenticate AppSync requests. I want to sync auth state inside my extension with the state on my website. I'm more or less able to do this with web extension manifest v2.

I'm unable to build a "modern" extension with Amplify Auth though because in manifest v3 you must use Service Workers.

When I import Amplify Auth into a Service Worker it throws this error: "Not supported" From here: https://github.com/aws-amplify/amplify-js/blob/df95ea3724eb6406f64b03f25086cd3e8644cb5f/packages/auth/src/urlListener.ts#L24

The reason is because the Service Worker context is not a web page nor node. It doesn't have window.location

Now I'm sure Amplify can do most of what it needs to do without window.location. I am able to work around this if I set:

global.window = self;
global.document = {
  // fool amplify
  location: new URL("http://foo.com") as unknown as Document["location"],
} as Document;

Also in my webpack config I need to set:

 output: {
    // no 'window'
    globalObject: "self",
}

Expected behavior

Don't explode if window.location is unavailable.

Ideally use self instead of window or this - see https://developer.mozilla.org/en-US/docs/Web/API/Window/self

Reproduction steps

Create manifest v3 browser extension and import { Auth } from "aws-amplify"

Code Snippet

// Put your code below this line.

Log output

``` // Put your logs below this line ```

aws-exports.js

No response

Manual configuration

No response

Additional configuration

No response

Mobile Device

No response

Mobile Operating System

No response

Mobile Browser

No response

Mobile Browser Version

No response

Additional information and screenshots

No response

ashika01 commented 2 years ago

@revmischa This is not currently supported in Amplify. This is marked as a feature request. Could you brief more on your use case? Could you provide example of need for amplify in browser extension?, why would the code run in a service worker?

I am unfamiliar with browser extensions, any sample code or usage could help us understand the use case better and help with getting official support for more cross platforms

revmischa commented 2 years ago

I've created a browser extension that requires users to log in to our service so that we can save their personal data. We use Cognito with OIDC and LinkedIn. It's the same flow one would use to sign in to a website except that at the end we transmit the credentials to the extension to save them there after logging in on the website so that the extension can make authenticated calls to AppSync as the user.

The service worker is the standard way to interface with other sites, acting as a persistent host to send and receive events and make API calls and store credentials. In there we call Auth.currentAuthenticatedUser().

Sending and receiving credentials is a challenge with Cognito as well. I realize the vision of having credential storage being abstracted away but I have to do some very gnarly hacks to authenticate users in the extension once they log in via Cognito in our website.

I would of course prefer a cleaner solution to this.

import {
  CognitoAccessToken,
  CognitoIdToken,
  CognitoRefreshToken,
  CognitoUser,
  CognitoUserPool,
  CognitoUserSession,
  ICognitoUserData,
} from "amazon-cognito-identity-js";
import { Auth } from "aws-amplify";
import browser from "webextension-polyfill";
import {
  COGNITO_USER_POOL_CLIENT_ID,
  COGNITO_USER_POOL_ID,
} from "../api/cognito/config";

// see: https://stackoverflow.com/questions/60244048/login-to-chrome-extension-via-website-with-aws-amplify

// this is in-memory storage backed by browser extension storage
const memStorage = new Map<string, string>();
// TODO: don't hog all of browser storage; store everything inside a single key or something
export class AmplifyAuthStorage {
  static syncPromise: Promise<void> | undefined = undefined;
  static locked: boolean = false;

  static keyPrefix = process.env.ENV_NAME || "development" + "_amplify_";

  static setItem(key_: string, value: string) {
    const key = AmplifyAuthStorage.keyPrefix + key_;
    if (!AmplifyAuthStorage.locked) browser.storage.local.set({ [key]: value }); // ->>> content script

    memStorage.set(key, value);
    return memStorage.get(key);
  }
  // get item with the key
  static getItem(key_: string) {
    const key = AmplifyAuthStorage.keyPrefix + key_;
    return memStorage.get(key) ?? null;
  }
  // remove item with the key
  static removeItem(key_: string) {
    const key = AmplifyAuthStorage.keyPrefix + key_;
    if (!AmplifyAuthStorage.locked) memStorage.delete(key);

    return browser.storage.local.remove(key);
  }
  // clear out the storage
  static clear() {
    console.debug("Clearing auth storage");
    memStorage.clear();
    if (!AmplifyAuthStorage.locked) browser.storage.local.clear();
  }

  // sync extension storage to local storage
  // https://docs.amplify.aws/lib/auth/manageusers/q/platform/js/#managing-security-tokens
  static async sync(): Promise<void> {
    if (!AmplifyAuthStorage.syncPromise) {
      AmplifyAuthStorage.syncPromise = new Promise((res, rej) => {
        // load all items from our storage
        browser.storage.local.get(null).then((saved) => {
          memStorage.clear(); // reset storage first to account for deleted items

          // sync to in-memory store (for synchronous access in getItem())
          Object.entries(saved).forEach(([key, value]) => {
            memStorage.set(key, value);
          });
          res();
        });
      });
    }
    return AmplifyAuthStorage.syncPromise;
  }

  static reset(): Promise<void> {
    AmplifyAuthStorage.syncPromise = undefined;
    return AmplifyAuthStorage.sync();
  }
}

// used to persist a serialized cognito session
// received from elsewhere (platform login page)
export const persistCognitoSession = async ({
  session,
  userPoolId,
  showNotification,
}: {
  showNotification: boolean;
  session: any;
  userPoolId?: string;
}) => {
  if (!session) {
    console.warn("Got asked to persist empty session");
    return;
  }

  // make sure we got credentials for the userpool we're talking to
  if (!userPoolId) {
    console.warn("missing userPoolId from persist session message");
  } else {
    // ensure we're getting authenticated for the correct user pool!
    if (userPoolId !== COGNITO_USER_POOL_ID) {
      console.warn(
        `Asked to save cognito credentials for user pool ${userPoolId} but we are configured for pool ${COGNITO_USER_POOL_ID}`
      );
      return;
    }
  }

  let idToken = new CognitoIdToken({
    IdToken: session.idToken.jwtToken,
  });
  let accessToken = new CognitoAccessToken({
    AccessToken: session.accessToken.jwtToken,
  });
  let refreshToken = new CognitoRefreshToken({
    RefreshToken: session.refreshToken.token,
  });
  let clockDrift = session.clockDrift;
  const sessionData = {
    IdToken: idToken,
    AccessToken: accessToken,
    RefreshToken: refreshToken,
    ClockDrift: clockDrift,
  };
  // Create the session
  let userSession = new CognitoUserSession(sessionData);
  const userData: ICognitoUserData = {
    Username: userSession.getIdToken().payload["cognito:username"],
    Pool: new CognitoUserPool({
      UserPoolId: COGNITO_USER_POOL_ID!,
      ClientId: COGNITO_USER_POOL_CLIENT_ID!,
    }),
  };
  if (!window.STORYBOOK_MODE) userData.Storage = AmplifyAuthStorage;
  // Make a new cognito user
  const cognitoUser = new CognitoUser(userData);
  // Attach the session to the user
  cognitoUser.setSignInUserSession(userSession);
  // Check to make sure it works
  cognitoUser.getSession(async (err: any, session: CognitoUserSession) => {
    if (err || !session) {
      console.error("Failed to validate new cognito session", err);
      return;
    }
    // we just saved a new session successfully
    setCurrentCognitoSession(session);
    const currentUser = await Auth.currentAuthenticatedUser({
      bypassCache: true,
    });
    console.debug("Current user is now:", currentUser);
  });
};

// no idea of any other way to override + store session
export const setCurrentCognitoSession = (session: CognitoUserSession) =>
  (Auth.currentSession = async () => session);
ykethan commented 2 years ago

hey @revmischa, this is interesting. As the service worker is a separate window that run in the background it does not have access to the tokens stored. One method we can utilize is the chrome messaging capabilities, we can get the tokens using the currentCredentials call and then pass the credentials to the service worker using the sendMessage call.

revmischa commented 2 years ago

That's exactly what I'm doing; sending creds via message to the service worker. I then need to tell the worker to save those credentials for itself.

ykethan commented 2 years ago

I understand, I did come across a workaround. Since Amplify is not using chrome.storage.local to store cookies in a service worker/background script, the workaround would be to tell amplify which storage call to use local data and auth cookies. For example.
we can use the script example from here: https://gitlab.com/kmiyashita/chrome-extension-amplify-auth/-/blob/main/packages/chrome-ext/src/common/SharedAuthStorage.ts

then import the BrowserStorage and configure amplify as following.

Amplify.configure({
  ...awsExports,
  Auth: {storage: BrowserStorage}
});

this should enable us to create a shared storage and enable us to use amplify resources. For reference this article provides additional information: https://betterprogramming.pub/developing-chrome-extensions-with-amplify-authentication-be9b9e496a06