Skip to content
This repository has been archived by the owner on Apr 11, 2024. It is now read-only.

Support for creating an offline token #936

Closed
claridgicus opened this issue Jul 5, 2023 · 33 comments
Closed

Support for creating an offline token #936

claridgicus opened this issue Jul 5, 2023 · 33 comments

Comments

@claridgicus
Copy link

Overview

For background processes, it's pretty unclear in the documentation how I would go about making the equivalency of an "offline session" in the terminology of the package.

I would expect I would be able to pass an access token, shop url etc to my "rest" instance and be able to use the functions of the package without having to deal with an actual user "session".

I could 100% have missed this feature in the package - if so, maybe the reference to that could be made more present in the documentation?

@marcdelalonde
Copy link

marcdelalonde commented Jul 6, 2023

I am also interested !

Seems like the accessToken I get from the session after a shopify.auth.callback call is not an offline token... :(

Thank you

@paulomarg
Copy link
Contributor

Hey folks, thanks for your question! When calling shopify.auth.begin, you can use the isOnline parameter to determine whether the OAuth process will create an online or offline access token.

Since the feature is there, I'll keep this issue open so we can improve the documentation around it, but please let us know if you have any issues creating offline tokens.

@javl
Copy link

javl commented Aug 11, 2023

@paulomarg, I'm wondering how to create an offline session after the latest update (with the Remix template).

Before the summer update I used to be able to create an endpoint at my app proxy like so:

app.get('/my-endpoint', async(_req, res) => {
  const session = await createOfflineSession(_req.query.shop, res);
  const client = new shopify.api.clients.Graphql({ session });
  const create_result = await client.query({
    data: {
      query: ...,
      variables: ...,
    },
  });
}

Where createOfflineSession is:

export async function createOfflineSession(shop, res) {
  const session = shopify.api.session.customAppSession(shop);
  session.accessToken = await getTokenFromDB(shop);
  if (session.accessToken == undefined) {
    console.log('no token found for shop ', shop);
    res.status(403).send({
      success: false,
      error: "Unable to create session"
    });
    return;
  }
  return session;
}

This way I could have an endpoint on my app proxy that would be able to run graphql commands without the need of an admin. But this doesn't work in the new system. shopify.api.session.customAppSession doesn't exist anymore, and neither does the code contain any reference to the shopify.auth.begin you mention. customAppSession still appears in the source but I'm not sure how to call it after the update.

How can I create an offline session with the new setup? It seems pretty much all documentation still uses the old Express setup, not the new Remix version (which in my option is a great update by the way).

Edit
As a quick sidenote: this is a simplified code example, as running this directly would allow anyone to change the shop name in the request to run the code on any random shop (as long as it appears in my token database i.e. has my app installed).

@paulomarg
Copy link
Contributor

As javl points out, you can use shopify.api.session.customAppSession (reference here). We could mention that in the docs to make it easier to find, thanks for pointing it out 🙂

As for the other question javl asks, right now the Remix package doesn't expose a way to manually create an offline session because we tried to create a simpler interface. We return an AdminContext with all of the clients already spun up and the session handled internally so you don't have to go through that process.

For instance, if you're using shopify.authenticate.admin or shopify.authenticate.webhook you'll get back an admin object which contains a REST and GraphQL client.

If there are use cases for having a session that don't require authentication we can see if it makes sense to provide some similar functionality out of the package!

@javl
Copy link

javl commented Aug 14, 2023

@paulomarg
So what would you suggested doing in the following situation:
I have an Theme app extension for a public app which allows users to select a file. This file gets uploaded to a remote server that does a very specific analysis (this can't be done within the remix app). When this is done, I need the remote server to send data back to my app (via the app proxy) which in turn creates a file / metaobject / whatever is needed.

All of this works separately, and it used to work in my app with the system described above, but because the incoming request is not from a logged in admin I can't have my app respond to the result of the analysis anymore.

@paulomarg
Copy link
Contributor

Fair point! I'll take this feedback to the team and we'll look into it :)

@javl
Copy link

javl commented Aug 14, 2023

Thanks. I do understand this might not be used a ton, and it could be potentially dangerous if devs don't implement some extra security and validation, but at the same time I feel there are enough use cases that need something like this.

With that, and the option to create private products that are only visible from a specific URL / the API (and NOT show up in /collections/all/products.json) Shopify would be perfect ;)

@javl
Copy link

javl commented Aug 15, 2023

@paulomarg We're completely stuck developing our app without this function, so I'm wondering if you have any idea if and when this would be implemented, so we can decide if we can continue working with Shopify or need to find a different partner. Is there a place where we can follow along with some sort of roadmap, or progress on these kind of requests?

@sam-masscreations
Copy link

@paulomarg i second what @javl is asking. We are creating a new app using the remix template, while there is pleasing signs we have hit a massive block in our development. Trying to create a discount code via the app proxy as we need to speak to our 3rd party loyalty scheme.

const {admin} = await shopify.authenticate.admin(request);

Believe we need something ASAP, shall i revert back to 3.47.5 as we need this feature.

@paulomarg
Copy link
Contributor

Hey folks, quick update on this: we're going to provide a way to handle unauthenticated (as in not signed by Shopify) requests. We're aligning on what we want the API to be like, but currently we believe it'll look something like this:

const {admin} = shopify.unauthenticated.admin(shop);

and the admin object will be the same you get back from authenticate.admin, but we won't be able to run any of the checks so it'll be up to the app to call this from a secure scenario.

We'd welcome your feedback on this API, and if you feel that this would work for your use cases!

In the meantime, I realize it's not great, but you can still use shopifyApi from @shopify/shopify-api to do something similar to what was available in the previous template. First, in shopify.server, call shopifyApi to set up a separate object:

import { shopifyApi } from "@shopify/shopify-api";

const config = {
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
  apiVersion: LATEST_API_VERSION,
  scopes: process.env.SCOPES?.split(","),
  restResources,
  ...(process.env.SHOP_CUSTOM_DOMAIN
    ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
    : {}),
};


const url = new URL(process.env.SHOPIFY_APP_URL || "");
export const shopifyAPI = shopifyApi({
  ...config,
  hostName: url.hostname,
  hostScheme: url.protocol.replace(":", ""),
  isEmbeddedApp: true,
});

const shopify = shopifyApp({
  ...config,
  appUrl: process.env.SHOPIFY_APP_URL || "",
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  webhooks: {
    APP_UNINSTALLED: {
      deliveryMethod: DeliveryMethod.Http,
      callbackUrl: "/webhooks",
    },
  },
  hooks: {
    afterAuth: async ({ session }) => {
      shopify.registerWebhooks({ session });
    },
  },
});

and in your loader or action:

export const loader = async ({ request }) => {
  const { searchParams } = new URL(request.url);
  const shop = searchParams.get("shop");

  const offlineSessionId = shopifyAPI.session.getOfflineId(shop || "");
  const session = await sessionStorage.loadSession(offlineSessionId);
  const graphqlClient = new shopifyAPI.clients.Graphql({ session });

  // Note the arguments for this client are slightly different
  const response = await graphqlClient.query({
    data: {
      query: {},
      variables: {}
    }
  });

  return json({data: response.body.data});
};

@javl
Copy link

javl commented Aug 15, 2023

Thank you for the quick reply and for your workaround to be used in the meantime!

I wonder if we'd be able to come up with some reliable ways to verify if a request was allowed to be made. I don't think you really want to try and hide what is going on; you mostly want to be able to verify parameters sent (like the store name) are original and haven't been altered.

Some initial thoughts that might be helpful:

  1. I haven't implemented an HMAC check in the Remix template yet, but I assume it's still possible to do so? This means you could at least verify if request to your app proxy is coming from a Shopify hosted page like from an app block. Can't be used for external requests of course.
  2. In my case I'm working with one-time use tokens provided by my external server and I will keep track of those in my app, only allowing a request if there is an active token available and ignoring any repeating requests.
  3. I don't have a lot of experience with using JWT yet, but it sounds like it might be useful in some cases to verify data hasn't been tampered with.

@javl
Copy link

javl commented Aug 15, 2023

Just want to confirm this workaround does indeed work. I basically took your example and added unauthenticated in front of the variable names so I can use them beside my original object and rewrote some functions to allow both an admin and a session argument (selecting what graphql method to use based on the argument provided), so I can continue development with the workaround and just swap it over once the shopify.unauthenticated.admin() update is here.

Thanks again for the quick response!

@byrichardpowell
Copy link
Contributor

byrichardpowell commented Aug 16, 2023

👋 Hey everyone!

The team is also discussing providing an authenticate function to authenticate app proxy requests. We think this would be something like

const {/* not sure what is returned yet */} = shopify.authenticate.public.onlinestore(request)

This would be used to authenticate request app proxy requests from online store.

@sam-masscreations @javl 2 questions:

  1. Would this suit your needs?
  2. With your use case, what API's would you access for these requests? Storefront? Admin?

Thanks in advance. We want to make this as seamless as possible, and your input can help to that end.

@javl
Copy link

javl commented Aug 18, 2023

@byrichardpowell

  1. Something like authenticate.public sounds good, also because it might be nice to be able to differentiate between actual admin sessions and these 'external' ones.
  • I'm not 100% sure what to check for during the authentication though; it would be nice to have some sort of verification to check if the call was initiated by a trusted source, but I can imagine there are a lot of ways to do this, and they might be different on an app to app basis (and whether the request is public facing or something 'invisible' like with a cronjob), so maybe it makes sense to run any secret keys / token tests after authenticate.public (to be implemented by the app developer).
  • For security purposes it might be good to show some examples of how to implement your own (JWT?) verification in the docs (once we think of a proper way)
  • Maybe there should be some access_scope to allow these 'userless' sessions, so 'external api access' can be disabled by default (making authenticate.public fail without this scope)
  1. In my case I need access to the Admin API: I have an app that allows you to upload a file to an external server, which will analyze the file and will then uses my app proxy to create a new product based on that file. In the future I might also need to perform other calls to Shopify, but they will all be for the Admin API.

Now that I have you, can I ask a quick, unrelated question, about where to place a specific request:
At the moment it's a hassle to create custom products for clients. There are some apps that claim to be able to create custom products (some of which have been promoted by/pointed to by Shopify staff on the forums) but a huge downside here is that there are multiple endpoints exposing all products (like collections/all/products.json). I've seen multiple stores, offering custom products, that expose personal data, like a store selling wedding cards (exposing names, addresses and dates) and a store selling printed pillows (exposing customers' photos and other images).
And even if you store sensitive data in metafields to hide them, you can still see loads of company-senstive information like the amount of products created and their price, and you won't be able to use the product's image to show the actual custom product for example.

It would be amazing to have some sort of unlisted flag which will prevent the product from showing up in any public overviews, making it only accessible via a direct URL (which might include some hard to guess ID) and Admin API. Currently you can keep a product from showing up in these places by removing it from the sales channel, but of course this also removes the ability to order the product.

What would be the best place to post a request to this extend, as there (obviously) isn't a public repo for the Shopify core. Or maybe to have a quick chat about how to implement something like this.

@byrichardpowell
Copy link
Contributor

Hey everyone,

We have released version 1.1.0 of @shopify/shopify-app-remix. This includes a new way to create an admin API context without authenticating a request:

// app/shopify.server.ts
import {shopifyApp} from '@shopify/shopify-app-remix';
import {restResources} from '@shopify/shopify-api/rest/admin/2023-04';

const shopify = shopifyApp({
  restResources,
  // ...etc
});

export default shopify;

// app/routes/\/.jsx
import {json} from '@remix-run/node';
import {authenticateExternalRequest} from '~/helpers/authenticate';
import shopify from '../../shopify.server';

export async function loader({request}) {
  const shop = await authenticateExternalRequest(request);
  const {admin, session} = await shopify.unauthenticated.admin(shop);

  return json(await admin.rest.resources.Product.count({session}));
}

We think this satisfies the first use case in this issue and we welcome feedback. As such, I'm going to close this issue.

A second use case referenced in this issue is authenticating Storefront App Proxy requests. We are tracking this here. It's the next thing I'm working on.


@javl Unfortunately I can't provide that kind of support here. The best thing I can suggest is to contact partner support: https://help.shopify.com/en/support/partners/org-select

@huykon
Copy link

huykon commented Aug 24, 2023

@byrichardpowell @paulomarg Are there any way to query storefront API in the remix route?

@nishikawa-nobuyuki
Copy link

@byrichardpowell
Hello!
Can you tell me what is in the implementation of authenticateExternalRequest?
The code for authenticateExternalRequest is not present in the app template.

@byrichardpowell
Copy link
Contributor

@huykon Not right now. But I think it's highly likely we'll add one soon. For now you can use @shopify/shopify-api-js: https://github.com/Shopify/shopify-api-js/blob/main/docs/reference/clients/Storefront.md

@nishikawa-nobuyuki This is just an illustration. The idea is that because it's a Request that Shopify doesn't control, we can't provide an authentication methods for it. So instead, you would implement one yourself. So, authenticateExternalRequest is something you would implement yourself.

@marloeffler
Copy link

marloeffler commented Aug 31, 2023

@byrichardpowell little bit lost here can you show us what import {authenticateExternalRequest} from '~/helpers/authenticate'; would do? not sure how the shop object has to look like.

@javl
Copy link

javl commented Aug 31, 2023

@marloeffler The authenticateExternalRequest function is not part of the Shopify code, you're meant to create it yourself with whatever method of authentication you want to use. Shop is just the shop name as you see it in your database, like myshop.myshopify.com, which you'll need to pass on to your endpoint.

I do agree it would be useful if Shopify could provide an example function in the docs, @byrichardpowell.
For example, I think this should work when you want to use some secret token (very simplistic and insecure, but as an example). Haven't tried this on a live app so please correct me if I'm wrong.

url: https://your-app-proxy-url.cloudflare.com/some-endpoint?shop=amazingshop.myshopify.com&token=verysneaky

export const authenticateExternalRequest = async (request) => {
  const { searchParams } = new URL(request.url);
  const secretToken = searchParams.get("token");

  // Check if the token is valid, return some error if not
  if (!secretToken || secretToken !== 'verysneaky') {
    return json({
      error: 'token invalid or missing'
    })
  }
  // otherwise, return the current shop name to work with
  const shop = searchParams.get("shop");
  if (!shop) {
    return json({
      error: 'no shop provided'
    })
  }
  return shop;
}

You'll probably want to implement some better security than a fixed string you're send along out in the open though. That's just waiting for something bad to happen.

@byrichardpowell
Copy link
Contributor

@marloeffler The idea is authenticateExternalRequest authenticates a request by some means that you control. It's not a request from Shopify, so @shopify/shopify-app-remix can't know how to authenticate that request. It's an illustration of that fact that you need to authenticate the request yourself, so if you are using shopify.unauthenticated.admin, authentication is up to you. I can't really show you what it would do, because that's up to you and whoever sent the request. Imagine for example the request is from some other 3P service. I'd expect that 3P service to have docs on how to authenticate that request.

The shop object is just a string, it's the input to getOfflineId, which is documented here: https://github.com/Shopify/shopify-api-js/blob/main/docs/reference/session/getOfflineId.md

shopify.unauthenticated.admin uses it to get a session from the database. shopify.unauthenticated.admin then uses that session to provide you with an API client for that shop. The full implementation is here:

https://github.com/Shopify/shopify-app-js/blob/main/packages/shopify-app-remix/src/server/unauthenticated/admin/factory.ts#L14-L28

@byrichardpowell
Copy link
Contributor

byrichardpowell commented Aug 31, 2023

@javl the example you provided is helpful, thank you.

I'm not sure how best to document this because anything specific we put to illustrate how authenticateExternalRequest might work has to be secure and be could just as likely to lead people astray as saying "this is up to you".

I'll think about this some more.

@javl
Copy link

javl commented Aug 31, 2023

@byrichardpowell On one hand I think having some skeleton function will be useful, but on the other hand I also agree you can't really put something simple in the Shopify repo and NOT expect people to run with this function without adding proper security measurements 🤡

export const authenticateExternalRequest = async (request) => {
// This function needs to return the domain of the shop this request is meant for
// BUT:
// DO NOT USE THIS FUNCTION LIGHTLY
// NOT IMPLEMENTING A SECURE WAY TO VALIDATE THE REQUEST
//____    __    ____  __   __       __      
//\   \  /  \  /   / |  | |  |     |  |     
// \   \/    \/   /  |  | |  |     |  |     
//  \            /   |  | |  |     |  |     
//   \    /\    /    |  | |  `----.|  `----.
//    \__/  \__/     |__| |_______||_______|
//                                          
// LEAD TO UNAUTHORIZED ACCESS AND DAMAGE
}

@marloeffler
Copy link

@byrichardpowell and @javl thank you so much for the full cover of my question!

@marloeffler
Copy link

@marloeffler The authenticateExternalRequest function is not part of the Shopify code, you're meant to create it yourself with whatever method of authentication you want to use. Shop is just the shop name as you see it in your database, like myshop.myshopify.com, which you'll need to pass on to your endpoint.

I do agree it would be useful if Shopify could provide an example function in the docs, @byrichardpowell. For example, I think this should work when you want to use some secret token (very simplistic and insecure, but as an example). Haven't tried this on a live app so please correct me if I'm wrong.

url: https://your-app-proxy-url.cloudflare.com/some-endpoint?shop=amazingshop.myshopify.com&token=verysneaky

export const authenticateExternalRequest = async (request) => {
  const { searchParams } = new URL(request.url);
  const secretToken = searchParams.get("token");

  // Check if the token is valid, return some error if not
  if (!secretToken || secretToken !== 'verysneaky') {
    return json({
      error: 'token invalid or missing'
    })
  }
  // otherwise, return the current shop name to work with
  const shop = searchParams.get("shop");
  if (!shop) {
    return json({
      error: 'no shop provided'
    })
  }
  return shop;
}

You'll probably want to implement some better security than a fixed string you're send along out in the open though. That's just waiting for something bad to happen.

i thin for my usecase this is fair enought to do it like that! Works like a charm, thank you!

@javl
Copy link

javl commented Sep 2, 2023

If you do it like that just make sure there is no way for requests to inject their own data or commands, like passing raw graphql strings others could tamper with. Without proper authenticating you have to assume the URL, and anything in the query, is public and can be changed.

@jamesdix54
Copy link

Hi everyone,

I'm having real problems handling webhooks in my remix app that are firing off outside of the admin (eg. handling SUBSCRIPTION_CONTRACTS_CREATE) so I also need an offline token but I'm completely stuck.

I've seen that unauthenticated.admin is now available to use and this seems to work in my dev store when running locally but not in the production store! I'm getting back

ShopifyError: Could not find a session for shop {shop} when creating unauthenticated admin context

here is my webhooks.jsx file which isn't too changed from the original template (some code taken out for brevity)

export const action = async ({ request }) => {

  let { topic, shop, payload } = await authenticate.webhook(request);
  const { admin } = await unauthenticated.admin(`${process.env.SHOP}.myshopify.com`)


  switch (topic) {
    case "APP_UNINSTALLED":
      if (session) {
        await db.session.deleteMany({ where: { shop } });
      }
      break;
    case "SUBSCRIPTION_CONTRACTS_CREATE":
       // DO STUFF

but it immediately errors and crashes out at await unauthenticated.admin... I feel like I'm missing something but I can't work it out.

Any thoughts much appreciated

@joelvh
Copy link

joelvh commented Sep 27, 2023

@jamesdix54 make sure process.env.SHOP is only the subdomain and not the full domain. Otherwise, you need to go through the install flow to get the offline session to be created (authenticate.admin(request)) before you can use unauthenticated.admin(...).

@jamesdix54
Copy link

Many thanks for your response @joelvh - I'm 99% sure the process.env.SHOP is correct, ie if my store's url is mystore.myshopify.com, then process.env.SHOP is mystore...

When you say I need to go through the install flow, could you elaborate a bit more please? There is a route auth.$.jsx which has the following code

import { authenticate } from "../shopify.server";

export async function loader({ request }) {
  const auth = await authenticate.admin(request);
  return null;
}

I assume this is the right area to be in but documentation seems so sparse...

It feels like I need to be committing an offline token to a db at some point I just can't for the life of me work out where, or how?!

For instance, following some suggestions above I can run

  const offlineSessionId = shopifyAPI.session.getOfflineId(`${process.env.SHOP}.myshopify.com` || "");

which comes back with offline_{shop}.myshopify.com but then

const offlineSession = await sessionStorage.loadSession(offlineSessionId);

comes back undefined?

Any help would be much appreciated!

@joelvh
Copy link

joelvh commented Sep 28, 2023

@jamesdix54 I'm not sure how you originally persisted the session data in your dev environment if you didn't implement the session storage. I'm using shopify-app-js (Remix), which has session storage mechanisms implemented. Maybe take a look at the Express or Remix implementations?

@jamesdix54
Copy link

@joelvh - I've taken the Shopify Remix template as my starter point: https://github.com/Shopify/shopify-app-template-remix

I haven't done anything around session persistence as I couldn't find documentation to suggest I needed to. It seems to "just work" out of the box when I'm testing in my dev environment, but not in prod. The authenticate.admin(request) seems to run correctly for a few minutes after installing it, but if I try a transaction 30 mins after installing, it fails.

If it's helpful, on install in the logs I'm seeing:

2023-09-27T16:21:17.594405+00:00 app[web.1]: [shopify-app/INFO] Authenticating admin request
2023-09-27T16:21:17.594776+00:00 app[web.1]: [shopify-app/INFO] Handling OAuth callback request
2023-09-27T16:21:17.596615+00:00 app[web.1]: [shopify-api/INFO] Completing OAuth | {shop: byloftie.myshopify.com}
2023-09-27T16:21:17.825182+00:00 app[web.1]: [shopify-api/INFO] Creating new session | {shop: byloftie.myshopify.com, isOnline: false}
2023-09-27T16:21:18.388643+00:00 app[web.1]: [shopify-app/INFO] Running afterAuth hook
2023-09-27T16:21:18.388649+00:00 app[web.1]: [shopify-api/INFO] Registering webhooks | {shop: byloftie.myshopify.com}

So it seems to be creating the correct token, and like I say, working in development environment, but not in production.

Any pointers as to what may be happening?

@joelvh
Copy link

joelvh commented Sep 28, 2023

@jamesdix54 my guess is it's using cookie storage and you're losing the cookie. Check what cookies are set. But for production, you'll want to use a database of some sort to store the offline token. Each of the storage options in the link I provided should have a README.md with some instructions. However, agreed that docs are sparse -- my PR is still open to update the DynamoDB docs.

@ankur6971
Copy link

As javl points out, you can use shopify.api.session.customAppSession (reference here). We could mention that in the docs to make it easier to find, thanks for pointing it out 🙂

As for the other question javl asks, right now the Remix package doesn't expose a way to manually create an offline session because we tried to create a simpler interface. We return an AdminContext with all of the clients already spun up and the session handled internally so you don't have to go through that process.

For instance, if you're using shopify.authenticate.admin or shopify.authenticate.webhook you'll get back an admin object which contains a REST and GraphQL client.

If there are use cases for having a session that don't require authentication we can see if it makes sense to provide some similar functionality out of the package!

This link is showing a File not found error

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests