UNPKG

@saleor/sdk

Version:
237 lines (214 loc) 6.63 kB
import { ApolloClient, FetchResult, InMemoryCache, NormalizedCacheObject, Reference, createHttpLink, } from "@apollo/client"; import fetch from "cross-fetch"; import jwtDecode from "jwt-decode"; import { JWTToken } from "../core"; import { AuthSDK, auth } from "../core/auth"; import { storage } from "../core/storage"; import { isInternalToken } from "../helpers"; import { TypedTypePolicies } from "./apollo-helpers"; import { ExternalRefreshMutation, RefreshTokenMutation } from "./types"; let client: ApolloClient<NormalizedCacheObject>; let authClient: AuthSDK; let refreshPromise: | ReturnType<AuthSDK["refreshToken"]> | ReturnType<AuthSDK["refreshExternalToken"]> | null = null; const isTokenRefreshExternal = ( result: RefreshTokenMutation | ExternalRefreshMutation ): result is ExternalRefreshMutation => "externalRefresh" in result; export type FetchConfig = Partial<{ /** * Enable auto token refreshing. Default to `true`. */ autoTokenRefresh: boolean; /** * Set a value for skew between local time and token expiration date in * seconds (only together with `autoTokenRefresh`). Defaults to `120`. */ tokenRefreshTimeSkew: number; /** * Refresh token and retry the request when Saleor responds with `Unauthorized` error. * Defaults to `true`. */ refreshOnUnauthorized: boolean; }>; export const createFetch = ({ autoTokenRefresh = true, tokenRefreshTimeSkew = 120, refreshOnUnauthorized = true, }: FetchConfig = {}) => async ( input: RequestInfo, init: RequestInit = {} ): Promise<Response> => { if (!client) { throw new Error( "Could not find Saleor's client instance. Did you forget to call createSaleorClient()?" ); } let token = storage.getAccessToken(); try { if ( ["refreshToken", "externalRefresh"].includes( // INFO: Non-null assertion is enabled because the block is wrapped inside try/catch // eslint-disable-next-line @typescript-eslint/no-non-null-assertion JSON.parse(init.body!.toString()).operationName ) ) { return fetch(input, init); } } catch (e) {} if (autoTokenRefresh && token) { // auto refresh token before provided time skew (in seconds) until it expires const decodedToken = jwtDecode<JWTToken>(token); const expirationTime = (decodedToken.exp - tokenRefreshTimeSkew) * 1000; const owner = decodedToken.owner; try { if (refreshPromise) { await refreshPromise; } else if (Date.now() >= expirationTime) { if (isInternalToken(owner)) { await authClient.refreshToken(); } else { await authClient.refreshExternalToken(); } } } catch (e) { } finally { refreshPromise = null; } token = storage.getAccessToken(); } if (token) { init.headers = { ...init.headers, "authorization-bearer": token, }; } if (refreshOnUnauthorized && token) { const response = await fetch(input, init); const data: FetchResult = await response.clone().json(); const isUnauthenticated = data?.errors?.some( error => error.extensions?.exception.code === "ExpiredSignatureError" ); let refreshTokenResponse: FetchResult< RefreshTokenMutation | ExternalRefreshMutation, Record<string, unknown>, Record<string, unknown> > | null = null; const owner = jwtDecode<JWTToken>(token).owner; if (isUnauthenticated) { try { if (refreshPromise) { refreshTokenResponse = await refreshPromise; } else { refreshPromise = isInternalToken(owner) ? authClient.refreshToken() : authClient.refreshExternalToken(); refreshTokenResponse = await refreshPromise; } if ( refreshTokenResponse.data && isTokenRefreshExternal(refreshTokenResponse.data) ? refreshTokenResponse.data.externalRefresh?.token : refreshTokenResponse.data?.tokenRefresh?.token ) { // check if mutation returns a valid token after refresh and retry the request return createFetch({ autoTokenRefresh: false, refreshOnUnauthorized: false, })(input, init); } else { // after Saleor returns ExpiredSignatureError status and token refresh fails // we log out the user and return the failed response authClient.logout(); } } catch (e) { } finally { refreshPromise = null; } } return response; } return fetch(input, init); }; const getTypePolicies = (autologin: boolean): TypedTypePolicies => ({ Query: { fields: { authenticated: { read(_, { readField, toReference }): boolean { return !!readField( "id", toReference({ __typename: "User", }) ); }, }, me: { read(_, { toReference, canRead }): Reference | undefined | null { const ref = toReference({ __typename: "User", }); return canRead(ref) ? ref : null; }, }, authenticating: { read( read = autologin && !!storage.getRefreshToken(), { readField } ): boolean { if (readField("authenticated")) { return false; } return read; }, }, }, }, User: { /** * IMPORTANT * This works as long as we have 1 User cache object which is the current logged in User. * If the client should ever fetch additional Users, this should be removed * and the login methods (token create or verify) should be responsible for writing USER query cache manually. */ keyFields: [], fields: { addresses: { merge: false, }, }, }, }); export const createApolloClient = ( apiUrl: string, autologin: boolean, fetchOptions?: FetchConfig ): ApolloClient<NormalizedCacheObject> => { const httpLink = createHttpLink({ fetch: createFetch(fetchOptions), uri: apiUrl, credentials: "include", }); const cache = new InMemoryCache({ typePolicies: getTypePolicies(autologin), }); client = new ApolloClient({ cache, link: httpLink, }); /** * Refreshing token code should stay under core/auth.ts To get this method available, * we need to call "auth()" here. refreshToken mutation doesn't require channel, so it * doesn't have to be populated with value. */ authClient = auth({ apolloClient: client, channel: "" }); return client; };