Skip to content

Commit d9fcd28

Browse files
committed
Restructure files
1 parent 33d1d8b commit d9fcd28

File tree

4 files changed

+357
-353
lines changed

4 files changed

+357
-353
lines changed

src/SlicknodeLink.ts

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
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+
}

src/__tests__/index-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {ApolloLink, execute, FetchResult, GraphQLRequest, Observable} from 'apol
22
import {expect} from 'chai';
33
import gql from 'graphql-tag';
44
import sinon from 'sinon';
5-
import SlicknodeLink, {REFRESH_TOKEN_MUTATION} from '../index';
5+
import SlicknodeLink, {REFRESH_TOKEN_MUTATION} from '../SlicknodeLink';
66
import {IAuthTokenSet} from '../types';
77

88
describe('SlicknodeLink', () => {

0 commit comments

Comments
 (0)