UNPKG

@urql/exchange-auth

Version:

An exchange for managing authentication and token refresh in urql

1 lines 18.1 kB
{"version":3,"file":"urql-exchange-auth.mjs","sources":["../src/authExchange.ts"],"sourcesContent":["import type { Source } from 'wonka';\nimport {\n pipe,\n map,\n filter,\n onStart,\n take,\n makeSubject,\n toPromise,\n merge,\n} from 'wonka';\n\nimport type {\n Operation,\n OperationContext,\n OperationResult,\n CombinedError,\n Exchange,\n DocumentInput,\n AnyVariables,\n OperationInstance,\n} from '@urql/core';\nimport { createRequest, makeOperation, makeErrorResult } from '@urql/core';\n\n/** Utilities to use while refreshing authentication tokens. */\nexport interface AuthUtilities {\n /** Sends a mutation to your GraphQL API, bypassing earlier exchanges and authentication.\n *\n * @param query - a GraphQL document containing the mutation operation that will be executed.\n * @param variables - the variables used to execute the operation.\n * @param context - {@link OperationContext} options that'll be used in future exchanges.\n * @returns A `Promise` of an {@link OperationResult} for the GraphQL mutation.\n *\n * @remarks\n * The `mutation()` utility method is useful when your authentication requires you to make a GraphQL mutation\n * request to update your authentication tokens. In these cases, you likely wish to bypass prior exchanges and\n * the authentication in the `authExchange` itself.\n *\n * This method bypasses the usual mutation flow of the `Client` and instead issues the mutation as directly\n * as possible. This also means that it doesn’t carry your `Client`'s default {@link OperationContext}\n * options, so you may have to pass them again, if needed.\n */\n mutate<Data = any, Variables extends AnyVariables = AnyVariables>(\n query: DocumentInput<Data, Variables>,\n variables: Variables,\n context?: Partial<OperationContext>\n ): Promise<OperationResult<Data>>;\n\n /** Adds additional HTTP headers to an `Operation`.\n *\n * @param operation - An {@link Operation} to add headers to.\n * @param headers - The HTTP headers to add to the `Operation`.\n * @returns The passed {@link Operation} with the headers added to it.\n *\n * @remarks\n * The `appendHeaders()` utility method is useful to add additional HTTP headers\n * to an {@link Operation}. It’s a simple convenience function that takes\n * `operation.context.fetchOptions` into account, since adding headers for\n * authentication is common.\n */\n appendHeaders(\n operation: Operation,\n headers: Record<string, string>\n ): Operation;\n}\n\n/** Configuration for the `authExchange` returned by the initializer function you write. */\nexport interface AuthConfig {\n /** Called for every operation to add authentication data to your operation.\n *\n * @param operation - An {@link Operation} that needs authentication tokens added.\n * @returns a new {@link Operation} with added authentication tokens.\n *\n * @remarks\n * The {@link authExchange} will call this function you provide and expects that you\n * add your authentication tokens to your operation here, on the {@link Operation}\n * that is returned.\n *\n * Hint: You likely want to modify your `fetchOptions.headers` here, for instance to\n * add an `Authorization` header.\n */\n addAuthToOperation(operation: Operation): Operation;\n\n /** Called before an operation is forwaded onwards to make a request.\n *\n * @param operation - An {@link Operation} that needs authentication tokens added.\n * @returns a boolean, if true, authentication must be refreshed.\n *\n * @remarks\n * The {@link authExchange} will call this function before an {@link Operation} is\n * forwarded onwards to your following exchanges.\n *\n * When this function returns `true`, the `authExchange` will call\n * {@link AuthConfig.refreshAuth} before forwarding more operations\n * to prompt you to update your authentication tokens.\n *\n * Hint: If you define this function, you can use it to check whether your authentication\n * tokens have expired.\n */\n willAuthError?(operation: Operation): boolean;\n\n /** Called after receiving an operation result to check whether it has failed with an authentication error.\n *\n * @param error - A {@link CombinedError} that a result has come back with.\n * @param operation - The {@link Operation} of that has failed.\n * @returns a boolean, if true, authentication must be refreshed.\n *\n * @remarks\n * The {@link authExchange} will call this function if it sees an {@link OperationResult}\n * with a {@link CombinedError} on it, implying that it may have failed due to an authentication\n * error.\n *\n * When this function returns `true`, the `authExchange` will call\n * {@link AuthConfig.refreshAuth} before forwarding more operations\n * to prompt you to update your authentication tokens.\n * Afterwards, this operation will be retried once.\n *\n * Hint: You should define a function that detects your API’s authentication\n * errors, e.g. using `result.extensions`.\n */\n didAuthError(error: CombinedError, operation: Operation): boolean;\n\n /** Called to refresh the authentication state.\n *\n * @remarks\n * The {@link authExchange} will call this function if either {@link AuthConfig.willAuthError}\n * or {@link AuthConfig.didAuthError} have returned `true` prior, which indicates that the\n * authentication state you hold has expired or is out-of-date.\n *\n * When this function is called, you should refresh your authentication state.\n * For instance, if you have a refresh token and an access token, you should rotate\n * these tokens with your API by sending the refresh token.\n *\n * Hint: You can use the {@link fetch} API here, or use {@link AuthUtilities.mutate}\n * if your API requires a GraphQL mutation to refresh your authentication state.\n */\n refreshAuth(): Promise<void>;\n}\n\nconst addAuthAttemptToOperation = (\n operation: Operation,\n authAttempt: boolean\n) =>\n makeOperation(operation.kind, operation, {\n ...operation.context,\n authAttempt,\n });\n\n/** Creates an `Exchange` handling control flow for authentication.\n *\n * @param init - An initializer function that returns an {@link AuthConfig} wrapped in a `Promise`.\n * @returns the created authentication {@link Exchange}.\n *\n * @remarks\n * The `authExchange` is used to create an exchange handling authentication and\n * the control flow of refresh authentication.\n *\n * You must pass an initializer function, which receives {@link AuthUtilities} and\n * must return an {@link AuthConfig} wrapped in a `Promise`.\n * When this exchange is used in your `Client`, it will first call your initializer\n * function, which gives you an opportunity to get your authentication state, e.g.\n * from local storage.\n *\n * You may then choose to validate this authentication state and update it, and must\n * then return an {@link AuthConfig}.\n *\n * This configuration defines how you add authentication state to {@link Operation | Operations},\n * when your authentication state expires, when an {@link OperationResult} has errored\n * with an authentication error, and how to refresh your authentication state.\n *\n * @example\n * ```ts\n * authExchange(async (utils) => {\n * let token = localStorage.getItem('token');\n * let refreshToken = localStorage.getItem('refreshToken');\n * return {\n * addAuthToOperation(operation) {\n * return utils.appendHeaders(operation, {\n * Authorization: `Bearer ${token}`,\n * });\n * },\n * didAuthError(error) {\n * return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');\n * },\n * async refreshAuth() {\n * const result = await utils.mutate(REFRESH, { token });\n * if (result.data?.refreshLogin) {\n * token = result.data.refreshLogin.token;\n * refreshToken = result.data.refreshLogin.refreshToken;\n * localStorage.setItem('token', token);\n * localStorage.setItem('refreshToken', refreshToken);\n * }\n * },\n * };\n * });\n * ```\n */\nexport function authExchange(\n init: (utilities: AuthUtilities) => Promise<AuthConfig>\n): Exchange {\n return ({ client, forward }) => {\n const bypassQueue = new Set<OperationInstance | undefined>();\n const retries = makeSubject<Operation>();\n const errors = makeSubject<OperationResult>();\n\n let retryQueue = new Map<number, Operation>();\n\n function flushQueue() {\n authPromise = undefined;\n const queue = retryQueue;\n retryQueue = new Map();\n queue.forEach(retries.next);\n }\n\n function errorQueue(error: Error) {\n authPromise = undefined;\n const queue = retryQueue;\n retryQueue = new Map();\n queue.forEach(operation => {\n errors.next(makeErrorResult(operation, error));\n });\n }\n\n let authPromise: Promise<void> | void;\n let config: AuthConfig | null = null;\n\n return operations$ => {\n function initAuth() {\n authPromise = Promise.resolve()\n .then(() =>\n init({\n mutate<Data = any, Variables extends AnyVariables = AnyVariables>(\n query: DocumentInput<Data, Variables>,\n variables: Variables,\n context?: Partial<OperationContext>\n ): Promise<OperationResult<Data>> {\n const baseOperation = client.createRequestOperation(\n 'mutation',\n createRequest(query, variables),\n context\n );\n return pipe(\n result$,\n onStart(() => {\n const operation = addAuthToOperation(baseOperation);\n bypassQueue.add(\n operation.context._instance as OperationInstance\n );\n retries.next(operation);\n }),\n filter(\n result =>\n result.operation.key === baseOperation.key &&\n baseOperation.context._instance ===\n result.operation.context._instance\n ),\n take(1),\n toPromise\n );\n },\n appendHeaders(\n operation: Operation,\n headers: Record<string, string>\n ) {\n const fetchOptions =\n typeof operation.context.fetchOptions === 'function'\n ? operation.context.fetchOptions()\n : operation.context.fetchOptions || {};\n return makeOperation(operation.kind, operation, {\n ...operation.context,\n fetchOptions: {\n ...fetchOptions,\n headers: {\n ...fetchOptions.headers,\n ...headers,\n },\n },\n });\n },\n })\n )\n .then((_config: AuthConfig) => {\n if (_config) config = _config;\n flushQueue();\n })\n .catch((error: Error) => {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n 'authExchange()’s initialization function has failed, which is unexpected.\\n' +\n 'If your initialization function is expected to throw/reject, catch this error and handle it explicitly.\\n' +\n 'Unless this error is handled it’ll be passed onto any `OperationResult` instantly and authExchange() will block further operations and retry.',\n error\n );\n }\n\n errorQueue(error);\n });\n }\n\n initAuth();\n\n function refreshAuth(operation: Operation) {\n // add to retry queue to try again later\n retryQueue.set(\n operation.key,\n addAuthAttemptToOperation(operation, true)\n );\n\n // check that another operation isn't already doing refresh\n if (config && !authPromise) {\n authPromise = config.refreshAuth().then(flushQueue).catch(errorQueue);\n }\n }\n\n function willAuthError(operation: Operation) {\n return (\n !operation.context.authAttempt &&\n config &&\n config.willAuthError &&\n config.willAuthError(operation)\n );\n }\n\n function didAuthError(result: OperationResult) {\n return (\n config &&\n config.didAuthError &&\n config.didAuthError(result.error!, result.operation)\n );\n }\n\n function addAuthToOperation(operation: Operation) {\n return config ? config.addAuthToOperation(operation) : operation;\n }\n\n const opsWithAuth$ = pipe(\n merge([retries.source, operations$]),\n map(operation => {\n if (operation.kind === 'teardown') {\n retryQueue.delete(operation.key);\n return operation;\n } else if (\n operation.context._instance &&\n bypassQueue.has(operation.context._instance)\n ) {\n return operation;\n } else if (operation.context.authAttempt) {\n return addAuthToOperation(operation);\n } else if (authPromise || !config) {\n if (!authPromise) initAuth();\n\n if (!retryQueue.has(operation.key))\n retryQueue.set(\n operation.key,\n addAuthAttemptToOperation(operation, false)\n );\n\n return null;\n } else if (willAuthError(operation)) {\n refreshAuth(operation);\n return null;\n }\n\n return addAuthToOperation(\n addAuthAttemptToOperation(operation, false)\n );\n }),\n filter(Boolean)\n ) as Source<Operation>;\n\n const result$ = pipe(opsWithAuth$, forward);\n\n return merge([\n errors.source,\n pipe(\n result$,\n filter(result => {\n if (\n !bypassQueue.has(result.operation.context._instance) &&\n result.error &&\n didAuthError(result) &&\n !result.operation.context.authAttempt\n ) {\n refreshAuth(result.operation);\n return false;\n }\n\n if (bypassQueue.has(result.operation.context._instance)) {\n bypassQueue.delete(result.operation.context._instance);\n }\n\n return true;\n })\n ),\n ]);\n };\n };\n}\n"],"names":["addAuthAttemptToOperation","operation","authAttempt","makeOperation","kind","context","authExchange","init","client","forward","bypassQueue","Set","retries","makeSubject","errors","retryQueue","Map","flushQueue","authPromise","undefined","queue","forEach","next","errorQueue","error","makeErrorResult","config","operations$","initAuth","Promise","resolve","then","mutate","query","variables","baseOperation","createRequestOperation","createRequest","toPromise","take","filter","result","key","_instance","onStart","addAuthToOperation","add","result$","appendHeaders","headers","fetchOptions","_config","catch","process","env","NODE_ENV","console","warn","refreshAuth","set","opsWithAuth$","Boolean","map","delete","has","willAuthError","merge","source","didAuthError"],"mappings":"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"}