@mapcreator/api 5.0.0-alpha.69 → 5.0.0-alpha.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/oauth.ts CHANGED
@@ -8,19 +8,27 @@ export let token: {
8
8
  toString: () => string;
9
9
  } | null = null;
10
10
 
11
+ let apiClientId = '';
11
12
  let callbackUrl = '';
13
+ let oauthScopes = ['*'];
12
14
 
13
- /**
14
- * Cleanup of previously used data. The code part can be removed in a while.
15
- */
16
- for (let i = 0; i < window.localStorage.length; ++i) {
17
- const key = window.localStorage.key(i);
15
+ const anchorParams = ['access_token', 'token_type', 'expires_in', 'state'];
18
16
 
19
- if (key?.startsWith('_m4n_')) {
20
- window.localStorage.removeItem(key);
21
- }
17
+ const storagePrefix = '_m4n_';
18
+ const statePrefix = 'oauth_state_';
19
+ const storageName = 'api_token';
20
+
21
+ const dummyTokenExpires = new Date('2100-01-01T01:00:00');
22
+
23
+ interface AnchorToken {
24
+ access_token: string;
25
+ token_type: string;
26
+ expires_in: string;
27
+ state: string;
22
28
  }
23
29
 
30
+ const titleCase = (str: unknown): string => String(str).toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
31
+
24
32
  /**
25
33
  * Setup internal structures to use dummy authentication flow
26
34
  *
@@ -32,9 +40,9 @@ export function initDummyFlow(apiUrl: string, oauthToken: string): void {
32
40
 
33
41
  apiHost = apiUrl.replace(/\/+$/, '');
34
42
  token = {
35
- type: parts[0].toLowerCase().replace(/\b\w/g, c => c.toUpperCase()),
43
+ type: titleCase(parts[0]),
36
44
  token: parts[1],
37
- expires: new Date('2100-01-01T01:00:00'),
45
+ expires: dummyTokenExpires,
38
46
 
39
47
  toString(): string {
40
48
  return `${this.type} ${this.token}`;
@@ -46,39 +54,261 @@ export function initDummyFlow(apiUrl: string, oauthToken: string): void {
46
54
  * Setup internal structures to use implicit authentication flow
47
55
  *
48
56
  * @param {string} apiUrl - Full API URL
57
+ * @param {string} clientId - OAuth client id
49
58
  * @param {string} [redirectUrl] - Callback URL
59
+ * @param {string[]} [scopes] - A list of required scopes
50
60
  */
51
- export function initImplicitFlow(apiUrl: string, redirectUrl = ''): void {
61
+ export function initImplicitFlow(apiUrl: string, clientId: string, redirectUrl = '', scopes = ['*']): void {
52
62
  apiHost = apiUrl.replace(/\/+$/, '');
53
63
 
64
+ apiClientId = String(clientId);
54
65
  callbackUrl = String(redirectUrl || window.location.href.split('#')[0]);
66
+ oauthScopes = scopes;
67
+
68
+ {
69
+ const key = `${storagePrefix}${storageName}`;
70
+ const data = window.localStorage.getItem(key);
71
+
72
+ if (data) {
73
+ try {
74
+ const obj = JSON.parse(data) as { type?: unknown; token?: unknown; expires?: unknown };
75
+
76
+ if (
77
+ typeof obj.type === 'string' &&
78
+ typeof obj.token === 'string' &&
79
+ typeof obj.expires === 'string' &&
80
+ new Date(obj.expires) > new Date()
81
+ ) {
82
+ token = {
83
+ type: titleCase(obj.type),
84
+ token: obj.token,
85
+ expires: new Date(obj.expires),
86
+
87
+ toString(): string {
88
+ return `${this.type} ${this.token}`;
89
+ },
90
+ };
91
+ } else {
92
+ window.localStorage.removeItem(key);
93
+ }
94
+ } catch (e) {
95
+ /* */
96
+ }
97
+ }
98
+ }
99
+
100
+ {
101
+ const obj = getAnchorToken();
102
+
103
+ if (isAnchorToken(obj)) {
104
+ // We'll not go there if anchor contains error and/or message
105
+ // This means that anchor parameters will be preserved for the next processing
106
+ cleanAnchorParams();
107
+
108
+ const expires = new Date(Date.now() + Number(obj.expires_in) * 1000);
109
+
110
+ if (isValidState(obj.state) && expires > new Date()) {
111
+ token = {
112
+ type: titleCase(obj.token_type),
113
+ token: obj.access_token,
114
+ expires,
115
+
116
+ toString(): string {
117
+ return `${this.type} ${this.token}`;
118
+ },
119
+ };
120
+
121
+ const key = `${storagePrefix}${storageName}`;
122
+ const data = { type: token.type, token: token.token, expires: expires.toUTCString() };
123
+
124
+ window.localStorage.setItem(key, JSON.stringify(data));
125
+ } else {
126
+ // TODO: add some logic to handle this
127
+ // throw Error('Invalid state in url');
128
+ }
129
+ }
130
+ }
55
131
 
56
- const href = sessionStorage.getItem('redirect-url');
132
+ if (authenticated()) {
133
+ const href = sessionStorage.getItem('redirect-url');
57
134
 
58
- if (href) {
59
- sessionStorage.removeItem('redirect-url');
60
- window.history.replaceState(null, document.title, href);
135
+ if (href) {
136
+ sessionStorage.removeItem('redirect-url');
137
+ window.history.replaceState(null, document.title, href);
138
+ }
61
139
  }
62
140
  }
63
141
 
64
- export async function authenticate(): Promise<never> {
142
+ export async function authenticate(): Promise<string> | never {
65
143
  return new Promise(() => {
144
+ if (anchorContainsError()) {
145
+ console.error(getError());
146
+ cleanAnchorParams();
147
+ }
148
+
149
+ forget();
150
+
66
151
  sessionStorage.setItem('redirect-url', window.location.href);
67
- window.location.assign(`${apiHost}/login?${new URLSearchParams({ redirect_uri: callbackUrl })}`);
152
+ window.location.assign(buildRedirectUrl());
68
153
  });
69
154
  }
70
155
 
71
- // eslint-disable-next-line @typescript-eslint/naming-convention
72
- type AuthHeaders = { Authorization: string } | { 'X-XSRF-Token': string } | undefined;
156
+ export function authenticated(): boolean {
157
+ return token != null && token.expires > new Date() && (
158
+ token.expires.valueOf() === dummyTokenExpires.valueOf() ||
159
+ !!window.localStorage.getItem(`${storagePrefix}${storageName}`)
160
+ );
161
+ }
162
+
163
+ export async function logout(): Promise<void> {
164
+ if (token) {
165
+ await fetch(`${apiHost}/oauth/logout`, {
166
+ method: 'POST',
167
+ headers: {
168
+ Accept: 'application/json',
169
+ Authorization: token.toString(),
170
+ },
171
+ });
172
+ }
173
+
174
+ forget();
175
+ }
176
+
177
+ function forget(): void {
178
+ for (let i = 0; i < window.localStorage.length; ++i) {
179
+ const key = window.localStorage.key(i);
180
+
181
+ if (key?.startsWith(storagePrefix)) {
182
+ window.localStorage.removeItem(key);
183
+ }
184
+ }
185
+
186
+ token = null;
187
+ }
188
+
189
+ function buildRedirectUrl(): string {
190
+ const queryParams = new URLSearchParams({
191
+ client_id: apiClientId,
192
+ redirect_uri: callbackUrl,
193
+ response_type: 'token',
194
+ scope: oauthScopes.join(' '),
195
+ state: generateState(),
196
+ });
197
+
198
+ return `${apiHost}/oauth/authorize?${queryParams}`;
199
+ }
200
+
201
+ function getAnchorQuery(): string {
202
+ return window.location.hash.replace(/^#\/?/, '');
203
+ }
204
+
205
+ function getAnchorParams(): Record<string, unknown> {
206
+ const query = getAnchorQuery();
207
+ // eslint-disable-next-line @stylistic/padding-line-between-statements,@typescript-eslint/no-unsafe-return
208
+ return Object.fromEntries(query.split('&').map(pair => pair.split('=').map(decodeURIComponent)));
209
+ }
210
+
211
+ function getAnchorToken(): Partial<AnchorToken> {
212
+ const params = getAnchorParams();
213
+
214
+ return Object.fromEntries(Object.entries(params).filter(([key]) => anchorParams.includes(key)));
215
+ }
216
+
217
+ function isAnchorToken(anchorToken: Partial<AnchorToken>): anchorToken is AnchorToken {
218
+ const queryKeys = Object.keys(anchorToken);
219
+
220
+ return anchorParams.every(key => queryKeys.includes(key));
221
+ }
222
+
223
+ function cleanAnchorParams(): void {
224
+ const query = window.location.hash.replace(/^#\/?/, '');
225
+ const targets = [...anchorParams, 'error', 'message'];
226
+ const newHash = query
227
+ .split('&')
228
+ .filter(pair => !targets.includes(decodeURIComponent(pair.split('=')[0])))
229
+ .join('&');
230
+
231
+ if (newHash) {
232
+ window.location.hash = newHash;
233
+ } else {
234
+ const { origin, pathname, search } = window.location;
235
+
236
+ window.history.replaceState(null, document.title, `${origin}${pathname}${search}`);
237
+ }
238
+ }
239
+
240
+ function isValidState(state: string): boolean {
241
+ const key = `${storagePrefix}${statePrefix}${state}`;
242
+ const found = window.localStorage.getItem(key) != null;
243
+
244
+ if (found) {
245
+ window.localStorage.removeItem(key);
246
+ }
247
+
248
+ return found;
249
+ }
250
+
251
+ function anchorContainsError(): boolean {
252
+ return 'error' in getAnchorParams();
253
+ }
73
254
 
74
- export function getAuthorizationHeaders(method: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'): AuthHeaders {
75
- const cookie = !token && method !== 'GET' && method !== 'HEAD'
76
- ? document.cookie.split(/ *; */).find(pair => pair.startsWith('XSRF-TOKEN'))?.split('=')[1]
77
- : undefined;
255
+ function generateState(): string {
256
+ // @ts-expect-error TS2365
257
+ // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
258
+ const state = (([1e7] + -1e3 + -4e3 + -8e3 + -1e11) as string).replace(
259
+ /[018]/g, // @ts-expect-error TS2362
260
+ c => (c ^ ((Math.random() * 256) & (0x0f >>> (c >>> 2)))).toString(16),
261
+ );
262
+ const key = `${storagePrefix}${statePrefix}${state}`;
78
263
 
79
- return token
80
- ? { Authorization: token.toString() }
81
- : cookie
82
- ? { 'X-XSRF-Token': decodeURIComponent(cookie) }
83
- : undefined;
264
+ window.localStorage.setItem(key, `${Date.now()}`);
265
+
266
+ return state;
267
+ }
268
+
269
+ class OAuthError extends Error {
270
+ error: string;
271
+
272
+ constructor(message: string, error: unknown) {
273
+ super(message);
274
+
275
+ this.error = String(error);
276
+ }
277
+
278
+ toString(): string {
279
+ let error = this.error;
280
+
281
+ if (error.includes('_')) {
282
+ error = error.replace('_', ' ').replace(/^./, c => c.toUpperCase());
283
+ }
284
+
285
+ return this.message ? `${error}: ${this.message}` : error;
286
+ }
287
+ }
288
+
289
+ function getError(): OAuthError {
290
+ const params = getAnchorParams();
291
+
292
+ return params.message
293
+ ? new OAuthError(params.message as string, params.error)
294
+ : new OAuthError(titleCase(params.error), params.error);
295
+ }
296
+
297
+ /**
298
+ * Our goal is to support even obsolete platforms (ES2017+ / Node.js 8.10+).
299
+ * This is a small polyfill for possibly missing method used in our codebase.
300
+ */
301
+ if (!Object.fromEntries) { // eslint-disable-next-line arrow-body-style
302
+ Object.fromEntries = <T = never>(entries: Iterable<readonly [string | number, T]>): { [k: string]: T } => {
303
+ return Array.from(entries).reduce<{ [k: string]: T }>(
304
+ (object, entry) => {
305
+ if (!Array.isArray(entry)) {
306
+ throw new TypeError(`Iterator value ${entry as unknown as string} is not an entry object.`);
307
+ }
308
+ object[`${entry[0]}`] = entry[1];
309
+
310
+ return object;
311
+ }, {}
312
+ );
313
+ };
84
314
  }
package/src/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { apiHost, authenticate, getAuthorizationHeaders, token } from './oauth.js';
2
1
  import type { CamelCasedProperties, SnakeCasedProperties } from 'type-fest';
2
+ import { apiHost, authenticate, token } from './oauth.js';
3
3
 
4
4
  export type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;
5
5
 
@@ -233,6 +233,7 @@ function getRequestInit<I extends ApiCommon, O extends Record<string, unknown>>(
233
233
  extraHeaders?: Record<string, string> | null,
234
234
  extraOptions?: ExtraOptions<I, O>,
235
235
  ): RequestInit {
236
+ const authorization = token ? { Authorization: token.toString() } : null;
236
237
  let contentType = null as { 'Content-Type': string } | null;
237
238
 
238
239
  if (body !== undefined) {
@@ -258,11 +259,10 @@ function getRequestInit<I extends ApiCommon, O extends Record<string, unknown>>(
258
259
  }
259
260
  }
260
261
 
261
- const method = extraOptions?.method ?? (body != null ? 'POST' : 'GET'); // don't touch `!=` please
262
- const authorization = getAuthorizationHeaders(method);
263
262
  const headers = { Accept: 'application/json', ...authorization, ...contentType, ...extraHeaders };
263
+ const method = extraOptions?.method ?? (body != null ? 'POST' : 'GET'); // don't touch `!=` please
264
264
 
265
- return { body, headers, method, ...!token && { credentials: 'include' } } as RequestInit;
265
+ return { body, headers, method } as RequestInit;
266
266
  }
267
267
 
268
268
  interface Context<I extends ApiCommon, O extends Record<string, unknown>> {