Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Token exchange prototype #448

Closed
wants to merge 22 commits into from
Closed

Conversation

rachel-carvalho
Copy link
Contributor

WHY are these changes introduced?

Fixes #0000

WHAT is this pull request doing?

Type of change

  • Patch: Bug (non-breaking change which fixes an issue)
  • Minor: New feature (non-breaking change which adds functionality)
  • Major: Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist

  • I have used yarn changeset to create a draft changelog entry (do NOT update the CHANGELOG.md files manually)
  • I have added/updated tests for this change
  • I have documented new APIs/updated the documentation for modified APIs (for public APIs)


logger.debug('Requesting token exchange');

const {session} = await api.auth.tokenExchange({
Copy link
Contributor

@rezaansyed rezaansyed Sep 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an access token is expired or does not exist, we will perform token exchange to get a new access token.

if (persistedSession.isScopeChanged(config.scopes)) {
config.sessionStorage.deleteSession(persistedSession.id);

this.redirectToInstall(request, shop);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an edge case that can happen when the app changes scopes, while a merchant has an active app browser session. Please see the discussion for more details.

const {api, logger, config} = this;

const isXhrRequest = request.headers.get('authorization');
if (!isXhrRequest) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the request is a document load with an invalid or non-existent session token, we will redirect to the bounce page to render App Bridge and redirect to the original page with the session token passed in the authorization header

return redirectToBouncePage(new URL(request.url), {api, logger, config});
}

return new Response(undefined, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an XHR request made via App Bridge's augmented fetch, return a 401 with the X-Shopify-Invalid-Session header.


console.log('error.response', JSON.stringify(error.response));

if (error.response.code === 401) {
Copy link
Contributor

@rezaansyed rezaansyed Sep 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When making Admin API requests, a 401 signifies that the access token is invalid.

params.config.sessionStorage.deleteSession(session.id);

const isXhrRequest = request.headers.get('authorization');
if (isXhrRequest) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this scenario, we want to perform token exchange. So we delete the access token and return a 401 with X-Shopify-Invalid-Session so that the augmented fetch can retry the request, consequently triggering token exchange.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rachel-carvalho I'm starting to think we shouldn't retry ever since it basically retries the entire request to the remix server:

loader() {
  const {admin} = authenticate.admin(request)
  add_some_records_to_local_db();
  non_shopify_api_calls();
  admin.createProducts() <- this is what we want to retry but would end up re-running all the previous calls.
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth simply redirecting to /session-token?url={request.url} to allow the app to naturally perform token exchange without ever retrying the request.

},
});
} else {
throw redirect(request.url);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an Admin API request is happening on a document load, we want to delete the access token and refresh the page in order to trigger token exchange.

Comment on lines 109 to 111
} else if (isAuthRequest) {
const shop = ensureValidShopParam(request, {api, logger, config});
const installUrl = `https://${shop}/admin/oauth/install?client_id=${config.apiKey}`;
throw redirect(installUrl);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to handle auth requests in token exchange?

Copy link
Contributor

@rezaansyed rezaansyed Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is related to Option 1 of handling access scopes changes during a merchant's app browser session.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're still handling the /auth route so that the login page where the developer can type their store url to initiate install could still work, since that's a nice tool during app development.

Then instead of initiating auth code flow like the original authenticate strategy does, it just redirects the user to the shopify managed install page.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the context 👍

Comment on lines +128 to +134
const sessionToken = await validateSessionTokenUncaught(
{api, logger, config},
sessionTokenString,
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How come we need the Uncaught version?

Copy link
Contributor

@rezaansyed rezaansyed Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We catch the invalid JWT error and either return response header X-Shopify-Invalid-Session for XHR requests or redirect to bounce page. We could probably do this better but keeping this rough because we're in prototype phase.

logger.info('Authenticating admin request');

if (sessionTokenParam) {
await validateUrlParams(request, {api, logger, config});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is validing the URL params overkill given?

  1. I think we can get all the same data from the session token
  2. The session token can only be constructed using the API secret.
  3. The URL params also use the API secret.

Worth checking with app sec? If so, we get to simplify our token exchange implementation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is the ability on the browser to simply refresh the iFrame instead of the entire page. So after a minute if the merchant only reloads the iFrame, it'll use an expired token. We should avoid that as that same session token might be used to make a token exchange request (which fails).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good catch.

But why does that mean we need to validate the URL params and the session token? Why not just the session token, the URL params expire in roughly the same timeframe?


if (sessionTokenParam) {
await validateUrlParams(request, {api, logger, config});
await ensureAppIsEmbeddedIfRequired(request, {api, logger, config});
Copy link
Contributor

@byrichardpowell byrichardpowell Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting.

In auth code flow ensureAppIsEmbeddedIfRequired makes sense. In token exchange IfRequired makes less sense, because it's always required?

When we product-ionize this code these are the kind of helpers that I'd want to be careful about. I'd prefer to duplicate the helper and reduce coupling between authe code flow and token exchange

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely agree, we want the new embedded strategy to be independent from the existing auth code flow.

We wanted to only include net new token exchange code in embedded-authenticate so that the diff here in the draft PR was easier to review, and we didn't want to go too much into refactoring before build.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes complete sense, thanks Rachel.

admin: oauth.authenticateAdmin.bind(oauth),
admin: config.isEmbeddedApp
? tokenExchange.authenticateAdmin.bind(tokenExchange)
: oauth.authenticateAdmin.bind(oauth),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this ❤️

@rachel-carvalho rachel-carvalho force-pushed the token-exchange-prototype branch from 5bdfbe3 to 04439db Compare October 19, 2023 15:51
@rezaansyed rezaansyed force-pushed the token-exchange-prototype branch from 04439db to 333d14b Compare November 8, 2023 14:40
@rezaansyed rezaansyed force-pushed the token-exchange-prototype branch from 333d14b to 2398cbf Compare November 8, 2023 14:42
@rezaansyed rezaansyed closed this Jan 23, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants