@saleor/sdk
Version:
Saleor TS SDK
237 lines (214 loc) • 6.63 kB
text/typescript
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;
};