Skip to content

Commit

Permalink
move session fetching and request validation to authenticate
Browse files Browse the repository at this point in the history
Co-authored-by: Paulo Margarido <[email protected]>
Co-authored-by: Rezaan Syed <[email protected]>
  • Loading branch information
3 people committed Nov 28, 2023
1 parent 02511ee commit cd4e13d
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 122 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ShopifyRestResources} from '@shopify/shopify-api';
import {JwtPayload, Session, ShopifyRestResources} from '@shopify/shopify-api';

import type {BasicParams} from '../../types';
import type {AppConfigArg} from '../../config-types';
Expand All @@ -8,6 +8,7 @@ import {
getSessionTokenFromUrlParam,
respondToBotRequest,
respondToOptionsRequest,
validateSessionToken,
} from '../helpers';

import {
Expand All @@ -20,14 +21,16 @@ import type {
AuthenticateAdmin,
EmbeddedAdminContext,
NonEmbeddedAdminContext,
SessionContext,
} from './types';
import {
createAdminApiContext,
ensureAppIsEmbeddedIfRequired,
ensureSessionTokenSearchParamIfRequired,
redirectFactory,
renderAppBridge,
validateShopAndHostParams,
} from './helpers';
import {AuthorizationStrategy} from './strategies/types';
import {AuthorizationStrategy, SessionTokenContext} from './strategies/types';

interface AuthStrategyParams extends BasicParams {
strategy: AuthorizationStrategy;
Expand Down Expand Up @@ -64,32 +67,31 @@ export function authStrategyFactory<

function createContext(
request: Request,
sessionContext: SessionContext,
session: Session,
sessionToken: JwtPayload,
): AdminContext<ConfigArg, Resources> {
const {session} = sessionContext;

const context:
| EmbeddedAdminContext<ConfigArg, Resources>
| NonEmbeddedAdminContext<ConfigArg, Resources> = {
admin: createAdminApiContext<Resources>(request, sessionContext.session, {
admin: createAdminApiContext<Resources>(request, session, {
api,
logger,
config,
}),
billing: {
require: requireBillingFactory({api, logger, config}, request, session),
request: requestBillingFactory({api, logger, config}, request, session),
cancel: cancelBillingFactory({api, logger, config}, request, session),
require: requireBillingFactory(params, request, session),
request: requestBillingFactory(params, request, session),
cancel: cancelBillingFactory(params, request, session),
},
session: sessionContext.session,
cors: ensureCORSHeadersFactory({api, logger, config}, request),
session,
cors: ensureCORSHeadersFactory(params, request),
};

if (config.isEmbeddedApp) {
return {
...context,
sessionToken: sessionContext!.token!,
redirect: redirectFactory({api, config, logger}, request),
sessionToken,
redirect: redirectFactory(params, request),
} as AdminContext<ConfigArg, Resources>;
} else {
return context as AdminContext<ConfigArg, Resources>;
Expand All @@ -104,27 +106,38 @@ export function authStrategyFactory<
await respondToExitIframeRequest(request);
await strategy.respondToOAuthRequests(request);

// If this is a valid request, but it doesn't have a session token header, this is a document request. We need to
// ensure we're embedded if needed and we have the information needed to load the session.
if (!getSessionTokenHeader(request)) {
validateShopAndHostParams(params, request);
await ensureAppIsEmbeddedIfRequired(params, request);
await ensureSessionTokenSearchParamIfRequired(params, request);
}

logger.info('Authenticating admin request');

const headerSessionToken = getSessionTokenHeader(request);
const searchParamSessionToken = getSessionTokenFromUrlParam(request);
const sessionToken = (headerSessionToken || searchParamSessionToken)!;
const {payload, shop, sessionId} = await getSessionTokenContext(
params,
request,
);

logger.debug('Attempting to authenticate session token', {
sessionToken: {
header: headerSessionToken,
search: searchParamSessionToken,
},
});
logger.debug('Loading session from storage', {sessionId});
const existingSession = sessionId
? await config.sessionStorage.loadSession(sessionId)
: undefined;

const sessionContext = await strategy.authenticate(request, sessionToken);
const session = await strategy.authenticate(
request,
existingSession,
shop,
);

logger.debug('Request is valid, loaded session from session token', {
shop: sessionContext.session.shop,
isOnline: sessionContext.session.isOnline,
shop: session.shop,
isOnline: session.isOnline,
});

return createContext(request, sessionContext);
return createContext(request, session, payload!);
} catch (errorOrResponse) {
if (errorOrResponse instanceof Response) {
ensureCORSHeadersFactory(params, request)(errorOrResponse);
Expand All @@ -134,3 +147,47 @@ export function authStrategyFactory<
}
};
}

async function getSessionTokenContext(
params: BasicParams,
request: Request,
): Promise<SessionTokenContext> {
const {api, config, logger} = params;

const headerSessionToken = getSessionTokenHeader(request);
const searchParamSessionToken = getSessionTokenFromUrlParam(request);
const sessionToken = (headerSessionToken || searchParamSessionToken)!;

logger.debug('Attempting to authenticate session token', {
sessionToken: {
header: headerSessionToken,
search: searchParamSessionToken,
},
});

if (config.isEmbeddedApp) {
const payload = await validateSessionToken(
{config, logger, api},
sessionToken,
);
const dest = new URL(payload.dest);
const shop = dest.hostname;

logger.debug('Session token is present, validating session', {shop});
const sessionId = config.useOnlineTokens
? api.session.getJwtSessionId(shop, payload.sub)
: api.session.getOfflineId(shop);

return {shop, payload, sessionId};
}

const url = new URL(request.url);
const shop = url.searchParams.get('shop')!;

const sessionId = await api.session.getCurrentId({
isOnline: config.useOnlineTokens,
rawRequest: request,
});

return {shop, sessionId, payload: undefined};
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './redirect-with-exitiframe';
export * from './redirect';
export * from './render-app-bridge';
export * from './trigger-after-auth-hook';
export * from './validate-shop-and-host-params';
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {redirect} from '@remix-run/server-runtime';
import {BasicParams} from 'src/server/types';

export function validateShopAndHostParams(
params: BasicParams,
request: Request,
) {
const {api, config, logger} = params;

if (config.isEmbeddedApp) {
const url = new URL(request.url);
const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!);
if (!shop) {
logger.debug('Missing or invalid shop, redirecting to login path', {
shop,
});
throw redirect(config.auth.loginPath);
}

const host = api.utils.sanitizeHost(url.searchParams.get('host')!);
if (!host) {
logger.debug('Invalid host, redirecting to login path', {
host: url.searchParams.get('host'),
});
throw redirect(config.auth.loginPath);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,20 @@ import {
Shopify,
ShopifyRestResources,
} from '@shopify/shopify-api';
import {redirect} from '@remix-run/server-runtime';

import type {BasicParams} from '../../../types';
import {
beginAuth,
ensureAppIsEmbeddedIfRequired,
ensureSessionTokenSearchParamIfRequired,
redirectToAuthPage,
redirectToShopifyOrAppRoot,
redirectWithExitIframe,
triggerAfterAuthHook,
validateShopAndHostParams,
} from '../helpers';
import {SessionContext} from '../types';
import {AppConfig} from '../../../config-types';
import {getSessionTokenHeader, validateSessionToken} from '../../helpers';
import {getSessionTokenHeader} from '../../helpers';

import {AuthorizationStrategy, SessionTokenContext} from './types';
import {AuthorizationStrategy} from './types';

export class AuthCodeFlowStrategy<
Resources extends ShopifyRestResources = ShopifyRestResources,
Expand All @@ -41,7 +38,7 @@ export class AuthCodeFlowStrategy<
}

public async respondToOAuthRequests(request: Request): Promise<void | never> {
const {api, logger, config} = this;
const {api, config} = this;

const url = new URL(request.url);
const isAuthRequest = url.pathname === config.auth.path;
Expand All @@ -58,32 +55,18 @@ export class AuthCodeFlowStrategy<
}
}

// If this is a valid request, but it doesn't have a session token header, this is a document request. We need to
// ensure we're embedded if needed and we have the information needed to load the session.
if (!getSessionTokenHeader(request)) {
const params = {api, logger, config};

await this.ensureInstalledOnShop(request);
await ensureAppIsEmbeddedIfRequired(params, request);
await ensureSessionTokenSearchParamIfRequired(params, request);
}
}

public async authenticate(
request: Request,
sessionToken: string,
): Promise<SessionContext | never> {
session: Session | undefined,
shop: string,
): Promise<Session | never> {
const {api, config, logger} = this;

const {shop, payload, sessionId} = await this.getSessionTokenContext(
request,
sessionToken,
);

logger.debug('Loading session from storage', {sessionId});

const session = await config.sessionStorage.loadSession(sessionId);

if (!session) {
logger.debug('No session found, redirecting to OAuth', {shop});
await redirectToAuthPage({config, logger, api}, request, shop);
Expand All @@ -95,13 +78,13 @@ export class AuthCodeFlowStrategy<
await redirectToAuthPage({config, logger, api}, request, shop);
}

return {session: session!, token: payload};
return session!;
}

private async ensureInstalledOnShop(request: Request) {
const {api, config, logger} = this;

this.validateUrlParams(request);
validateShopAndHostParams({api, config, logger}, request);

const url = new URL(request.url);
let shop = url.searchParams.get('shop');
Expand Down Expand Up @@ -147,69 +130,6 @@ export class AuthCodeFlowStrategy<
}
}

private validateUrlParams(request: Request) {
const {api, config, logger} = this;

if (config.isEmbeddedApp) {
const url = new URL(request.url);
const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!);
if (!shop) {
logger.debug('Missing or invalid shop, redirecting to login path', {
shop,
});
throw redirect(config.auth.loginPath);
}

const host = api.utils.sanitizeHost(url.searchParams.get('host')!);
if (!host) {
logger.debug('Invalid host, redirecting to login path', {
host: url.searchParams.get('host'),
});
throw redirect(config.auth.loginPath);
}
}
}

private async getSessionTokenContext(
request: Request,
sessionToken: string,
): Promise<SessionTokenContext> {
const {api, config, logger} = this;

if (config.isEmbeddedApp) {
const payload = await validateSessionToken(
{config, logger, api},
sessionToken,
);
const dest = new URL(payload.dest);
const shop = dest.hostname;

logger.debug('Session token is present, validating session', {shop});
const sessionId = config.useOnlineTokens
? api.session.getJwtSessionId(shop, payload.sub)
: api.session.getOfflineId(shop);

return {shop, payload, sessionId};
}

const url = new URL(request.url);
const shop = url.searchParams.get('shop')!;

const sessionId = await api.session.getCurrentId({
isOnline: config.useOnlineTokens,
rawRequest: request,
});

if (!sessionId) {
logger.debug('Session id not found in cookies, redirecting to OAuth', {
shop,
});
throw await beginAuth({api, config, logger}, request, false, shop);
}

return {shop, sessionId, payload: undefined};
}

private async handleAuthBeginRequest(
request: Request,
shop: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import {JwtPayload} from '@shopify/shopify-api';

import {SessionContext} from '../types';
import {JwtPayload, Session} from '@shopify/shopify-api';

export interface SessionTokenContext {
shop: string;
sessionId: string;
sessionId?: string;
payload?: JwtPayload;
}

export interface AuthorizationStrategy {
respondToOAuthRequests: (request: Request) => Promise<void | never>;
authenticate: (
request: Request,
sessionToken: string,
) => Promise<SessionContext | never>;
session: Session | undefined,
shop: string,
) => Promise<Session | never>;
}

0 comments on commit cd4e13d

Please sign in to comment.