ExpeditionRPG / expedition

Expedition: The Cards & App RPG
https://ExpeditionGame.com
Other
79 stars 24 forks source link

Auth migration - "Sign In With Google" #904

Closed smartin015 closed 1 year ago

smartin015 commented 1 year ago

Replaces the old "gAPI"/OAuth2 login flow (deprecated) with the Sign In with Google flow (probably not deprecated for at least a couple years hopefully).

See the official announcement indicating deprecation will happen on March 31st of this year.

This PR addresses login for the web instance only, NOT for Android or iOS apps. Those apps will likely fail to authenticate when Google turns off the old API.

Internal dev log: https://docs.google.com/document/d/1kcUlTsAyu7hfTqFVWQn8zhV_Vi9_HSrNpbMu_ICWYg4/edit#

TL;DR:

Prior behavior

In the app:

  1. User clicks the "Sign In" button on the SearchDisclaimer page before seeing search results.
  2. This fires ensureLogin() action within app/src/actions/User.tsx, which calls the underlying loginWeb() from shared/auth/Web.tsx.
  3. That calls gapi.auth2.getAuthInstance().signIn (now deprecated) to open a Google-controlled popup, receive user login credentials, and parse and return user metadata of the form {email, image, name, idToken}.
  4. The app (back in app/src/actions/User.tsx) uses this metadata to configure google analytics and Raven logging, before calling updateState() to finally set the redux user state using the USER_LOGIN action.

In the quest creator:

  1. User clicks the "Log In" button on the quests/src/components/Splash.tsx page, triggering a callback to onLogin in the react container.
  2. This fires loginUser() action within quests/src/actions/User.tsx, which performs step (3) as above to get user authentication and metadata - however, it also passes two extra authorization scopes: drive.file and drive.install, needed for Google Drive file saving and manipulation.
  3. After gapi stuff returns, registerUserAndIdToken() is called (in shared/auth/API.tsx, here) which sends a POST request to the API server.
  4. This is received by the /auth/google' handler in api/src/lib/oauth2.ts, where the JSON body is parsed and req.body.id_token is sneakily used by Passport (and the passport-google-id-token mixin). Passport then
    • unpacks the JWT token
    • verifies it
    • authenticates the user to the API server
    • fetches saved user metadata from postgresql storage (see the db.users.findOne() invocation)
    • formats and returns the user metadata including last login, loot points etc. to the code in shared/auth/API.tsx.
  5. Finally, actions are fired including SET_PROFILE_META to set the user profile/state information, and loadQuestFromURL() to load either an existing or new quest depending on user action.

At this point, Google's gapi JS library is loaded with an auth token and authorized scopes. Subsequent calls to gapi.client.request() in quests/src/actions/Quest.tsx pass this info along to Google servers when calling the Drive API.

New behavior

In the app:

  1. The "Sign In" button is replaced by the "Sign In with Google" button, encapsulated in the LoginButton component in shared/auth/LoginButton.tsx.
  2. Button clicks are handled by google, but the retrieved JWT token is directed to the onLogin callback of the component, which in the App is linked to onLogin() within app/src/components/views/SearchDisclaimerContainer.tsx.
  3. This calls sendAuthTokenToAPIServer located in app/src/actions/User.tsx, which calls registerUserAndIdToken from shared/auth/Web.tsx.
  4. This is handled roughly the same as the API handling step (4) in the quest creator, however the parsing is slightly different as the Google metdata provided for the user is now encoded inside the JWT and not passed separately.
  5. The response is munged by registerUserAndIdToken, then returned to sendAuthTokenToAPIServer, which sets GA and Raven state as needed.
  6. The USER_LOGIN action and card navigation action are then fired upon return to the onLogin container callback.\

In the quest creator:

(1) and (2) are handled as in the app, i.e. now Google controls them. Only difference is the onLogin callback within quests/src/components/SplashContainer.tsx is triggered, not the one from the app.

  1. registerUserAndIdToken is called (same as app)
  2. Same API steps as in the app
  3. postLoginUser fires (in quests/src/actions/User.tsx) when registerUserAndIdToken returns. This triggers the SET_PROFILE_META and loadQuestFromURL() as in (6) of the original quest auth flow.

Google has separated authentication from authorization, so we still need to fetch an authorization token granting permission to use Drive despite having already logged in the user. These are short-lived and should be checked before executing API calls.

  1. When the user performs an action that triggers a Drive API call from quests/src/actions/Quest.tsx (e.g. updateDriveFile()), the ensureToken() wrapper is called (from quests/src/actions/User.tsx).
  2. This method checks if a valid token already exists (via. gapi.client.getToken()). If it does, it returns the cached token.
  3. Otherwise, loadGapi() is called (from shared/auth/Web.tsx, modified to call gapi.load, gapi.client.init, and gapi.client.setAPIKey thus providing all non-user config needed to function).
  4. When that returns, getAuthorizationToken is called from shared/auth/Web.tsx which calls Google's google.accounts.oauth2.initTokenClient (i.e. the new, non-gapi approach). This promps the user, then calls back with a JSON object representing the authorization token.
  5. Back in ensureToken(), gapi.auth.setToken is called with the newly retrieved token.
  6. The Drive API call proceeds as required by the user, now with authentication and authorization.