forked from octokit/auth-oauth-user-client.js
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmod.ts
471 lines (429 loc) · 16.1 KB
/
mod.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
import type {
GitHubAppAuthentication,
OAuthAppAuthentication,
} from "@octokit/auth-oauth-user";
import type {
EndpointDefaults,
EndpointOptions,
OctokitResponse,
RequestInterface,
RequestParameters,
Route,
} from "@octokit/types";
import { oauthAuthorizationUrl } from "@octokit/oauth-authorization-url";
/*
## Types
An `AuthStrategy` is a function that takes a single parameter of type
`AuthStrategyOptions` and returns an `Authenticator`. An `Authenticator` is
also a function (with state of type `AuthenticatorState`) that takes an
`AuthenticatorMethods` and returns an `Auth` with `token`.
*/
type ClientTypes = OAuthApp | GitHubApp;
export type OAuthApp = "oauth-app";
export type GitHubApp = "github-app";
/**
* A generic version of `AuthInterface` defined in [@octokit/types.ts][1]
* [1]: https://github.com/octokit/types.ts/blob/master/src/AuthInterface.ts
*
* > Interface to implement complex authentication strategies for Octokit.
* An object Implementing the AuthInterface can directly be passed as the
* `auth` option in the Octokit constructor.
*
* > For the official implementations of the most common authentication
* strategies, see https://github.com/octokit/auth.js
*/
export interface AuthStrategy<
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
> {
(
method?: AuthenticatorMethods<ClientType, ExpirationEnabled>,
): Promise<Auth<ClientType, ExpirationEnabled> | null>;
hook<T = unknown>(
request: RequestInterface,
route: Route | EndpointOptions,
parameters?: RequestParameters,
): Promise<OctokitResponse<T>>;
}
/**
* Supported methods of a created client authentication strategy:
*
* 1. Get token
* 2. [Sign in](https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#1-request-a-users-github-identity)
* 3. [Create an app token](https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github)
* 4. [Check a token](https://docs.github.com/en/rest/reference/apps#check-a-token)
* 5. [Create a scoped access token](https://docs.github.com/en/rest/reference/apps#create-a-scoped-access-token)
* 6. [Reset a token](https://docs.github.com/en/rest/reference/apps#reset-a-token)
* 7. [Renewing a user token with a refresh token](https://docs.github.com/en/developers/apps/building-github-apps/refreshing-user-to-server-access-tokens#renewing-a-user-token-with-a-refresh-token)
* 8. [Delete an app token](https://docs.github.com/en/rest/reference/apps#delete-an-app-token) (sign out)
* 9. [Delete an app
* authorization](https://docs.github.com/en/rest/reference/apps#delete-an-app-authorization)
*/
export type AuthenticatorMethods<
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
> =
| { type: "getToken" }
| (
& {
type: "signIn";
login?: string;
allowSignup?: boolean;
}
& (ClientType extends OAuthApp ? { scopes?: string[] }
: Record<never, never>)
)
| { type: "createToken" }
| { type: "checkToken" }
| (ClientType extends OAuthApp ? { type: "createScopedToken" } : never)
| { type: "resetToken" }
| (ExpirationEnabled extends true ? { type: "renewToken" } : never)
| { type: "deleteToken"; offline?: boolean }
| { type: "deleteAuthorization" };
/**
* Authentication object returned from [`@octokit/oauth-app.js`][1].
*
* [1]: https://github.com/octokit/oauth-app.js
*/
export type Auth<
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
> =
& (ExpirationEnabled extends true ? {
expiresAt: string;
refreshToken: string;
refreshTokenExpiresAt: string;
}
: Record<never, never>)
& Omit<
(ClientType extends OAuthApp ? OAuthAppAuthentication
: GitHubAppAuthentication),
"clientSecret"
>;
/**
* State of an authenticator. Missing options have default values.
*/
type AuthenticatorState<
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
> = Required<AuthStrategyOptions<ClientType, ExpirationEnabled>>;
/** Options to create an authenticator. */
export type AuthStrategyOptions<
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
> =
& MandatoryAuthStrategyOptions<ClientType, ExpirationEnabled>
& Partial<OptionalAuthStrategyOptions<ClientType, ExpirationEnabled>>;
/** Mandatory options to create an authenticator. */
type MandatoryAuthStrategyOptions<
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
> = {
clientId: string;
clientType: ClientType;
expirationEnabled: ExpirationEnabled;
};
/** Optional options to create an authenticator. */
type OptionalAuthStrategyOptions<
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
> = {
// generic properties
auth: Auth<ClientType, ExpirationEnabled> | null;
authStore: Store<Auth<ClientType, ExpirationEnabled>> | false;
defaultScopes: ClientType extends OAuthApp ? string[] : never;
// non-generic properties
location: Location;
fetch: typeof fetch;
serviceOrigin: string;
servicePathPrefix: string;
stateStore: Store<string> | false;
};
/**
* Generic store to persist authentication object or oauth `state` for [web
* application flow][1].
*
* [1]: https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow
*/
export type Store<T> = {
get: () => T | null | Promise<T | null>;
set: (value: T | null) => void | Promise<void>;
};
// TODO: making @octokit/oauth-authorization-url generic
type OAuthAuthorizationUrlOptions<ClientType extends ClientTypes> =
& {
clientType: ClientType;
clientId: string;
allowSignup?: boolean;
login?: string;
redirectUrl?: string;
state?: string;
baseUrl?: string;
}
& (ClientType extends OAuthApp ? { scopes?: string | string[] }
: Record<never, never>);
type OAuthAuthorizationUrlResult<ClientType extends ClientTypes> = {
allowSignup: boolean;
clientId: string;
clientType: ClientType;
login?: string;
redirectUrl?: string;
state: string;
url: string;
} & (ClientType extends OAuthApp ? { scopes: string[] } : Record<never, never>);
declare module "@octokit/oauth-authorization-url" {
function oauthAuthorizationUrl<ClientType extends ClientTypes>(
options: OAuthAuthorizationUrlOptions<ClientType>,
): OAuthAuthorizationUrlResult<ClientType>;
}
/* ## Metadata */
export const NAME = "@octokit/auth-oauth-user-client.js";
export const VERSION = "0.1.0";
/* ## Authentication Strategy */
export const createOAuthUserClientAuth = <
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
>(
options: AuthStrategyOptions<ClientType, ExpirationEnabled>,
): AuthStrategy<ClientType, ExpirationEnabled> => {
if (options.clientType === "oauth-app" && options.expirationEnabled) {
throw Error("OAuth App does not support token expiration.");
}
const defaultOptions = {
authStore: createLocalStore(`AUTH:${options.clientId}`),
stateStore: createLocalStore(`STATE:${options.clientId}`),
auth: null,
...(options.clientType === "oauth-app"
? { defaultScopes: [] as string[] }
: {}),
serviceOrigin: location.origin,
servicePathPrefix: "/api/github/oauth",
location,
fetch,
} as OptionalAuthStrategyOptions<ClientType, ExpirationEnabled>;
const state = { ...defaultOptions, ...options };
if (options.auth && state.authStore) state.authStore.set(options.auth);
const _auth = auth(state);
return Object.assign(_auth, { hook: hook(_auth) });
};
/* ## Authentication Methods */
const auth = <
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
>(
state: AuthenticatorState<ClientType, ExpirationEnabled>,
) => {
type Command = AuthenticatorMethods<ClientType, ExpirationEnabled>;
const authStore = state.authStore || undefined;
const stateStore = state.stateStore || undefined;
const fetchAuth = async (
type: keyof typeof endpoints,
token: string | null,
body: Record<string, unknown> | null,
) => {
let auth: Auth<ClientType, ExpirationEnabled> | null = null;
try {
auth = (await fetchOAuthApp(state, type, token, body))
?.authentication || null;
} catch (error) {
if (/bad_refresh_token/.test(error.message)) auth = null;
else throw error;
}
if (auth) auth = { ...(state.auth || {}), ...auth };
return await setAuth(auth);
};
const setAuth = async (
auth: Auth<ClientType, ExpirationEnabled> | null = null,
) => {
await authStore?.set(auth);
return (state.auth = auth);
};
return async function auth(
command: Command = { type: "getToken" },
): Promise<Auth<ClientType, ExpirationEnabled> | null> {
const { type, ...commandOptions } = command;
const url = new URL(state.location.href);
const code = url.searchParams.get("code");
const newState = url.searchParams.get("state");
switch (type) {
case "signIn": {
await setAuth(); // clear local auth before redirecting
const newState = Math.random().toString(36).substring(2);
stateStore?.set(newState);
const redirectUrl = oauthAuthorizationUrl<ClientType>({
clientType: state.clientType,
clientId: state.clientId,
redirectUrl: state.location.href,
state: newState,
...commandOptions,
} as OAuthAuthorizationUrlOptions<ClientType>).url;
state.location.href = redirectUrl;
return null;
}
case "getToken": {
if (!code || !newState) {
state.auth ||= (await authStore?.get()) || null;
if (!state.auth) return null;
if (
// @ts-ignore better than a one-time assertion function
!state.auth.expiresAt || new Date(state.auth.expiresAt) > new Date()
) {
return state.auth;
}
return await auth({ type: "renewToken" } as Command);
}
}
/* falls through */
case "createToken": {
if (!code || !newState) {
throw Error('Both "code" & "state" parameters are required.');
}
url.searchParams.delete("code");
url.searchParams.delete("state");
const redirectUrl = url.href;
// @ts-ignore mock `window.history` in tests
window.history.replaceState({}, "", redirectUrl);
const oldState = (await stateStore?.get());
await stateStore?.set(null);
if (stateStore && (newState != oldState)) {
throw Error("State mismatch.");
}
return await fetchAuth("createToken", null, { code, redirectUrl });
}
case "checkToken":
case "createScopedToken":
case "resetToken":
case "renewToken":
case "deleteToken":
case "deleteAuthorization": {
let body: Record<string, unknown> | null = null;
if (["POST", "PUT", "PATCH"].includes(endpoints[type]?.[0])) {
const { type: _, ..._payload } = command as Record<string, unknown>;
body = _payload;
}
if (type === "deleteToken" && command.offline) return await setAuth();
if (type === "renewToken") {
if (state.auth) {
const auth = state.auth as Auth<ClientType, true>;
const renewableUntil = new Date(auth.refreshTokenExpiresAt);
if (new Date() > renewableUntil) return await setAuth();
body!.refreshToken = auth.refreshToken;
}
} else state.auth = await auth();
if (!state.auth) throw Error("Unauthorized.");
const { token } = state.auth; // TODO: does `renewToken` need token?
if (type.startsWith("delete")) await setAuth();
return await fetchAuth(type, token, body);
}
}
};
};
/* ## Fetch OAuth App */
const fetchOAuthApp = async <
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
>(
state: AuthenticatorState<ClientType, ExpirationEnabled>,
command: keyof typeof endpoints,
token: string | null,
body: Record<string, unknown> | null,
) => {
const [method, path] = endpoints[command];
const headers: Record<string, string> = {
"user-agent": `${NAME}/${VERSION} ${navigator.userAgent}`,
...(token ? { authorization: "token " + token } : {}),
...(body ? { "content-type": "application/json; charset=utf-8" } : {}),
accept: "application/json",
};
const route = state.serviceOrigin + state.servicePathPrefix + path;
const { fetch } = state;
const response = await fetch(route, {
method,
headers,
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!response.ok) throw new Error(await response.text());
if (response.status === 204) return null;
return await response.json();
};
type AnyResponse<T> = OctokitResponse<T>;
/* ## Create (Generic) Local Store */
const createLocalStore = <T>(key: string): Store<T> => {
const _key = NAME + ":" + key;
const _localStorage = localStorage;
return {
get: () => {
const text = _localStorage.getItem(_key);
return text ? JSON.parse(text) as T : null;
},
set: (value) => {
value
? _localStorage.setItem(_key, JSON.stringify(value))
: _localStorage.removeItem(_key);
},
};
};
/*
## OAuth App Endpoints
TODO: better defined in `oauth-app.js`?
*/
const endpoints = {
createToken: ["POST", "/token"],
checkToken: ["GET", "/token"],
createScopedToken: ["POST", "/token/scoped"],
resetToken: ["PATCH", "/token"],
renewToken: ["PATCH", "/refresh-token"],
deleteToken: ["DELETE", "/token"],
deleteAuthorization: ["DELETE", "/grant"],
} as const;
/*
## Hooks
TODO: should be part of `octokit/core`?
*/
const hook = <
ClientType extends ClientTypes,
ExpirationEnabled extends boolean,
>(_auth: () => Promise<Auth<ClientType, ExpirationEnabled> | null>): <T>(
request: RequestInterface,
route: Route | EndpointOptions,
parameters: RequestParameters,
) => Promise<AnyResponse<T>> => {
return async <T>(
request: RequestInterface,
route: Route | EndpointOptions,
parameters: RequestParameters = {},
): Promise<AnyResponse<T>> => {
const endpoint = request.endpoint.merge(
route as string,
parameters,
) as EndpointDefaults & { url: string };
// The following endpoints require an OAuth App to authenticate using its
// client_id and client_secret. Unable to perform basic authentication
// since client secret is missing.
//
// - [`POST /applications/{client_id}/token`](https://docs.github.com/en/rest/reference/apps#check-a-token) - Check a token
// - [`PATCH /applications/{client_id}/token`](https://docs.github.com/en/rest/reference/apps#reset-a-token) - Reset a token
// - [`POST /applications/{client_id}/token/scoped`](https://docs.github.com/en/rest/reference/apps#create-a-scoped-access-token) - Create a scoped access token
// - [`DELETE /applications/{client_id}/token`](https://docs.github.com/en/rest/reference/apps#delete-an-app-token) - Delete an app token
// - [`DELETE
// /applications/{client_id}/grant`](https://docs.github.com/en/rest/reference/apps#delete-an-app-authorization)
// - Delete an app authorization
//
// deprecated:
// - [`GET /applications/{client_id}/tokens/{access_token}`](https://docs.github.com/en/rest/reference/apps#check-an-authorization) - Check an authorization
// - [`POST /applications/{client_id}/tokens/{access_token}`](https://docs.github.com/en/rest/reference/apps#reset-an-authorization) - Reset an authorization
// - [`DELETE /applications/{client_id}/tokens/{access_token}`](https://docs.github.com/en/rest/reference/apps#revoke-an-authorization-for-an-application) - Revoke an authorization for an application
// - [`DELETE /applications/{client_id}/grants/{access_token}`](https://docs.github.com/en/rest/reference/apps#revoke-a-grant-for-an-application) - Revoke a grant for an application
if (/\/applications\/[^/]+\/(token|grant)s?/.test(endpoint.url)) {
throw Error("Basic authentication is unsupported.");
}
// do not intercept OAuth Web flow requests
const oauthWebFlowUrls = /\/login\/(oauth\/access_token|device\/code)$/;
if (!oauthWebFlowUrls.test(endpoint.url)) {
const auth = await _auth();
const token = auth?.token;
if (token) endpoint.headers.authorization = "token " + token;
}
return request(endpoint);
};
};