Skip to content

Commit cc4cf40

Browse files
Use token exchange instead of redirecting for online session (#20)
1 parent c1a61e3 commit cc4cf40

File tree

9 files changed

+274
-92
lines changed

9 files changed

+274
-92
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ _Importing differs slightly from the official library in that the `createShopify
5555
For requests, create the middleware like this:
5656

5757
```js
58-
// For requests from the frontend, we want to return headers, so we can check if we need to reauth on the client side
58+
// For API requests from the frontend, we want to return headers, so we can check if we need to reauthenticate on the client side.
59+
// NOTE: Now this isn't needed as often since we use the token exchange endpoint to get the online token.
5960
const verifyApiRequest = verifyRequest({ returnHeader: true });
6061
const verifyPageRequest = verifyRequest();
6162
```

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "simple-koa-shopify-auth",
3-
"version": "2.1.17",
4-
"description": "A better, simplified version of the (no longer supported) @Shopify/koa-shopify-auth middleware library. It removes the use of cookies for sessions (which greatly smooths the auth process), replaces a deprecated API call, and supports v2 of the official @shopify/shopify-api package.",
3+
"version": "3.0.0",
4+
"description": "A better, simplified version of the now deprecated @Shopify/koa-shopify-auth middleware library. It removes the use of cookies for sessions (which greatly smooths the auth process), replaces a deprecated API call, and supports v2 of the official @shopify/shopify-api package.",
55
"author": "TheSecurityDev",
66
"license": "MIT",
77
"repository": {
@@ -29,10 +29,10 @@
2929
"prepublish": "npm run build"
3030
},
3131
"dependencies": {
32-
"lru-cache": "^9.1.2"
32+
"lru-cache": "^10.2.0"
3333
},
3434
"peerDependencies": {
35-
"@shopify/shopify-api": "^5.0.1"
35+
"@shopify/shopify-api": "^5.3.0"
3636
},
3737
"devDependencies": {
3838
"@types/koa": "^2.14.0",

src/create-shopify-auth.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default function createShopifyAuth(options: OAuthBeginConfig) {
4242
(path === oAuthStartPath && shouldPerformTopLevelOAuth(ctx))
4343
) {
4444
// Auth started
45-
if (!validateShop(shop)) {
45+
if (!Shopify.Utils.sanitizeShop(shop)) {
4646
// Invalid shop
4747
ctx.response.status = 400;
4848
ctx.response.body = shop ? "Invalid shop parameter" : "Missing shop parameter";
@@ -74,9 +74,7 @@ export default function createShopifyAuth(options: OAuthBeginConfig) {
7474
query as unknown as AuthQuery
7575
);
7676
ctx.state.shopify = session;
77-
if (config.afterAuth) {
78-
await config.afterAuth(ctx);
79-
}
77+
await config.afterAuth?.(ctx);
8078
} catch (err) {
8179
const message = (err as Error).message;
8280
switch (true) {
@@ -87,8 +85,6 @@ export default function createShopifyAuth(options: OAuthBeginConfig) {
8785
// This is likely because the OAuth session cookie expired before the merchant approved the request
8886
ctx.redirect(`${oAuthStartPath}?${querystring}`);
8987
break;
90-
case err instanceof Shopify.Errors.InvalidJwtError:
91-
ctx.throw(401, message);
9288
default:
9389
ctx.throw(500, message);
9490
}
@@ -99,8 +95,3 @@ export default function createShopifyAuth(options: OAuthBeginConfig) {
9995
await next();
10096
};
10197
}
102-
103-
export function validateShop(shop: string): boolean {
104-
const shopUrlRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.(com|io)[/]*$/;
105-
return shopUrlRegex.test(shop);
106-
}

src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import createShopifyAuth, { validateShop } from "./create-shopify-auth";
2-
import verifyRequest from "./verify-request";
1+
import createShopifyAuth from "./create-shopify-auth";
2+
import verifyRequest, { AuthFailureHeader } from "./verify-request";
33

4-
export { createShopifyAuth, validateShop, verifyRequest };
4+
export { createShopifyAuth, verifyRequest, AuthFailureHeader };
5+
export { createSession } from "./session";
6+
export { exchangeSessionTokenForAccessTokenSession } from "./token-exchange";

src/session.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Shopify, { OnlineAccessResponse } from "@shopify/shopify-api";
2+
3+
/* Creates a new session from the response from the access token request. */
4+
export function createSession(responseBody: OnlineAccessResponse, shop: string, state = "") {
5+
const { access_token, scope, ...rest } = responseBody;
6+
const isOnline = !!rest.associated_user; // If there's an associated user, then it's an online access token
7+
8+
// Get the session ID
9+
const sessionId = Shopify.Context.IS_EMBEDDED_APP
10+
? isOnline
11+
? Shopify.Auth.getJwtSessionId(shop, `${rest.associated_user.id}`)
12+
: Shopify.Auth.getOfflineSessionId(shop)
13+
: `${shop}_${Date.now()}`;
14+
15+
// Initialize the session object
16+
const session = new Shopify.Session.Session(sessionId, shop, state, isOnline);
17+
session.accessToken = access_token;
18+
session.scope = scope;
19+
20+
if (isOnline) {
21+
// Add the online access info
22+
const sessionExpiration = new Date(Date.now() + responseBody.expires_in * 1000);
23+
session.expires = sessionExpiration;
24+
session.onlineAccessInfo = rest;
25+
}
26+
27+
return session;
28+
}

src/token-exchange.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import Shopify, { DataType, OnlineAccessResponse } from "@shopify/shopify-api";
2+
import { HttpResponseError } from "@shopify/shopify-api/dist/error";
3+
import { Session } from "@shopify/shopify-api/dist/auth/session";
4+
5+
import { createSession } from "./session";
6+
7+
// Cache object with current requests so we can avoid making the same request multiple times.
8+
// The key is in the format `{shop}:{tokenType}:{encodedSessionToken}:{saveSession}` and the value is the promise of the request.
9+
const currentTokenExchangeRequests = new Map<string, Promise<Session>>();
10+
11+
/** Given the shop, encoded JWT session token, and token type, return a session object with an access token.
12+
https://shopify.dev/docs/apps/auth/get-access-tokens/token-exchange
13+
14+
The request will be de-duplicated, so if multiple requests are made at once with the same shop, token and token type, only one request will be made.
15+
16+
@param shop The shop's myshopify domain
17+
@param encodedSessionToken The encoded JWT session token
18+
@param tokenType The type of access token to exchange for ('online' or 'offline')
19+
@param saveSession If true, the new session will be saved to storage (default)
20+
21+
@returns The new session object
22+
*/
23+
export async function exchangeSessionTokenForAccessTokenSession(
24+
shop: string,
25+
encodedSessionToken: string,
26+
tokenType: "online" | "offline",
27+
saveSession = true // If true, the new session will be saved to storage
28+
) {
29+
// Check if we already have a request in progress
30+
const key = `${shop}:${tokenType}:${encodedSessionToken}:${saveSession}`;
31+
const existingRequest = currentTokenExchangeRequests.get(key);
32+
if (existingRequest) return existingRequest; // If we already have a request in progress, use it
33+
34+
// Otherwise make the request with a timeout
35+
const requestOrTimeout = Promise.race([
36+
makeTokenExchangeRequest({ shop, encodedSessionToken, tokenType, saveSession }),
37+
new Promise<Session>((resolve, reject) =>
38+
setTimeout(() => reject(new Error("Request timed out")), 10000)
39+
),
40+
]);
41+
42+
// Save the request to the cache
43+
currentTokenExchangeRequests.set(key, requestOrTimeout);
44+
45+
// When the request is done, remove it from the cache
46+
requestOrTimeout.finally(() => {
47+
currentTokenExchangeRequests.delete(key);
48+
});
49+
50+
return requestOrTimeout;
51+
}
52+
53+
// Internal function that makes the request to Shopify to exchange the session token for an access token. The main function is a wrapper around this one that implements de-duplicating the requests.
54+
async function makeTokenExchangeRequest(params: {
55+
shop: string;
56+
encodedSessionToken: string;
57+
tokenType: "online" | "offline";
58+
saveSession: boolean;
59+
}) {
60+
const { shop, encodedSessionToken, tokenType, saveSession } = params;
61+
62+
const sanitizedShop = Shopify.Utils.sanitizeShop(shop, true);
63+
64+
// Construct the request body
65+
const body = {
66+
client_id: Shopify.Context.API_KEY,
67+
client_secret: Shopify.Context.API_SECRET_KEY,
68+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
69+
subject_token: encodedSessionToken,
70+
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
71+
requested_token_type: `urn:shopify:params:oauth:token-type:${tokenType}-access-token`,
72+
};
73+
74+
// Make the request
75+
const response = await fetch(`https://${sanitizedShop}/admin/oauth/access_token`, {
76+
method: "POST",
77+
headers: { "Content-Type": DataType.JSON, Accept: DataType.JSON },
78+
body: JSON.stringify(body),
79+
});
80+
81+
// Check the response for errors
82+
if (!response.ok) {
83+
const { status, statusText, headers } = response;
84+
throw new HttpResponseError({
85+
message: `Failed to exchange session token for access token: ${status} ${statusText}`,
86+
code: status,
87+
statusText,
88+
body,
89+
headers: headers as any,
90+
});
91+
}
92+
93+
// Parse the response
94+
const sessionResponse: OnlineAccessResponse = await response.json();
95+
96+
// Create the session
97+
const session = createSession(sessionResponse, shop);
98+
99+
// Make sure the session is active
100+
if (!session.isActive()) {
101+
throw new Error(
102+
`The session '${session?.id}' we just got from Shopify is not active for shop '${shop}'`
103+
);
104+
}
105+
106+
// Save the session to storage if requested
107+
if (saveSession) await Shopify.Utils.storeSession(session);
108+
109+
return session;
110+
}

src/top-level-oauth-redirect.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export async function startTopLevelOauthRedirect(ctx: Context, apiKey: string, p
3737
setTopLevelOAuthCookieValue(ctx, "1");
3838
let { query } = ctx;
3939
const hostName = Shopify.Context.HOST_NAME; // Use this instead of ctx.host to prevent issues when behind a proxy
40-
const shop = query.shop ? query.shop.toString() : "";
41-
const host = query.host ? query.host.toString() : "";
40+
const shop = Shopify.Utils.sanitizeShop(query.shop?.toString() ?? "") ?? "";
41+
const host = Shopify.Utils.sanitizeHost(query.host?.toString() ?? "") ?? "";
4242
const params = { shop, host };
4343
const queryString = new URLSearchParams(params).toString(); // Use this instead of ctx.querystring, because it sanitizes the query parameters we are using
4444
ctx.body = await getTopLevelRedirectScript(

src/utils.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Shopify from "@shopify/shopify-api";
2+
import { MissingJwtTokenError, HttpResponseError } from "@shopify/shopify-api/dist/error";
3+
import { JwtPayload } from "@shopify/shopify-api/dist/utils/decode-session-token";
4+
import { Context } from "koa";
5+
6+
/** Throw the error, unless it's an `HttpResponseError` with status `401`. */
7+
export function throwUnlessAuthError(err: HttpResponseError | Error | unknown) {
8+
if (err instanceof HttpResponseError) {
9+
// NOTE: Shopify API v3+ uses 'response.code' instead of 'code'
10+
const code = (err as any)?.code ?? err.response?.code;
11+
if (code === 401) return; // Catch the 401 error so we can re-authorize
12+
}
13+
throw err; // Throw any other errors
14+
}
15+
16+
////////////////////////////
17+
// Session token utils
18+
////////////////////////////
19+
20+
/** Find and return the base64 encoded JWT session token from the request authorization header in the given context. Throws an error if it wasn't found. */
21+
export function getEncodedSessionToken(ctx: Context) {
22+
if (Shopify.Context.IS_EMBEDDED_APP) {
23+
const authHeader = ctx.req.headers.authorization ?? "";
24+
const matches = authHeader?.match(/Bearer (.*)/);
25+
if (!matches) throw new MissingJwtTokenError("Missing Bearer token in authorization header");
26+
return matches[1];
27+
} else {
28+
throw new Error("Session tokens are only available in embedded apps");
29+
}
30+
}
31+
32+
/** Get the shop from the JWT session token. */
33+
export function getShopFromSessionToken(sessionToken: JwtPayload) {
34+
return sessionToken.dest.replace("https://", "");
35+
}
36+
37+
/** Get the base64 encoded host from the JWT session token. */
38+
function getHostFromSessionToken(sessionToken: JwtPayload) {
39+
return Buffer.from(sessionToken.iss.replace("https://", "")).toString("base64");
40+
}
41+
42+
/** Parse given decoded session token and return the shop and host query string params. */
43+
export function getShopAndHostQueryStringFromSessionToken(sessionToken: JwtPayload) {
44+
const shop = getShopFromSessionToken(sessionToken);
45+
const host = getHostFromSessionToken(sessionToken);
46+
return new URLSearchParams({ shop, host }).toString();
47+
}

0 commit comments

Comments
 (0)