Closed claridgicus closed 1 year ago
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
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.
@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).
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!
@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.
Fair point! I'll take this feedback to the team and we'll look into it :)
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 ;)
@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?
@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.
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});
};
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:
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!
👋 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:
Thanks in advance. We want to make this as seamless as possible, and your input can help to that end.
@byrichardpowell
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.
authenticate.public
(to be implemented by the app developer). authenticate.public
fail without this scope)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.
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
@byrichardpowell @paulomarg Are there any way to query storefront API in the remix route?
@byrichardpowell Hello! Can you tell me what is in the implementation of authenticateExternalRequest? The code for authenticateExternalRequest is not present in the app template.
@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.
@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.
@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.
@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:
@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.
@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
}
@byrichardpowell and @javl thank you so much for the full cover of my question!
@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, likemyshop.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!
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.
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
@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(...)
.
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!
@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?
@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?
@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.
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
orshopify.authenticate.webhook
you'll get back anadmin
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
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?