@urql/exchange-auth
Version:
An exchange for managing authentication and token refresh in urql
1 lines • 17.8 kB
Source Map (JSON)
{"version":3,"file":"urql-exchange-auth.min.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","authPromise","bypassQueue","Set","retries","makeSubject","errors","retryQueue","Map","flushQueue","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","refreshAuth","set","opsWithAuth$","Boolean","map","delete","has","willAuthError","merge","source","didAuthError"],"mappings":"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"}