oauth2-cli 0.5.1 → 0.7.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +87 -1
  3. package/dist/Client.d.ts +102 -46
  4. package/dist/Client.js +175 -112
  5. package/dist/Credentials.d.ts +30 -0
  6. package/dist/Errors/BadResponse.d.ts +3 -0
  7. package/dist/Errors/BadResponse.js +7 -0
  8. package/dist/Errors/IndeterminateConfiguration.d.ts +3 -0
  9. package/dist/Errors/IndeterminateConfiguration.js +5 -0
  10. package/dist/Errors/MissingAccessToken.d.ts +3 -0
  11. package/dist/Errors/MissingAccessToken.js +5 -0
  12. package/dist/Errors/PortCollision.d.ts +3 -0
  13. package/dist/Errors/PortCollision.js +5 -0
  14. package/dist/Errors/index.d.ts +4 -0
  15. package/dist/Errors/index.js +4 -0
  16. package/dist/Request/Injection.d.ts +21 -0
  17. package/dist/Request/Injection.js +1 -0
  18. package/dist/Request/Scope.d.ts +2 -0
  19. package/dist/Request/Scope.js +6 -0
  20. package/dist/Request/index.d.ts +2 -0
  21. package/dist/Request/index.js +2 -0
  22. package/dist/Session.d.ts +47 -0
  23. package/dist/Session.js +77 -0
  24. package/dist/{FileStorage.d.ts → Token/FileStorage.d.ts} +2 -3
  25. package/dist/{FileStorage.js → Token/FileStorage.js} +3 -4
  26. package/dist/Token/Response.d.ts +2 -0
  27. package/dist/Token/Response.js +1 -0
  28. package/dist/Token/TokenStorage.d.ts +4 -0
  29. package/dist/Token/TokenStorage.js +1 -0
  30. package/dist/Token/addHelpers.d.ts +8 -0
  31. package/dist/Token/addHelpers.js +44 -0
  32. package/dist/Token/index.d.ts +3 -0
  33. package/dist/Token/index.js +3 -0
  34. package/dist/WebServer.d.ts +64 -0
  35. package/dist/WebServer.js +116 -0
  36. package/dist/index.d.ts +5 -3
  37. package/dist/index.js +5 -3
  38. package/package.json +11 -6
  39. package/dist/Localhost.d.ts +0 -14
  40. package/dist/Localhost.js +0 -88
  41. package/dist/Token.d.ts +0 -18
  42. package/dist/Token.js +0 -26
  43. package/dist/TokenStorage.d.ts +0 -5
  44. /package/dist/{TokenStorage.js → Credentials.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ ## [0.7.0](https://github.com/battis/oauth2-cli/compare/oauth2-cli/0.6.0...oauth2-cli/0.7.0) (2026-02-16)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * make more properties of Client accessible to subclasses
11
+
12
+ ### Features
13
+
14
+ * make more properties of Client accessible to subclasses ([74ef874](https://github.com/battis/oauth2-cli/commit/74ef874804323a9c3c496ddf7b0e24bac9e671e1))
15
+
16
+ ## [0.6.0](https://github.com/battis/oauth2-cli/compare/oauth2-cli/0.5.1...oauth2-cli/0.6.0) (2026-02-15)
17
+
18
+
19
+ ### ⚠ BREAKING CHANGES
20
+
21
+ * limit TokenStorage to storing _only_ refresh_tokens
22
+ * improve express shutdown, refactor entire package
23
+
24
+ ### Features
25
+
26
+ * compatible with @battis/google-cloud-run-to-localhost ([3e449b1](https://github.com/battis/oauth2-cli/commit/3e449b14d546759f7e6543c86350ea83b60a80a7))
27
+ * display authorization starting URL on command line as well as opening browser ([fb72bcf](https://github.com/battis/oauth2-cli/commit/fb72bcffafaf66453f8f8f0f8af4b27ef11827c6))
28
+ * externalize path/port identification to gcrtl ([0f6c28b](https://github.com/battis/oauth2-cli/commit/0f6c28b31a7888eac524b28bcdc9c8eabbf57d91))
29
+ * fallback to package templates if ejs present but no alternative template providedd ([6b05454](https://github.com/battis/oauth2-cli/commit/6b05454976b1fb4144f91ba51fc1c9331f1c0f34))
30
+ * limit TokenStorage to storing _only_ refresh_tokens ([3de9c96](https://github.com/battis/oauth2-cli/commit/3de9c96510d15eebd51a0be7d8df278614541f95))
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * attempt to authorize and retry when encountering 401 error ([bc75fed](https://github.com/battis/oauth2-cli/commit/bc75fed052b548e41411287f7d98fa78c3f27ee5))
36
+ * improve express shutdown, refactor entire package ([a740313](https://github.com/battis/oauth2-cli/commit/a740313c33d26f07ebab5b282607d8828cf3c3a7))
37
+
5
38
  ## [0.5.1](https://github.com/battis/oauth2-cli/compare/oauth2-cli/0.5.0...oauth2-cli/0.5.1) (2026-01-20)
6
39
 
7
40
 
package/README.md CHANGED
@@ -13,4 +13,90 @@ npm i oauth2-cli
13
13
 
14
14
  ## Usage
15
15
 
16
- See [examples/get-token](../../examples/get-token).
16
+ ```ts
17
+ import { Client, FileStorage } from 'oauth2-cli';
18
+
19
+ type ExpectedResponse = {
20
+ value: string;
21
+ };
22
+
23
+ const client = new Client({
24
+ client_id: 'm3C6dGQJPJrvgwN97mTP4pWVH9smZGrr',
25
+ client_secret: '2XUktyxU2KQmQAoVHxQXNaHZ4G7XqJdP',
26
+ redirect_uri: 'http://localhost:3000/example/redirect',
27
+ authorization_endpoint: 'https://example.com/oauth2/auth',
28
+ token_endpoint: 'https://example.com/oauth2/token',
29
+ storage: new FileStorage('/path/to/token/file.json');
30
+ });
31
+ console.log(
32
+ client.fetchJSON<ExpectedResponse>('https://example.com/path/to/api/endpoint')
33
+ );
34
+ ```
35
+
36
+ Broadly speaking, having provided the configuration, the client is immediately ready to accept requests. If an stored access token is available, it will be used (and transparently refreshed as necessary, if possible). If no access token is available, the authorization flow will be triggered by the request, opening a browser window for the user to sign in and provide their authoriztion.
37
+
38
+ ### Instantiate a `Client`
39
+
40
+ A `Client` requires some minimal information in order to interact with an OAuth 2.0 authorized API. The OAuth 2.0 base set is a `client_id`, `client_secret`, `authorization_endpoint`, `token_endpoint`, and a `redirect_uri`. For an OpenID-authenticated API, you could provide a `client_id`, `client_secret`, `issuer`, and `redirect_uri` and the Client will query the issuer for further details regarding required connection parameters (it is built on to of [openid-client](https://www.npmjs.com/package/openid-client)).
41
+
42
+ In both cases, the token can be persisted by passing an implementation of [`TokenStorage`](https://github.com/battis/oauth2-cli/blob/main/packages/oauth2-cli/src/Token/TokenStorage.ts), such as [`FileStorage`](https://github.com/battis/oauth2-cli/blob/main/packages/oauth2-cli/src/Token/FileStorage.ts) which expects a path to a location to store a JSON file of access token data. _There are more secure ways to store your tokens, such as [@oauth2-cli/qui-cli](https://www.npmjs.com/package/@oauth2-cli/qui-cli)'s [`EnvironmentStorage`](https://github.com/battis/oauth2-cli/blob/main/packages/qui-cli/src/EnvironmentStorage.ts) which can be linked to a [1Password vault](https://github.com/battis/qui-cli/tree/main/packages/env#1password-integration)._
43
+
44
+ #### `redirect_uri` to Localhost
45
+
46
+ Since the `redirect_uri` is receiving the authorization code in the Authorization Code token flow, the Client needs to be able to "catch" that redirect. The easy way to do this is to register a localhost address with the API (e.g. `http://localhost:3000/my/redirect/path`). When such a redirect URI is given to the client, it stands up (briefly) a local web server to receive that request at the port and path provided.
47
+
48
+ Not every API accepts a `localhost` redirect (it creates the possibility of CORS exploits that could lead to XSS vulnerabilities). For these APIs, using [gcrtl](https://github.com/battis/google-cloud-run-to-localhost#readme) or a similar system will work as well. (In the specific case of `gcrtl`, `oauth2-cli` will trim the leading `/http/localhost:<port>` from provided `redirect_uri` and expect the _remainder_ of the path.)
49
+
50
+ #### `http` protocol
51
+
52
+ If you would prefer an `https` connection to localhost, you have to roll your own SSL certificate.
53
+
54
+ ### Request an endpoint
55
+
56
+ #### `request()`
57
+
58
+ As noted above, `oauth2-cli` is built on top of [openid-client](https://www.npmjs.com/package/openid-client). The `request()` method is a pass-through to the `openid-client` [fetchProtectedResource()](https://github.com/panva/openid-client/blob/b77d87c1e2f5fef6fab501de615fb83a74a0251f/docs/functions/fetchProtectedResource.md) function, with the configuration and accessToken managed by the Client.
59
+
60
+ ```ts
61
+ class Client {
62
+ // ...
63
+
64
+ public async request(
65
+ url: Req.URL.ish,
66
+ method = 'GET',
67
+ body?: OpenIDClient.FetchBody,
68
+ headers: Req.Headers.ish = {},
69
+ dPoPOptions?: OpenIDClient.DPoPOptions
70
+ ) {
71
+ // ...
72
+ }
73
+ }
74
+ ```
75
+
76
+ [`Req.URL.ish`](https://github.com/battis/oauth2-cli/blob/main/packages/oauth2-cli/src/Request/URL.ts#L3) and [`Req.Headers.ish`](https://github.com/battis/oauth2-cli/blob/main/packages/oauth2-cli/src/Request/Headers.ts#L1) are more forgiving types accepting not just those specific types, but reasonable facsimiles of them.
77
+
78
+ ### `requestJSON<T>()`
79
+
80
+ Given that many APIs return JSON-formatted responses, it is convenient to just get that JSON (optionally pre-typed based on what you expect to receive) rather than having to process the response yourself.
81
+
82
+ ```ts
83
+ class Client {
84
+ // ...
85
+
86
+ public async requestJSON<
87
+ T extends OpenIDClient.JsonValue = OpenIDClient.JsonValue
88
+ >(
89
+ url: Req.URL.ish,
90
+ method = 'GET',
91
+ body?: OpenIDClient.FetchBody,
92
+ headers: Req.Headers.ish = {},
93
+ dPoPOptions?: OpenIDClient.DPoPOptions
94
+ ) {
95
+ // ...
96
+ }
97
+ }
98
+ ```
99
+
100
+ ## Examples
101
+
102
+ [Refer to examples for more detailed usage.](https://github.com/battis/oauth2-cli/tree/main/examples/oauth2-cli#readme)
package/dist/Client.d.ts CHANGED
@@ -1,57 +1,113 @@
1
- import * as Configuration from '@battis/oauth2-configure';
2
- import { JSONValue } from '@battis/typescript-tricks';
1
+ import { PathString } from '@battis/descriptive-types';
2
+ import { Request } from 'express';
3
+ import { EventEmitter } from 'node:events';
3
4
  import * as OpenIDClient from 'openid-client';
4
- import { Token } from './Token.js';
5
- import { TokenStorage } from './TokenStorage.js';
6
- export type Credentials = Configuration.Options & {
7
- scope?: string;
8
- headers?: Record<string, string>;
9
- parameters?: Record<string, string>;
10
- store?: TokenStorage | string;
5
+ import * as requestish from 'requestish';
6
+ import * as Credentials from './Credentials.js';
7
+ import * as Req from './Request/index.js';
8
+ import { Session, SessionOptions } from './Session.js';
9
+ import * as Token from './Token/index.js';
10
+ /**
11
+ * A generic `redirect_uri` to use if the server does not require pre-registered
12
+ * `redirect_uri` values
13
+ */
14
+ export declare const DEFAULT_REDIRECT_URI = "http://localhost:3000/oauth2-cli/redirect";
15
+ export type ClientOptions = {
16
+ /** Credentials for server access */
17
+ credentials: Credentials.Combined;
18
+ /** Optional request components to inject */
19
+ inject?: {
20
+ search?: requestish.URLSearchParams.ish;
21
+ headers?: requestish.Headers.ish;
22
+ body?: requestish.Body.ish;
23
+ };
24
+ /**
25
+ * Optional absolute path to EJS view templates directory, see
26
+ * [WebServer.setViews()](./Webserver.ts)
27
+ */
28
+ views?: PathString;
29
+ /** Optional {@link TokenStorage} implementation to manage tokens */
30
+ storage?: Token.TokenStorage;
11
31
  };
12
- /** Wrap an OpenID configuration in an object-oriented API client. */
13
- export declare class Client {
14
- private credentials;
15
- private tokenMutex;
16
- private config?;
17
- private token?;
18
- private store?;
19
- constructor(credentials: Credentials);
20
- /** Acquire a valid, unexpired API access token. */
21
- getToken(): Promise<Token | undefined>;
22
- /** Refresh Token Grant */
23
- protected refreshToken(token: Token): Promise<Token | undefined>;
24
- /** Acquire a valid OpenID configuration. */
25
- protected getConfiguration(): Promise<OpenIDClient.Configuration>;
26
- /** Authorization Code Grant */
27
- protected authorize(): Promise<Token | undefined>;
28
- /**
29
- * Make an authorized request to the API, acquiring an unexpired acess token
30
- * if necessary.
31
- */
32
- request(url: URL | string, method?: string, body?: OpenIDClient.FetchBody, headers?: Headers, dPoPOptions?: OpenIDClient.DPoPOptions): Promise<Response>;
33
- /**
34
- * Make an authorized request to the API, acquiring an unexpired acess token
35
- * if necessary.
32
+ type RefreshOptions = {
33
+ /**
34
+ * Optional refresh token
36
35
  *
37
- * This converts the Fetch API request into an OpenID request. To directly
38
- * make this request, use {@link request()}
36
+ * If using {@link TokenStorage}, the refresh token should be stored with the
37
+ * access token and does not need to be separately managed and stored
39
38
  */
40
- fetch(endpoint: string | URL | Request, init?: RequestInit): Promise<Response>;
39
+ refresh_token?: string;
40
+ /** Additional request injection for refresh grant flow */
41
+ inject?: Req.Injection;
42
+ };
43
+ type GetTokenOptions = {
41
44
  /**
42
- * Make an authorized request to the API, acquiring an unexpired acess token
43
- * if necessary and parse the JSON respomse.
45
+ * Optional access token
44
46
  *
45
- * @param validator Optional validator function test that the JSON response is
46
- * the expected type.
47
+ * If using {@link TokenStorage}, the access token does not need to be
48
+ * separately managed and stored
47
49
  */
48
- requestJSON<T extends JSONValue = JSONValue>(url: URL | string, method?: string, body?: OpenIDClient.FetchBody, headers?: Headers, dPoPOptions?: OpenIDClient.DPoPOptions): Promise<T>;
50
+ token?: Token.Response;
49
51
  /**
50
- * Make an authorized request to the API, acquiring an unexpired acess token
51
- * if necessary and parse the JSON respomse.
52
+ * Additional request injection for authorization code grant and/or refresh
53
+ * grant flows
54
+ */
55
+ inject?: Req.Injection;
56
+ };
57
+ /**
58
+ * Wrap {@link https://www.npmjs.com/package/openid-client openid-client} in a
59
+ * class instance specific to a particular OAuth/OpenID server credential-set,
60
+ * abstracting away most flows into {@link getToken}
61
+ *
62
+ * Emits {@link Client.TokenEvent} whenever a new access token is received
63
+ */
64
+ export declare class Client extends EventEmitter {
65
+ static readonly TokenEvent = "token";
66
+ protected credentials: Credentials.Combined;
67
+ protected config?: OpenIDClient.Configuration;
68
+ protected views?: PathString;
69
+ protected inject?: Req.Injection;
70
+ private token?;
71
+ private tokenLock;
72
+ private storage?;
73
+ constructor({ credentials, views, inject, storage }: ClientOptions);
74
+ get redirect_uri(): requestish.URL.ish;
75
+ /**
76
+ * @throws IndeterminateConfiguration if provided credentials combined with
77
+ * OpenID discovery fail to generate a complete configuration
78
+ */
79
+ getConfiguration(): Promise<OpenIDClient.Configuration>;
80
+ protected getParameters(session: Session): Promise<URLSearchParams>;
81
+ getAuthorizationUrl(session: Session): Promise<URL>;
82
+ createSession({ views, ...options }: Omit<SessionOptions, 'client'>): Session;
83
+ authorize(options?: Omit<SessionOptions, 'client'>): Promise<Token.Response>;
84
+ handleAuthorizationCodeRedirect(req: Request, session: Session): Promise<void>;
85
+ protected refreshTokenGrant({ refresh_token, inject: request }?: RefreshOptions): Promise<Token.Response | undefined>;
86
+ /**
87
+ * Get an unexpired access token
52
88
  *
53
- * This converts the Fetch API request into an OpenID request. To directly
54
- * make this request, use {@link requestJSON()}
89
+ * Depending on provided and/or stored access token and refresh token values,
90
+ * this may require interactive authorization
91
+ */
92
+ getToken({ token, inject: request }?: GetTokenOptions): Promise<Token.Response>;
93
+ /** @throws MissingAccessToken If response does not include `access_token` */
94
+ protected save(token: Token.Response): Promise<Token.Response>;
95
+ /**
96
+ * @param url If an `issuer` has been defined, `url` accepts paths relative to
97
+ * the `issuer` URL as well as absolute URLs
98
+ * @param method Optional, defaults to `GET` unless otherwise specified
99
+ * @param body Optional
100
+ * @param headers Optional
101
+ * @param dPoPOptions Optional
102
+ */
103
+ request(url: requestish.URL.ish, method?: string, body?: OpenIDClient.FetchBody, headers?: requestish.Headers.ish, dPoPOptions?: OpenIDClient.DPoPOptions): Promise<Response>;
104
+ private toJSON;
105
+ /**
106
+ * Returns the result of {@link request} as a parsed JSON object, optionally
107
+ * typed as `T`
55
108
  */
56
- fetchJSON<T extends JSONValue = JSONValue>(endpoint: string | URL | Request, init?: RequestInit): Promise<T>;
109
+ requestJSON<T extends OpenIDClient.JsonValue = OpenIDClient.JsonValue>(url: requestish.URL.ish, method?: string, body?: OpenIDClient.FetchBody, headers?: requestish.Headers.ish, dPoPOptions?: OpenIDClient.DPoPOptions): Promise<T>;
110
+ fetch(input: requestish.URL.ish, init?: RequestInit, dPoPOptions?: OpenIDClient.DPoPOptions): Promise<Response>;
111
+ fetchJSON<T extends OpenIDClient.JsonValue = OpenIDClient.JsonValue>(input: requestish.URL.ish, init?: RequestInit, dPoPOptions?: OpenIDClient.DPoPOptions): Promise<T>;
57
112
  }
113
+ export {};
package/dist/Client.js CHANGED
@@ -1,140 +1,203 @@
1
- import * as Configuration from '@battis/oauth2-configure';
2
1
  import { Mutex } from 'async-mutex';
2
+ import { EventEmitter } from 'node:events';
3
3
  import * as OpenIDClient from 'openid-client';
4
- import { FileStorage } from './FileStorage.js';
5
- import * as Localhost from './Localhost.js';
6
- import { Token } from './Token.js';
7
- /** Wrap an OpenID configuration in an object-oriented API client. */
8
- export class Client {
4
+ import * as requestish from 'requestish';
5
+ import * as Errors from './Errors/index.js';
6
+ import * as Req from './Request/index.js';
7
+ import { Session } from './Session.js';
8
+ /**
9
+ * A generic `redirect_uri` to use if the server does not require pre-registered
10
+ * `redirect_uri` values
11
+ */
12
+ export const DEFAULT_REDIRECT_URI = 'http://localhost:3000/oauth2-cli/redirect';
13
+ /**
14
+ * Wrap {@link https://www.npmjs.com/package/openid-client openid-client} in a
15
+ * class instance specific to a particular OAuth/OpenID server credential-set,
16
+ * abstracting away most flows into {@link getToken}
17
+ *
18
+ * Emits {@link Client.TokenEvent} whenever a new access token is received
19
+ */
20
+ export class Client extends EventEmitter {
21
+ static TokenEvent = 'token';
9
22
  credentials;
10
- tokenMutex = new Mutex();
11
23
  config;
24
+ views;
25
+ inject;
12
26
  token;
13
- store;
14
- constructor(credentials) {
27
+ tokenLock = new Mutex();
28
+ storage;
29
+ constructor({ credentials, views, inject, storage }) {
30
+ super();
15
31
  this.credentials = credentials;
16
- if (this.credentials.store) {
17
- if (typeof this.credentials.store === 'string') {
18
- this.store = new FileStorage(this.credentials.store);
19
- }
20
- else {
21
- this.store = this.credentials.store;
22
- }
23
- }
32
+ this.views = views;
33
+ this.inject = inject;
34
+ this.storage = storage;
24
35
  }
25
- /** Acquire a valid, unexpired API access token. */
26
- async getToken() {
27
- return await this.tokenMutex.runExclusive((async () => {
28
- if (!this.token) {
29
- this.token = await this.store?.load();
30
- }
31
- if (this.token?.hasExpired()) {
32
- this.token = await this.refreshToken(this.token);
33
- }
34
- if (!this.token) {
35
- this.token = await this.authorize();
36
- }
37
- return this.token;
38
- }).bind(this));
39
- }
40
- /** Refresh Token Grant */
41
- async refreshToken(token) {
42
- if (token.refresh_token) {
43
- const { headers, parameters } = this.credentials;
44
- let freshTokens;
45
- if ((freshTokens = Token.fromResponse(await OpenIDClient.refreshTokenGrant(await Configuration.acquire(this.credentials), token.refresh_token, parameters,
46
- // @ts-expect-error 2322 undocumented arg pass-through to oauth4webapi
47
- { headers }), token.refresh_token))) {
48
- if (this.store) {
49
- await this.store.save(freshTokens);
50
- }
51
- return freshTokens;
52
- }
53
- }
54
- return this.authorize();
36
+ get redirect_uri() {
37
+ return this.credentials.redirect_uri;
55
38
  }
56
- /** Acquire a valid OpenID configuration. */
39
+ /**
40
+ * @throws IndeterminateConfiguration if provided credentials combined with
41
+ * OpenID discovery fail to generate a complete configuration
42
+ */
57
43
  async getConfiguration() {
44
+ if (!this.config && this.credentials.issuer) {
45
+ this.config = await OpenIDClient.discovery(requestish.URL.from(this.credentials.issuer), this.credentials.client_id, { client_secret: this.credentials.client_secret });
46
+ }
47
+ if (!this.config && this.credentials?.authorization_endpoint) {
48
+ this.config = new OpenIDClient.Configuration({
49
+ issuer: `https://${requestish.URL.from(this.credentials.authorization_endpoint).hostname}`,
50
+ authorization_endpoint: requestish.URL.toString(this.credentials.authorization_endpoint),
51
+ token_endpoint: requestish.URL.toString(this.credentials.token_endpoint ||
52
+ this.credentials.authorization_endpoint)
53
+ }, this.credentials.client_id, { client_secret: this.credentials.client_secret });
54
+ }
58
55
  if (!this.config) {
59
- this.config = await Configuration.acquire(this.credentials);
56
+ throw new Errors.IndeterminateConfiguration();
60
57
  }
61
58
  return this.config;
62
59
  }
63
- /** Authorization Code Grant */
64
- async authorize() {
65
- const { scope, redirect_uri, parameters: additionalParameters } = this.credentials;
66
- return new Promise((resolve, reject) => {
67
- const code_verifier = OpenIDClient.randomPKCECodeVerifier();
68
- OpenIDClient.calculatePKCECodeChallenge(code_verifier).then(async (code_challenge) => {
69
- let state = undefined;
70
- const parameters = {
71
- ...additionalParameters,
72
- redirect_uri,
73
- code_challenge,
74
- code_challenge_method: 'S256' // TODO make code challenge method configurable?
75
- };
76
- if (scope) {
77
- parameters.scope = scope;
78
- }
79
- if (!(await this.getConfiguration()).serverMetadata().supportsPKCE()) {
80
- state = OpenIDClient.randomState();
81
- parameters.state = state;
82
- }
83
- await Localhost.redirectServer({
84
- ...this.credentials,
85
- authorization_url: OpenIDClient.buildAuthorizationUrl(await this.getConfiguration(), parameters).href,
86
- code_verifier,
87
- state,
88
- resolve: (async (response) => {
89
- this.token = Token.fromResponse(response);
90
- if (this.token && this.store) {
91
- await this.store.save(this.token);
92
- }
93
- resolve(this.token);
94
- }).bind(this),
95
- reject
96
- });
97
- });
60
+ async getParameters(session) {
61
+ const params = requestish.URLSearchParams.merge(this.inject?.search, session.inject?.search) || new URLSearchParams();
62
+ params.set('redirect_uri', requestish.URL.toString(this.credentials.redirect_uri));
63
+ params.set('code_challenge', await OpenIDClient.calculatePKCECodeChallenge(session.code_verifier));
64
+ params.set('code_challenge_method', 'S256');
65
+ params.set('state', session.state);
66
+ if (this.credentials.scope) {
67
+ params.set('scope', Req.Scope.toString(this.credentials.scope));
68
+ }
69
+ return params;
70
+ }
71
+ async getAuthorizationUrl(session) {
72
+ return OpenIDClient.buildAuthorizationUrl(await this.getConfiguration(), await this.getParameters(session));
73
+ }
74
+ createSession({ views, ...options }) {
75
+ return new Session({
76
+ client: this,
77
+ views: views || this.views,
78
+ ...options
98
79
  });
99
80
  }
100
- /**
101
- * Make an authorized request to the API, acquiring an unexpired acess token
102
- * if necessary.
103
- */
104
- async request(url, method = 'GET', body, headers, dPoPOptions) {
105
- for (const header in this.credentials.headers) {
106
- headers?.append(header, this.credentials.headers[header]);
81
+ async authorize(options = {}) {
82
+ const session = this.createSession(options);
83
+ const token = await this.save(await session.authorizationCodeGrant());
84
+ return token;
85
+ }
86
+ async handleAuthorizationCodeRedirect(req, session) {
87
+ try {
88
+ const response = await OpenIDClient.authorizationCodeGrant(await this.getConfiguration(), new URL(req.url, this.redirect_uri), {
89
+ pkceCodeVerifier: session.code_verifier,
90
+ expectedState: session.state
91
+ }, this.inject?.search
92
+ ? requestish.URLSearchParams.from(this.inject.search)
93
+ : undefined);
94
+ await session.resolve(response);
95
+ }
96
+ catch (error) {
97
+ await session.resolve(undefined, error);
98
+ }
99
+ }
100
+ async refreshTokenGrant({ refresh_token = this.token?.refresh_token, inject: request } = {}) {
101
+ if (!refresh_token && !this.token && this.storage) {
102
+ refresh_token = await this.storage.load();
103
+ }
104
+ if (!refresh_token || refresh_token === '') {
105
+ return undefined;
107
106
  }
108
- return await OpenIDClient.fetchProtectedResource(await this.getConfiguration(), (await this.getToken()).access_token, new URL(url), method, body, headers, dPoPOptions);
107
+ const token = await OpenIDClient.refreshTokenGrant(await this.getConfiguration(), refresh_token, this.inject?.search
108
+ ? requestish.URLSearchParams.from(this.inject.search)
109
+ : undefined, {
110
+ // @ts-expect-error 2322 undocumented arg pass-through to oauth4webapi
111
+ headers: Req.Headers.merge(this.headers, request?.headers)
112
+ });
113
+ return await this.save(token);
109
114
  }
110
115
  /**
111
- * Make an authorized request to the API, acquiring an unexpired acess token
112
- * if necessary.
116
+ * Get an unexpired access token
113
117
  *
114
- * This converts the Fetch API request into an OpenID request. To directly
115
- * make this request, use {@link request()}
118
+ * Depending on provided and/or stored access token and refresh token values,
119
+ * this may require interactive authorization
116
120
  */
117
- async fetch(endpoint, init) {
118
- return this.request(endpoint.toString(), init?.method, init?.body?.toString(), new Headers(init?.headers));
121
+ async getToken({ token, inject: request } = {}) {
122
+ return await this.tokenLock.runExclusive(async () => {
123
+ token = token || this.token;
124
+ if (!this.token?.expiresIn() && this.storage) {
125
+ this.token = await this.refreshTokenGrant({ inject: request });
126
+ }
127
+ if (!this.token) {
128
+ this.token = await this.authorize({ inject: request });
129
+ }
130
+ return this.token;
131
+ });
132
+ }
133
+ /** @throws MissingAccessToken If response does not include `access_token` */
134
+ async save(token) {
135
+ this.token = token;
136
+ if (!token.access_token) {
137
+ throw new Errors.MissingAccessToken();
138
+ }
139
+ if (this.storage && this.token.refresh_token) {
140
+ await this.storage.save(this.token.refresh_token);
141
+ }
142
+ this.emit(Client.TokenEvent, this.token);
143
+ return this.token;
119
144
  }
120
145
  /**
121
- * Make an authorized request to the API, acquiring an unexpired acess token
122
- * if necessary and parse the JSON respomse.
123
- *
124
- * @param validator Optional validator function test that the JSON response is
125
- * the expected type.
146
+ * @param url If an `issuer` has been defined, `url` accepts paths relative to
147
+ * the `issuer` URL as well as absolute URLs
148
+ * @param method Optional, defaults to `GET` unless otherwise specified
149
+ * @param body Optional
150
+ * @param headers Optional
151
+ * @param dPoPOptions Optional
126
152
  */
127
- async requestJSON(url, method = 'GET', body, headers, dPoPOptions) {
128
- return (await (await this.request(url, method, body, headers, dPoPOptions)).json());
153
+ async request(url, method = 'GET', body, headers = {}, dPoPOptions) {
154
+ try {
155
+ url = requestish.URL.from(url);
156
+ }
157
+ catch (error) {
158
+ if (this.credentials.issuer) {
159
+ url = new URL(url, this.credentials.issuer);
160
+ }
161
+ else {
162
+ throw error;
163
+ }
164
+ }
165
+ const request = async () => await OpenIDClient.fetchProtectedResource(await this.getConfiguration(), (await this.getToken()).access_token, requestish.URL.from(requestish.URLSearchParams.appendTo(url, this.inject?.search || {})), method, body, requestish.Headers.merge(this.inject?.headers, headers), dPoPOptions);
166
+ try {
167
+ return await request();
168
+ }
169
+ catch (error) {
170
+ if (typeof error === 'object' &&
171
+ error !== null &&
172
+ 'status' in error &&
173
+ error.status === 401) {
174
+ await this.authorize();
175
+ return await request();
176
+ }
177
+ else {
178
+ throw error;
179
+ }
180
+ }
181
+ }
182
+ async toJSON(response) {
183
+ if (response.ok) {
184
+ return (await response.json());
185
+ }
186
+ else {
187
+ throw new Errors.BadResponse(response);
188
+ }
129
189
  }
130
190
  /**
131
- * Make an authorized request to the API, acquiring an unexpired acess token
132
- * if necessary and parse the JSON respomse.
133
- *
134
- * This converts the Fetch API request into an OpenID request. To directly
135
- * make this request, use {@link requestJSON()}
191
+ * Returns the result of {@link request} as a parsed JSON object, optionally
192
+ * typed as `T`
136
193
  */
137
- async fetchJSON(endpoint, init) {
138
- return (await (await this.fetch(endpoint, init)).json());
194
+ async requestJSON(url, method = 'GET', body, headers = {}, dPoPOptions) {
195
+ return await this.toJSON(await this.request(url, method, body, headers, dPoPOptions));
196
+ }
197
+ async fetch(input, init, dPoPOptions) {
198
+ return await this.request(input, init?.method, await requestish.Body.from(init?.body), requestish.Headers.from(init?.headers), dPoPOptions);
199
+ }
200
+ async fetchJSON(input, init, dPoPOptions) {
201
+ return await this.toJSON(await this.fetch(input, init, dPoPOptions));
139
202
  }
140
203
  }
@@ -0,0 +1,30 @@
1
+ import * as requestish from 'requestish';
2
+ import * as Req from './Request/index.js';
3
+ export type OAuth2 = {
4
+ client_id: string;
5
+ client_secret: string;
6
+ redirect_uri: requestish.URL.ish;
7
+ authorization_endpoint: requestish.URL.ish;
8
+ token_endpoint: requestish.URL.ish;
9
+ scope?: Req.Scope.ish;
10
+ };
11
+ export type OpenID = {
12
+ issuer: requestish.URL.ish;
13
+ client_id: string;
14
+ client_secret: string;
15
+ redirect_uri: requestish.URL.ish;
16
+ };
17
+ export type Combined = {
18
+ client_id: string;
19
+ client_secret: string;
20
+ redirect_uri: requestish.URL.ish;
21
+ scope?: Req.Scope.ish;
22
+ } & ({
23
+ issuer?: requestish.URL.ish;
24
+ authorization_endpoint: requestish.URL.ish;
25
+ token_endpoint: requestish.URL.ish;
26
+ } | {
27
+ issuer: requestish.URL.ish;
28
+ authorization_endpoint?: requestish.URL.ish;
29
+ token_endpoint?: requestish.URL.ish;
30
+ });
@@ -0,0 +1,3 @@
1
+ export declare class BadResponse extends Error {
2
+ constructor(response: Response);
3
+ }
@@ -0,0 +1,7 @@
1
+ export class BadResponse extends Error {
2
+ constructor(response) {
3
+ super(`Response error ${response.status}: ${response.statusText}`, {
4
+ cause: response
5
+ });
6
+ }
7
+ }