|
| 1 | +import {ApolloLink, createOperation, FetchResult, NextLink, Observable, Operation} from 'apollo-link'; |
| 2 | +import {removeDirectivesFromDocument} from 'apollo-utilities'; |
| 3 | +import { DirectiveNode, FieldNode, OperationDefinitionNode } from 'graphql'; |
| 4 | +import gql from 'graphql-tag'; |
| 5 | +import MemoryStorage from './storage/MemoryStorage'; |
| 6 | +import {IAuthTokenSet, ISlicknodeLinkOptions, IStorage} from './types'; |
| 7 | + |
| 8 | +const REFRESH_TOKEN_KEY = ':auth:refreshToken'; |
| 9 | +const REFRESH_TOKEN_EXPIRES_KEY = ':auth:refreshTokenExpires'; |
| 10 | +const ACCESS_TOKEN_KEY = ':auth:accessToken'; |
| 11 | +const ACCESS_TOKEN_EXPIRES_KEY = ':auth:accessTokenExpires'; |
| 12 | + |
| 13 | +const DEFAULT_NAMESPACE = 'slicknode'; |
| 14 | + |
| 15 | +declare var global: { |
| 16 | + localStorage: IStorage; |
| 17 | +}; |
| 18 | + |
| 19 | +export const REFRESH_TOKEN_MUTATION = gql`mutation refreshToken($token: String!) { |
| 20 | + refreshAuthToken(input: {refreshToken: $token}) { |
| 21 | + accessToken |
| 22 | + refreshToken |
| 23 | + accessTokenLifetime |
| 24 | + refreshTokenLifetime |
| 25 | + } |
| 26 | +}`; |
| 27 | + |
| 28 | +export const LOGOUT_MUTATION = gql`mutation logout($refreshToken: String) { |
| 29 | + logoutUser(input:{refreshToken:$refreshToken}) { |
| 30 | + success |
| 31 | + } |
| 32 | +}`; |
| 33 | + |
| 34 | +const authenticationDirectiveRemoveConfig = { |
| 35 | + test: (directive: DirectiveNode) => directive.name.value === 'authenticate', |
| 36 | + remove: false, |
| 37 | +}; |
| 38 | + |
| 39 | +/** |
| 40 | + * SlicknodeLink instance to be used to load data with apollo-client |
| 41 | + * from slicknode GraphQL servers |
| 42 | + */ |
| 43 | +export default class SlicknodeLink extends ApolloLink { |
| 44 | + |
| 45 | + public options: ISlicknodeLinkOptions; |
| 46 | + public storage: IStorage; |
| 47 | + public namespace: string; |
| 48 | + |
| 49 | + /** |
| 50 | + * Constructor |
| 51 | + * @param options |
| 52 | + */ |
| 53 | + constructor(options: ISlicknodeLinkOptions = {}) { |
| 54 | + super(); |
| 55 | + this.options = options; |
| 56 | + this.namespace = options.namespace || DEFAULT_NAMESPACE; |
| 57 | + this.storage = options.storage || global.localStorage || new MemoryStorage(); |
| 58 | + } |
| 59 | + |
| 60 | + /** |
| 61 | + * |
| 62 | + * @param {Operation} operation |
| 63 | + * @param {NextLink} forward |
| 64 | + * @returns {Observable<FetchResult> | null} |
| 65 | + */ |
| 66 | + public request( |
| 67 | + operation: Operation, |
| 68 | + forward?: NextLink, |
| 69 | + ): Observable<FetchResult> | null { |
| 70 | + if (!forward) { |
| 71 | + throw new Error( |
| 72 | + 'Network link is missing in apollo client or SlicknodeLink is last link in the chain.', |
| 73 | + ); |
| 74 | + } |
| 75 | + return new Observable<FetchResult>((observer) => { |
| 76 | + this.getAuthHeaders(forward) |
| 77 | + .then((authHeaders) => { |
| 78 | + operation.setContext(({headers}: {headers: any}) => ({ |
| 79 | + headers: { |
| 80 | + ...(headers || {}), |
| 81 | + ...authHeaders, |
| 82 | + }, |
| 83 | + })); |
| 84 | + |
| 85 | + const definitions = operation.query.definitions; |
| 86 | + // Find current operation in definitions |
| 87 | + const currentOperation: OperationDefinitionNode | null = definitions.find((operationDefinition) => { |
| 88 | + return ( |
| 89 | + operationDefinition.kind === 'OperationDefinition' && |
| 90 | + operationDefinition.name && |
| 91 | + operationDefinition.name.value === operation.operationName |
| 92 | + ); |
| 93 | + }) as OperationDefinitionNode | null; |
| 94 | + |
| 95 | + // Check mutations for directives and logoutMutation |
| 96 | + const resultListeners: Array<(value: any) => void> = []; |
| 97 | + if (currentOperation && currentOperation.operation === 'mutation') { |
| 98 | + const fields: FieldNode[] = []; |
| 99 | + currentOperation.selectionSet.selections.forEach((selectionNode) => { |
| 100 | + if (selectionNode.kind === 'Field') { |
| 101 | + fields.push(selectionNode); |
| 102 | + } else { |
| 103 | + // @TODO: Collect all relevant fields recursively from fragments / child fragments |
| 104 | + // tslint:disable-next-line no-console |
| 105 | + console.warn('Fragments not supported on Mutation type for slicknode-apollo-link'); |
| 106 | + } |
| 107 | + }); |
| 108 | + |
| 109 | + fields.forEach((field) => { |
| 110 | + if (field.name.value === 'logoutUser') { |
| 111 | + // Subscribe to result to remove auth tokens from storage |
| 112 | + resultListeners.push(() => { |
| 113 | + this.debug('Removing auth tokens from storage'); |
| 114 | + this.logout(); |
| 115 | + }); |
| 116 | + } else if ( |
| 117 | + field.directives && |
| 118 | + field.directives.find((directive) => directive.name.value === 'authenticate') |
| 119 | + ) { |
| 120 | + const fieldName = field.alias ? field.alias.value : field.name.value; |
| 121 | + // Subscribe to result to set auth token set |
| 122 | + resultListeners.push((result) => { |
| 123 | + // Validate auth token set and update tokens if valid |
| 124 | + if ( |
| 125 | + result.data && |
| 126 | + result.data.hasOwnProperty(fieldName) && |
| 127 | + typeof result.data[fieldName] === 'object' |
| 128 | + ) { |
| 129 | + const tokenSet = result.data[fieldName]; |
| 130 | + this.validateAndSetAuthTokenSet(tokenSet); |
| 131 | + } else { |
| 132 | + this.debug('No valid token set returned'); |
| 133 | + } |
| 134 | + }); |
| 135 | + } |
| 136 | + }); |
| 137 | + } |
| 138 | + // Remove @authenticated directives from document |
| 139 | + operation.query = removeDirectivesFromDocument( |
| 140 | + [ authenticationDirectiveRemoveConfig ], |
| 141 | + operation.query, |
| 142 | + ); |
| 143 | + const nextObservable = forward(operation); |
| 144 | + |
| 145 | + // Add result listeners for token and logout processing |
| 146 | + resultListeners.map((listener) => nextObservable.subscribe(listener)); |
| 147 | + nextObservable.subscribe(observer); |
| 148 | + }) |
| 149 | + .catch((error) => { |
| 150 | + this.debug('Error obtaining auth headers in SlicknodeLink'); |
| 151 | + observer.error(error); |
| 152 | + }); |
| 153 | + }); |
| 154 | + } |
| 155 | + |
| 156 | + /** |
| 157 | + * Returns true if the client has a valid access token |
| 158 | + * |
| 159 | + * @returns {boolean} |
| 160 | + */ |
| 161 | + public hasAccessToken(): boolean { |
| 162 | + return Boolean(this.getAccessToken()); |
| 163 | + } |
| 164 | + |
| 165 | + /** |
| 166 | + * Returns true if the client has a valid refresh token |
| 167 | + * |
| 168 | + * @returns {boolean} |
| 169 | + */ |
| 170 | + public hasRefreshToken(): boolean { |
| 171 | + return Boolean(this.getRefreshToken()); |
| 172 | + } |
| 173 | + |
| 174 | + /** |
| 175 | + * Updates the auth token set |
| 176 | + * @param token |
| 177 | + */ |
| 178 | + public setAuthTokenSet(token: IAuthTokenSet): void { |
| 179 | + this.setAccessToken(token.accessToken); |
| 180 | + this.setAccessTokenExpires(token.accessTokenLifetime * 1000 + Date.now()); |
| 181 | + this.setRefreshToken(token.refreshToken); |
| 182 | + this.setRefreshTokenExpires(token.refreshTokenLifetime * 1000 + Date.now()); |
| 183 | + } |
| 184 | + |
| 185 | + /** |
| 186 | + * Stores the refreshToken in the storage of the client |
| 187 | + * @param token |
| 188 | + */ |
| 189 | + public setRefreshToken(token: string) { |
| 190 | + const key = this.namespace + REFRESH_TOKEN_KEY; |
| 191 | + this.storage.setItem(key, token); |
| 192 | + } |
| 193 | + |
| 194 | + /** |
| 195 | + * Returns the refresh token, NULL if none was stored yet |
| 196 | + * @returns {string|null} |
| 197 | + */ |
| 198 | + public getRefreshToken(): string | null { |
| 199 | + if ((this.getRefreshTokenExpires() || 0) < Date.now()) { |
| 200 | + return null; |
| 201 | + } |
| 202 | + const key = this.namespace + REFRESH_TOKEN_KEY; |
| 203 | + return this.storage.getItem(key); |
| 204 | + } |
| 205 | + |
| 206 | + /** |
| 207 | + * Sets the time when the auth token expires |
| 208 | + */ |
| 209 | + public setAccessTokenExpires(timestamp: number | null) { |
| 210 | + const key = this.namespace + ACCESS_TOKEN_EXPIRES_KEY; |
| 211 | + if (timestamp) { |
| 212 | + this.storage.setItem(key, String(timestamp)); |
| 213 | + } else { |
| 214 | + this.storage.removeItem(key); |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + /** |
| 219 | + * Returns the UNIX Timestamp when the refresh token expires |
| 220 | + * @returns {number|null} |
| 221 | + */ |
| 222 | + public getRefreshTokenExpires(): number | null { |
| 223 | + const key = this.namespace + REFRESH_TOKEN_EXPIRES_KEY; |
| 224 | + const expires = this.storage.getItem(key); |
| 225 | + return expires ? parseInt(expires, 10) : null; |
| 226 | + } |
| 227 | + |
| 228 | + /** |
| 229 | + * Sets the time when the auth token expires |
| 230 | + */ |
| 231 | + public setRefreshTokenExpires( |
| 232 | + timestamp: number | null, |
| 233 | + ): void { |
| 234 | + const key = this.namespace + REFRESH_TOKEN_EXPIRES_KEY; |
| 235 | + this.storage.setItem(key, String(timestamp)); |
| 236 | + } |
| 237 | + |
| 238 | + /** |
| 239 | + * Returns the UNIX Timestamp when the access token expires |
| 240 | + * @returns {number|null} |
| 241 | + */ |
| 242 | + public getAccessTokenExpires(): number | null { |
| 243 | + const key = this.namespace + ACCESS_TOKEN_EXPIRES_KEY; |
| 244 | + const expires = this.storage.getItem(key) || null; |
| 245 | + return expires ? parseInt(expires, 10) : null; |
| 246 | + } |
| 247 | + |
| 248 | + /** |
| 249 | + * Writes the access token to storage |
| 250 | + * @param token |
| 251 | + */ |
| 252 | + public setAccessToken(token: string): void { |
| 253 | + const key = this.namespace + ACCESS_TOKEN_KEY; |
| 254 | + this.storage.setItem(key, token); |
| 255 | + } |
| 256 | + |
| 257 | + /** |
| 258 | + * Returns the access token, NULL if no valid token was found |
| 259 | + * @returns {null} |
| 260 | + */ |
| 261 | + public getAccessToken(): string | null { |
| 262 | + // Check if is expired |
| 263 | + if ((this.getAccessTokenExpires() || 0) < Date.now()) { |
| 264 | + return null; |
| 265 | + } |
| 266 | + const key = this.namespace + ACCESS_TOKEN_KEY; |
| 267 | + return this.storage.getItem(key) || null; |
| 268 | + } |
| 269 | + |
| 270 | + /** |
| 271 | + * Clears all tokens in the storage |
| 272 | + */ |
| 273 | + public async logout(): Promise<void> { |
| 274 | + this.storage.removeItem(this.namespace + REFRESH_TOKEN_KEY); |
| 275 | + this.storage.removeItem(this.namespace + REFRESH_TOKEN_EXPIRES_KEY); |
| 276 | + this.storage.removeItem(this.namespace + ACCESS_TOKEN_KEY); |
| 277 | + this.storage.removeItem(this.namespace + ACCESS_TOKEN_EXPIRES_KEY); |
| 278 | + } |
| 279 | + |
| 280 | + /** |
| 281 | + * Returns the headers that are required to authenticate at the GraphQL endpoint. |
| 282 | + * If no access tokens are available, an attempt is made to retrieve it from the backend |
| 283 | + * with the refreshToken |
| 284 | + */ |
| 285 | + public getAuthHeaders(forward: NextLink): Promise<HeadersInit> { |
| 286 | + return new Promise<{[key: string]: string}>((resolve, reject) => { |
| 287 | + const accessToken = this.options.accessToken || this.getAccessToken(); |
| 288 | + const refreshToken = this.getRefreshToken(); |
| 289 | + |
| 290 | + if (accessToken) { |
| 291 | + this.debug('Using valid access token'); |
| 292 | + resolve({ |
| 293 | + Authorization: `Bearer ${accessToken}`, |
| 294 | + }); |
| 295 | + return; |
| 296 | + } |
| 297 | + |
| 298 | + // We have no token, try to get it from API via next link |
| 299 | + if (!accessToken && refreshToken) { |
| 300 | + this.debug('No valid access token found, obtaining new AuthTokenSet with refresh token'); |
| 301 | + const refreshOperation = createOperation({}, { |
| 302 | + query: REFRESH_TOKEN_MUTATION, |
| 303 | + variables: { |
| 304 | + token: refreshToken, |
| 305 | + }, |
| 306 | + }); |
| 307 | + const observer = forward(refreshOperation); |
| 308 | + observer.subscribe({ |
| 309 | + error: (error) => { |
| 310 | + this.debug(`Error refreshing AuthTokenSet: ${error.message}`); |
| 311 | + this.logout(); |
| 312 | + resolve({}); |
| 313 | + }, |
| 314 | + next: (result) => { |
| 315 | + if (result.data && result.data.refreshAuthToken) { |
| 316 | + this.validateAndSetAuthTokenSet(result.data.refreshAuthToken); |
| 317 | + } else { |
| 318 | + this.debug('Refreshing auth token mutation failed'); |
| 319 | + this.logout(); |
| 320 | + } |
| 321 | + resolve({}); |
| 322 | + }, |
| 323 | + }); |
| 324 | + } else { |
| 325 | + resolve({}); |
| 326 | + } |
| 327 | + }); |
| 328 | + } |
| 329 | + |
| 330 | + protected validateAndSetAuthTokenSet(tokenSet: any): boolean { |
| 331 | + if ( |
| 332 | + typeof tokenSet === 'object' && |
| 333 | + typeof tokenSet.accessToken === 'string' && |
| 334 | + typeof tokenSet.accessTokenLifetime === 'number' && |
| 335 | + typeof tokenSet.refreshToken === 'string' && |
| 336 | + typeof tokenSet.refreshTokenLifetime === 'number' |
| 337 | + ) { |
| 338 | + // Update auth tokens in storage of link |
| 339 | + this.setAuthTokenSet(tokenSet); |
| 340 | + this.debug('Login successful, auth token set updated'); |
| 341 | + return true; |
| 342 | + } |
| 343 | + |
| 344 | + this.debug('The auth token set has no valid format'); |
| 345 | + return false; |
| 346 | + } |
| 347 | + |
| 348 | + protected debug(message: string) { |
| 349 | + if (this.options.debug) { |
| 350 | + console.log(`[Slicknode Auth] ${message}`); // tslint:disable-line no-console |
| 351 | + } |
| 352 | + } |
| 353 | +} |
0 commit comments