diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd7dc561..3180ac9cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## Unreleased - Minor text/doc changes - Added `2021-01` API version to enum. [#117](https://github.com/shopify/shopify-node-api/pull/117) +- Allow retrieving offline sessions using `loadCurrentSession`. [#119](https://github.com/shopify/shopify-node-api/pull/119) ## [1.0.0] diff --git a/docs/usage/oauth.md b/docs/usage/oauth.md index 84ed42a5a..d790dbed5 100644 --- a/docs/usage/oauth.md +++ b/docs/usage/oauth.md @@ -112,16 +112,16 @@ You can use the `Shopify.Utils.loadCurrentSession()` method to load an online se As mentioned in the previous sections, you can use the OAuth methods to create both offline and online sessions. Once the process is completed, the session will be stored as per your `Context.SESSION_STORAGE` strategy, and can be retrieved with the below utitilies. -- To load an online session: +- To load a session, you can use the following method. You can load both online and offline sessions from the current request / response objects. ```ts -await Shopify.Utils.loadCurrentSession(request, response) +await Shopify.Utils.loadCurrentSession(request, response, isOnline); ``` -- To load an offline session: +- If you need to load a session for a background job, you can get offline sessions directly from the shop. ```ts -await Shopify.Utils.loadOfflineSession(shop) +await Shopify.Utils.loadOfflineSession(shop); ``` -The library supports creating both offline and online sessions for the same shop, so it is up to the app to call the appropriate loading method depending on its needs. +**Note**: the `loadOfflineSession` method does not perform any validations on the `shop` parameter. You should avoid calling it from user inputs or URLs. ## Detecting scope changes diff --git a/src/auth/oauth/oauth.ts b/src/auth/oauth/oauth.ts index 05f404564..427a80e17 100644 --- a/src/auth/oauth/oauth.ts +++ b/src/auth/oauth/oauth.ts @@ -224,10 +224,15 @@ const ShopifyOAuth = { /** * Extracts the current session id from the request / response pair. * - * @param request HTTP request object + * @param request HTTP request object * @param response HTTP response object + * @param isOnline Whether to load online (default) or offline sessions (optional) */ - getCurrentSessionId(request: http.IncomingMessage, response: http.ServerResponse): string | undefined { + getCurrentSessionId( + request: http.IncomingMessage, + response: http.ServerResponse, + isOnline = true, + ): string | undefined { let currentSessionId: string | undefined; if (Context.IS_EMBEDDED_APP) { @@ -239,13 +244,20 @@ const ShopifyOAuth = { } const jwtPayload = decodeSessionToken(matches[1]); - currentSessionId = this.getJwtSessionId(jwtPayload.dest.replace(/^https:\/\//, ''), jwtPayload.sub); + const shop = jwtPayload.dest.replace(/^https:\/\//, ''); + if (isOnline) { + currentSessionId = this.getJwtSessionId(shop, jwtPayload.sub); + } else { + currentSessionId = this.getOfflineSessionId(shop); + } } } - // We fall back to the cookie session to allow apps to load their skeleton page after OAuth, so they can set up App - // Bridge and get a new JWT. + // Non-embedded apps will always load sessions using cookies. However, we fall back to the cookie session for + // embedded apps to allow apps to load their skeleton page after OAuth, so they can set up App Bridge and get a new + // JWT. if (!currentSessionId) { + // We still want to get the offline session id from the cookie to make sure it's validated currentSessionId = this.getCookieSessionId(request, response); } diff --git a/src/utils/delete-current-session.ts b/src/utils/delete-current-session.ts index b1c0216d7..427826b2f 100644 --- a/src/utils/delete-current-session.ts +++ b/src/utils/delete-current-session.ts @@ -7,16 +7,18 @@ import * as ShopifyErrors from '../error'; /** * Finds and deletes the current user's session, based on the given request and response * - * @param req Current HTTP request - * @param res Current HTTP response + * @param request Current HTTP request + * @param response Current HTTP response + * @param isOnline Whether to load online (default) or offline sessions (optional) */ export default async function deleteCurrentSession( request: http.IncomingMessage, response: http.ServerResponse, + isOnline = true, ): Promise { Context.throwIfUninitialized(); - const sessionId = ShopifyOAuth.getCurrentSessionId(request, response); + const sessionId = ShopifyOAuth.getCurrentSessionId(request, response, isOnline); if (!sessionId) { throw new ShopifyErrors.SessionNotFound('No active session found.'); } diff --git a/src/utils/load-current-session.ts b/src/utils/load-current-session.ts index 1c813ae5c..699d79fc8 100644 --- a/src/utils/load-current-session.ts +++ b/src/utils/load-current-session.ts @@ -7,16 +7,18 @@ import {Session} from '../auth/session'; /** * Loads the current user's session, based on the given request and response. * - * @param req Current HTTP request - * @param res Current HTTP response + * @param request Current HTTP request + * @param response Current HTTP response + * @param isOnline Whether to load online (default) or offline sessions (optional) */ export default async function loadCurrentSession( request: http.IncomingMessage, response: http.ServerResponse, + isOnline = true, ): Promise { Context.throwIfUninitialized(); - const sessionId = ShopifyOAuth.getCurrentSessionId(request, response); + const sessionId = ShopifyOAuth.getCurrentSessionId(request, response, isOnline); if (!sessionId) { return Promise.resolve(undefined); } diff --git a/src/utils/test/delete-current-session.test.ts b/src/utils/test/delete-current-session.test.ts index 3c66870ec..7adcacd8c 100644 --- a/src/utils/test/delete-current-session.test.ts +++ b/src/utils/test/delete-current-session.test.ts @@ -11,6 +11,7 @@ import {Session} from '../../auth/session'; import {JwtPayload} from '../decode-session-token'; import deleteCurrentSession from '../delete-current-session'; import loadCurrentSession from '../load-current-session'; +import {ShopifyOAuth} from '../../auth/oauth/oauth'; jest.mock('cookies'); @@ -67,6 +68,43 @@ describe('deleteCurrenSession', () => { await expect(loadCurrentSession(req, res)).resolves.toBe(undefined); }); + it('finds and deletes the current offline session when using cookies', async () => { + Context.IS_EMBEDDED_APP = false; + Context.initialize(Context); + + const req = {} as http.IncomingMessage; + const res = {} as http.ServerResponse; + + const cookieId = ShopifyOAuth.getOfflineSessionId('test-shop.myshopify.io'); + + const session = new Session(cookieId); + await expect(Context.SESSION_STORAGE.storeSession(session)).resolves.toEqual(true); + + Cookies.prototype.get.mockImplementation(() => cookieId); + + await expect(deleteCurrentSession(req, res, false)).resolves.toBe(true); + await expect(loadCurrentSession(req, res, false)).resolves.toBe(undefined); + }); + + it('finds and deletes the current offline session when using JWT', async () => { + Context.IS_EMBEDDED_APP = true; + Context.initialize(Context); + + const token = jwt.sign(jwtPayload, Context.API_SECRET_KEY, {algorithm: 'HS256'}); + const req = { + headers: { + authorization: `Bearer ${token}`, + }, + } as http.IncomingMessage; + const res = {} as http.ServerResponse; + + const session = new Session(ShopifyOAuth.getOfflineSessionId('test-shop.myshopify.io')); + await expect(Context.SESSION_STORAGE.storeSession(session)).resolves.toEqual(true); + + await expect(deleteCurrentSession(req, res, false)).resolves.toBe(true); + await expect(loadCurrentSession(req, res, false)).resolves.toBe(undefined); + }); + it('throws an error when no cookie is found', async () => { Context.IS_EMBEDDED_APP = false; Context.initialize(Context); diff --git a/src/utils/test/load-current-session.test.ts b/src/utils/test/load-current-session.test.ts index e8f6aa9d5..edd69c027 100644 --- a/src/utils/test/load-current-session.test.ts +++ b/src/utils/test/load-current-session.test.ts @@ -10,6 +10,7 @@ import * as ShopifyErrors from '../../error'; import {Session} from '../../auth/session'; import {JwtPayload} from '../decode-session-token'; import loadCurrentSession from '../load-current-session'; +import {ShopifyOAuth} from '../../auth/oauth/oauth'; jest.mock('cookies'); @@ -136,4 +137,39 @@ describe('loadCurrentSession', () => { await expect(loadCurrentSession(req, res)).resolves.toEqual(session); }); + + it('loads offline sessions from cookies', async () => { + Context.IS_EMBEDDED_APP = false; + Context.initialize(Context); + + const req = {} as http.IncomingMessage; + const res = {} as http.ServerResponse; + + const cookieId = ShopifyOAuth.getOfflineSessionId('test-shop.myshopify.io'); + + const session = new Session(cookieId); + await expect(Context.SESSION_STORAGE.storeSession(session)).resolves.toEqual(true); + + Cookies.prototype.get.mockImplementation(() => cookieId); + + await expect(loadCurrentSession(req, res, false)).resolves.toEqual(session); + }); + + it('loads offline sessions from JWT token', async () => { + Context.IS_EMBEDDED_APP = true; + Context.initialize(Context); + + const token = jwt.sign(jwtPayload, Context.API_SECRET_KEY, {algorithm: 'HS256'}); + const req = { + headers: { + authorization: `Bearer ${token}`, + }, + } as http.IncomingMessage; + const res = {} as http.ServerResponse; + + const session = new Session(ShopifyOAuth.getOfflineSessionId('test-shop.myshopify.io')); + await expect(Context.SESSION_STORAGE.storeSession(session)).resolves.toEqual(true); + + await expect(loadCurrentSession(req, res, false)).resolves.toEqual(session); + }); });