@btravstack/oauth-client 0.0.0-stage โ 0.29.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.
- package/LICENSE +21 -0
- package/README.md +91 -2
- package/dist/index.cjs +207 -0
- package/dist/index.d.cts +118 -0
- package/dist/index.d.mts +118 -0
- package/dist/index.mjs +202 -0
- package/package.json +74 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Benoit TRAVERS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @btravstack/oauth-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> A `TokenSource` port for [`@btravstack/core`](../core): a bearer token so a
|
|
4
|
+
> service calls another as itself, over the OAuth 2.0 client credentials
|
|
5
|
+
> grant โ kept per audience until shortly before it expires, one fetch shared
|
|
6
|
+
> by concurrent callers, and a static adapter for tests.
|
|
7
|
+
|
|
8
|
+
๐ **[Documentation](https://btravstack.github.io/btravstack/reference/oauth-client)** ยท
|
|
9
|
+
[API Reference](https://btravstack.github.io/btravstack/api/oauth-client/)
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add @btravstack/oauth-client @btravstack/core @btravstack/config \
|
|
13
|
+
@btravstack/di openid-client unthrown
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Every dependency is a peer. Node `>=22.12`: `openid-client` is ESM-only, so
|
|
17
|
+
the CommonJS build relies on `require(esm)`.
|
|
18
|
+
|
|
19
|
+
## A worked example
|
|
20
|
+
|
|
21
|
+
<!-- doctest: prelude
|
|
22
|
+
import { Module, Port, Provider } from "@btravstack/di";
|
|
23
|
+
import type { AsyncResult } from "unthrown";
|
|
24
|
+
-->
|
|
25
|
+
|
|
26
|
+
A port that needs a token for the inventory service, and the composition that
|
|
27
|
+
provides one:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import {
|
|
31
|
+
TokenSource,
|
|
32
|
+
oauthClient,
|
|
33
|
+
type TokenIssuerUnavailable,
|
|
34
|
+
type TokenRefused,
|
|
35
|
+
} from "@btravstack/oauth-client";
|
|
36
|
+
|
|
37
|
+
class InventoryAuth extends Port("ReadmeInventoryAuth")<{
|
|
38
|
+
readonly header: () => AsyncResult<string, TokenRefused | TokenIssuerUnavailable>;
|
|
39
|
+
}> {}
|
|
40
|
+
|
|
41
|
+
export const inventoryAuth = Provider(InventoryAuth)({
|
|
42
|
+
inject: { tokens: TokenSource },
|
|
43
|
+
sync: ({ tokens }) => ({
|
|
44
|
+
header: () =>
|
|
45
|
+
tokens
|
|
46
|
+
.token({ audience: "https://inventory.internal", scope: "inventory:read" })
|
|
47
|
+
.map(({ bearer }) => `Bearer ${bearer}`),
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const Gateway = Module("ReadmeGateway")({
|
|
52
|
+
imports: [oauthClient()],
|
|
53
|
+
provides: [inventoryAuth],
|
|
54
|
+
exports: [InventoryAuth],
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Putting the token on an oRPC client's `Authorization` header, without a
|
|
59
|
+
`Promise` leaving the `Result`, is
|
|
60
|
+
[Call another service as itself](https://btravstack.github.io/btravstack/how-to/call-another-service-as-itself).
|
|
61
|
+
|
|
62
|
+
## Options
|
|
63
|
+
|
|
64
|
+
| Option | Where | What it is |
|
|
65
|
+
| --------------------- | ------------------------------------- | --------------------------------------------------------------- |
|
|
66
|
+
| `OAUTH_ISSUER` | environment, pinned by `issuer` | the authorization server โ required |
|
|
67
|
+
| `OAUTH_CLIENT_ID` | environment, pinned by `clientId` | this service's client โ required |
|
|
68
|
+
| `OAUTH_CLIENT_SECRET` | environment, pinned by `clientSecret` | its secret, from a Kubernetes Secret โ required |
|
|
69
|
+
| `allowInsecureIssuer` | `oauthClient({ โฆ })` | accept an `http:` issuer that is not loopback (default `false`) |
|
|
70
|
+
| `clock` | `oauthClient({ โฆ })` | what a token's lifetime is measured against |
|
|
71
|
+
|
|
72
|
+
The full table โ defaults, semantics and the reasoning โ lives on
|
|
73
|
+
[the reference page](https://btravstack.github.io/btravstack/reference/oauth-client),
|
|
74
|
+
which is this list's one detailed home.
|
|
75
|
+
|
|
76
|
+
## What it decides, and what it does not
|
|
77
|
+
|
|
78
|
+
**It decides** that discovery is lazy, so an issuer down at deploy fails the
|
|
79
|
+
calls that need a token rather than the boot; that a token is reused until a
|
|
80
|
+
minute before it expires; that a refusal (`TokenRefused`, with the issuer's
|
|
81
|
+
`error` code) and an outage (`TokenIssuerUnavailable`) are two typed errors;
|
|
82
|
+
that every fetch is reported to `Observers`; and that an `oauth-client` health
|
|
83
|
+
check fails while an audience's last fetch failed.
|
|
84
|
+
|
|
85
|
+
**It does not decide** what a failure means for your call โ retrying belongs
|
|
86
|
+
to your transport โ and it does not yet mint a token on behalf of a user
|
|
87
|
+
(RFC 8693 token exchange is deferred). The reasoning is in
|
|
88
|
+
[`AGENTS.md`](./AGENTS.md).
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _btravstack_config = require("@btravstack/config");
|
|
3
|
+
let _btravstack_core = require("@btravstack/core");
|
|
4
|
+
let _btravstack_di = require("@btravstack/di");
|
|
5
|
+
let openid_client = require("openid-client");
|
|
6
|
+
let unthrown = require("unthrown");
|
|
7
|
+
//#region src/token-source.ts
|
|
8
|
+
/**
|
|
9
|
+
* The authorization server answered, and the answer was no โ an OAuth error
|
|
10
|
+
* (`invalid_client`, `invalid_scope`, `invalid_request`, `unauthorized_client`โฆ).
|
|
11
|
+
* A configuration or a registration is wrong, so asking again will not help.
|
|
12
|
+
*
|
|
13
|
+
* It carries the server's `error` code and description, and never the client
|
|
14
|
+
* secret.
|
|
15
|
+
*/
|
|
16
|
+
var TokenRefused = class extends (0, unthrown.TaggedError)("TokenRefused") {};
|
|
17
|
+
/**
|
|
18
|
+
* The authorization server could not be asked: discovery failed, the network
|
|
19
|
+
* did, or it answered with a server error. Transient, so a later call asks
|
|
20
|
+
* again โ a failure is never cached.
|
|
21
|
+
*/
|
|
22
|
+
var TokenIssuerUnavailable = class extends (0, unthrown.TaggedError)("TokenIssuerUnavailable") {};
|
|
23
|
+
/**
|
|
24
|
+
* The port an application depends on to call another service as itself: a
|
|
25
|
+
* bearer token for an audience. `oauthClient()` provides it over the client
|
|
26
|
+
* credentials grant; `staticTokenSource` is the in-process stand-in.
|
|
27
|
+
*/
|
|
28
|
+
var TokenSource = class extends (0, _btravstack_di.Port)("TokenSource") {};
|
|
29
|
+
/**
|
|
30
|
+
* The in-process adapter: the same `bearer` for every request, and it cannot
|
|
31
|
+
* fail. The shape `@btravstack/testing`'s `overridden` takes, so a spec swaps
|
|
32
|
+
* the real source under the real root.
|
|
33
|
+
*/
|
|
34
|
+
const staticTokenSource = (bearer) => (0, _btravstack_di.Provider)(TokenSource)({
|
|
35
|
+
inject: {},
|
|
36
|
+
value: { token: () => (0, unthrown.OkAsync)({ bearer }) }
|
|
37
|
+
});
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/client.ts
|
|
40
|
+
/**
|
|
41
|
+
* How long before its stated expiry a token stops being handed out: it must
|
|
42
|
+
* outlive the request it rides, through the callee's queue and a clock that
|
|
43
|
+
* disagrees with this one. Capped at half a lifetime, so a short-lived token
|
|
44
|
+
* is still reused.
|
|
45
|
+
*/
|
|
46
|
+
const SKEW_MS = 6e4;
|
|
47
|
+
/** The source and its health, built once, so the check reads the same fetches the callers make. */
|
|
48
|
+
var TokenFetches = class extends (0, _btravstack_di.Port)("OAuthTokenFetches") {};
|
|
49
|
+
const fetching = (request) => ({
|
|
50
|
+
component: "oauth-client",
|
|
51
|
+
name: "token",
|
|
52
|
+
attributes: {
|
|
53
|
+
operation: "token",
|
|
54
|
+
"btravstack.oauth.audience": request.audience
|
|
55
|
+
},
|
|
56
|
+
details: { "btravstack.oauth.scope": request.scope ?? "" }
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* What the issuer said no with, when the answer was a refusal rather than an
|
|
60
|
+
* outage. A challenge is RFC 6749 ยง5.2's `401` for a rejected client, which
|
|
61
|
+
* is `invalid_client` by definition.
|
|
62
|
+
*/
|
|
63
|
+
const refusalOf = (cause) => cause instanceof openid_client.WWWAuthenticateChallengeError ? {
|
|
64
|
+
error: "invalid_client",
|
|
65
|
+
description: void 0
|
|
66
|
+
} : cause instanceof openid_client.ResponseBodyError && cause.status < 500 ? {
|
|
67
|
+
error: cause.error,
|
|
68
|
+
description: cause.error_description
|
|
69
|
+
} : void 0;
|
|
70
|
+
const reasonOf = (audience, failure) => failure.tag === "Err" && failure.error._tag === "TokenRefused" ? `${audience}: refused (${failure.error.error})` : `${audience}: the issuer could not be asked`;
|
|
71
|
+
const tokenFetches = (settings, observers, clock) => {
|
|
72
|
+
let configuration;
|
|
73
|
+
const cached = /* @__PURE__ */ new Map();
|
|
74
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
75
|
+
const failing = /* @__PURE__ */ new Map();
|
|
76
|
+
const discover = () => {
|
|
77
|
+
configuration ??= (0, unthrown.fromPromise)(Promise.resolve().then(() => (0, openid_client.discovery)(new URL(settings.issuer), settings.clientId, void 0, (0, openid_client.ClientSecretBasic)(settings.clientSecret), { execute: settings.insecure ? [openid_client.allowInsecureRequests] : [] })), (cause) => new TokenIssuerUnavailable({
|
|
78
|
+
issuer: settings.issuer,
|
|
79
|
+
cause
|
|
80
|
+
})).map((config) => {
|
|
81
|
+
if (settings.insecure) (0, openid_client.allowInsecureRequests)(config);
|
|
82
|
+
return config;
|
|
83
|
+
}).tapFailure(() => {
|
|
84
|
+
configuration = void 0;
|
|
85
|
+
});
|
|
86
|
+
return configuration;
|
|
87
|
+
};
|
|
88
|
+
const grant = (request, config) => (0, unthrown.fromPromise)((0, openid_client.clientCredentialsGrant)(config, {
|
|
89
|
+
audience: request.audience,
|
|
90
|
+
...request.scope === void 0 ? {} : { scope: request.scope }
|
|
91
|
+
}), (cause) => {
|
|
92
|
+
const refusal = refusalOf(cause);
|
|
93
|
+
return refusal === void 0 ? new TokenIssuerUnavailable({
|
|
94
|
+
issuer: settings.issuer,
|
|
95
|
+
cause
|
|
96
|
+
}) : new TokenRefused({
|
|
97
|
+
audience: request.audience,
|
|
98
|
+
...refusal
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
const fetchToken = (key, request) => {
|
|
102
|
+
const asked = clock.now();
|
|
103
|
+
const fetched = (0, _btravstack_core.observed)(observers, fetching(request), () => discover().flatMap((config) => grant(request, config))).map((response) => {
|
|
104
|
+
const lifetimeMs = (response.expires_in ?? 0) * 1e3;
|
|
105
|
+
return {
|
|
106
|
+
token: { bearer: response.access_token },
|
|
107
|
+
refreshAt: asked + lifetimeMs - Math.min(SKEW_MS, lifetimeMs / 2)
|
|
108
|
+
};
|
|
109
|
+
}).tap((entry) => {
|
|
110
|
+
inFlight.delete(key);
|
|
111
|
+
failing.delete(key);
|
|
112
|
+
cached.set(key, entry);
|
|
113
|
+
}).tapFailure((failure) => {
|
|
114
|
+
inFlight.delete(key);
|
|
115
|
+
failing.set(key, reasonOf(request.audience, failure));
|
|
116
|
+
}).map((entry) => entry.token);
|
|
117
|
+
inFlight.set(key, fetched);
|
|
118
|
+
return fetched;
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
source: { token: (request) => {
|
|
122
|
+
const key = JSON.stringify([request.audience, request.scope ?? null]);
|
|
123
|
+
const hit = cached.get(key);
|
|
124
|
+
if (hit !== void 0 && clock.now() < hit.refreshAt) return (0, unthrown.OkAsync)(hit.token);
|
|
125
|
+
return inFlight.get(key) ?? fetchToken(key, request);
|
|
126
|
+
} },
|
|
127
|
+
health: () => failing.size === 0 ? (0, unthrown.OkAsync)() : (0, unthrown.ErrAsync)(new _btravstack_core.HealthCheckFailed({ reason: [...failing.values()].join("; ") }))
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* The client credentials starter: `TokenSource` over an OAuth 2.0
|
|
132
|
+
* authorization server, so a service calls another as itself.
|
|
133
|
+
*
|
|
134
|
+
* ```ts
|
|
135
|
+
* Module("Gateway")({ imports: [oauthClient()], provides: [ordersClient] });
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* - **Configured from `OAUTH_ISSUER`, `OAUTH_CLIENT_ID` and
|
|
139
|
+
* `OAUTH_CLIENT_SECRET`**, each pinned by its option, validated as the graph
|
|
140
|
+
* is built. A cleartext issuer off loopback is a `ConfigInvalid` unless
|
|
141
|
+
* `allowInsecureIssuer` is pinned.
|
|
142
|
+
* - **Discovery is lazy**: the first token asks for the issuer's metadata, and
|
|
143
|
+
* a failed discovery is asked again by the next. An authorization server
|
|
144
|
+
* down at deploy fails the calls that need a token, not the boot.
|
|
145
|
+
* - **A token is kept per audience and scope** until a minute before it
|
|
146
|
+
* expires (half its lifetime, if that is shorter), and callers asking for
|
|
147
|
+
* the same one while it is being fetched share that one request. A failure
|
|
148
|
+
* is never kept.
|
|
149
|
+
* - **Every fetch is reported to `Observers`** (`component: "oauth-client"`);
|
|
150
|
+
* a token served from memory reports nothing.
|
|
151
|
+
* - **It contributes the `oauth-client` health check**, failing while the
|
|
152
|
+
* last fetch of any audience failed โ a rotated secret and an unreachable
|
|
153
|
+
* issuer both show there, where a reachability probe would see only the
|
|
154
|
+
* second.
|
|
155
|
+
*/
|
|
156
|
+
const oauthClient = (options = {}) => {
|
|
157
|
+
const clock = options.clock ?? _btravstack_core.systemClock;
|
|
158
|
+
const schema = _btravstack_config.Config.object({
|
|
159
|
+
issuer: _btravstack_config.Config.pinned(options.issuer, _btravstack_config.Config.url("OAUTH_ISSUER")),
|
|
160
|
+
clientId: _btravstack_config.Config.pinned(options.clientId, _btravstack_config.Config.string("OAUTH_CLIENT_ID")),
|
|
161
|
+
clientSecret: _btravstack_config.Config.pinned(options.clientSecret, _btravstack_config.Config.string("OAUTH_CLIENT_SECRET"))
|
|
162
|
+
});
|
|
163
|
+
const fetches = (0, _btravstack_di.Provider)(TokenFetches)({
|
|
164
|
+
inject: {
|
|
165
|
+
env: _btravstack_config.Env,
|
|
166
|
+
observers: _btravstack_core.Observers
|
|
167
|
+
},
|
|
168
|
+
make: ({ env, observers }) => _btravstack_config.Config.parse("OAuthClient", schema)(env).flatMap((bound) => {
|
|
169
|
+
const insecure = (0, _btravstack_config.cleartext)(bound.issuer, options.allowInsecureIssuer ?? false);
|
|
170
|
+
return insecure === "refused" ? (0, unthrown.ErrAsync)((0, _btravstack_config.cleartextRefused)({
|
|
171
|
+
port: "OAuthClient",
|
|
172
|
+
variable: "OAUTH_ISSUER",
|
|
173
|
+
option: "allowInsecureIssuer",
|
|
174
|
+
on: "oauthClient()",
|
|
175
|
+
what: "it sends the client secret and every token in the open"
|
|
176
|
+
})) : (0, unthrown.OkAsync)(tokenFetches({
|
|
177
|
+
...bound,
|
|
178
|
+
insecure
|
|
179
|
+
}, observers, clock));
|
|
180
|
+
})
|
|
181
|
+
});
|
|
182
|
+
return (0, _btravstack_di.Module)("OAuthClient")({
|
|
183
|
+
needs: [_btravstack_config.Env],
|
|
184
|
+
provides: [
|
|
185
|
+
_btravstack_core.noObserverMember,
|
|
186
|
+
fetches,
|
|
187
|
+
(0, _btravstack_di.Provider)(TokenSource)({
|
|
188
|
+
inject: { fetches: TokenFetches },
|
|
189
|
+
sync: ({ fetches: built }) => built.source
|
|
190
|
+
}),
|
|
191
|
+
_btravstack_di.Provider.member(_btravstack_core.HealthChecks)({
|
|
192
|
+
inject: { fetches: TokenFetches },
|
|
193
|
+
sync: ({ fetches: built }) => ({
|
|
194
|
+
name: "oauth-client",
|
|
195
|
+
check: built.health
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
],
|
|
199
|
+
exports: [TokenSource, _btravstack_core.HealthChecks]
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
//#endregion
|
|
203
|
+
exports.TokenIssuerUnavailable = TokenIssuerUnavailable;
|
|
204
|
+
exports.TokenRefused = TokenRefused;
|
|
205
|
+
exports.TokenSource = TokenSource;
|
|
206
|
+
exports.oauthClient = oauthClient;
|
|
207
|
+
exports.staticTokenSource = staticTokenSource;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ConfigInvalid, EnvReading, MaybePinned, OptionsAs, Unpinned } from "@btravstack/config";
|
|
2
|
+
import { Clock, HealthChecks } from "@btravstack/core";
|
|
3
|
+
import { Module, Provider } from "@btravstack/di";
|
|
4
|
+
import { AsyncResult } from "unthrown";
|
|
5
|
+
//#region src/token-source.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* What a token is asked for: the service it will be presented to, and
|
|
8
|
+
* optionally the scopes it must carry (space-delimited, as OAuth spells them).
|
|
9
|
+
*
|
|
10
|
+
* A record rather than positional arguments, so a later field โ the actor a
|
|
11
|
+
* call is made on behalf of โ joins it without moving anything already here.
|
|
12
|
+
*/
|
|
13
|
+
type TokenRequest = {
|
|
14
|
+
/** The callee, as its authorization server names it: the `aud` the token is minted for. */
|
|
15
|
+
readonly audience: string;
|
|
16
|
+
readonly scope?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A token to present, as `Authorization: Bearer <bearer>`.
|
|
20
|
+
*
|
|
21
|
+
* No expiry rides it: keeping a token until shortly before it expires is the
|
|
22
|
+
* source's job, and a caller that held the expiry would be a caller about to
|
|
23
|
+
* cache it a second time.
|
|
24
|
+
*/
|
|
25
|
+
type AccessToken = {
|
|
26
|
+
readonly bearer: string;
|
|
27
|
+
};
|
|
28
|
+
declare const TokenRefused_base: import("unthrown").TaggedErrorConstructor<"TokenRefused">;
|
|
29
|
+
/**
|
|
30
|
+
* The authorization server answered, and the answer was no โ an OAuth error
|
|
31
|
+
* (`invalid_client`, `invalid_scope`, `invalid_request`, `unauthorized_client`โฆ).
|
|
32
|
+
* A configuration or a registration is wrong, so asking again will not help.
|
|
33
|
+
*
|
|
34
|
+
* It carries the server's `error` code and description, and never the client
|
|
35
|
+
* secret.
|
|
36
|
+
*/
|
|
37
|
+
export declare class TokenRefused extends TokenRefused_base<{
|
|
38
|
+
readonly audience: string;
|
|
39
|
+
readonly error: string;
|
|
40
|
+
readonly description: string | undefined;
|
|
41
|
+
}> {}
|
|
42
|
+
declare const TokenIssuerUnavailable_base: import("unthrown").TaggedErrorConstructor<"TokenIssuerUnavailable">;
|
|
43
|
+
/**
|
|
44
|
+
* The authorization server could not be asked: discovery failed, the network
|
|
45
|
+
* did, or it answered with a server error. Transient, so a later call asks
|
|
46
|
+
* again โ a failure is never cached.
|
|
47
|
+
*/
|
|
48
|
+
export declare class TokenIssuerUnavailable extends TokenIssuerUnavailable_base<{
|
|
49
|
+
readonly issuer: string;
|
|
50
|
+
readonly cause: unknown;
|
|
51
|
+
}> {}
|
|
52
|
+
type TokenSourceService = {
|
|
53
|
+
readonly token: (request: TokenRequest) => AsyncResult<AccessToken, TokenRefused | TokenIssuerUnavailable>;
|
|
54
|
+
};
|
|
55
|
+
declare const TokenSource_base: import("@btravstack/di").PortClass<"TokenSource">;
|
|
56
|
+
/**
|
|
57
|
+
* The port an application depends on to call another service as itself: a
|
|
58
|
+
* bearer token for an audience. `oauthClient()` provides it over the client
|
|
59
|
+
* credentials grant; `staticTokenSource` is the in-process stand-in.
|
|
60
|
+
*/
|
|
61
|
+
export declare class TokenSource extends TokenSource_base<TokenSourceService> {}
|
|
62
|
+
/**
|
|
63
|
+
* The in-process adapter: the same `bearer` for every request, and it cannot
|
|
64
|
+
* fail. The shape `@btravstack/testing`'s `overridden` takes, so a spec swaps
|
|
65
|
+
* the real source under the real root.
|
|
66
|
+
*/
|
|
67
|
+
export declare const staticTokenSource: (bearer: string) => Provider<TokenSource, never, never> & {
|
|
68
|
+
readonly port: typeof TokenSource;
|
|
69
|
+
};
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/client.d.ts
|
|
72
|
+
/** What {@link oauthClient} is handed. Each of the first three pins the variable named beside it. */
|
|
73
|
+
type OAuthClientOptions = {
|
|
74
|
+
/** Pins `OAUTH_ISSUER` โ the authorization server, as its discovery document names itself. */
|
|
75
|
+
readonly issuer?: string | undefined;
|
|
76
|
+
/** Pins `OAUTH_CLIENT_ID`. */
|
|
77
|
+
readonly clientId?: string | undefined;
|
|
78
|
+
/** Pins `OAUTH_CLIENT_SECRET`. A deployment sets the variable, from a Secret. */
|
|
79
|
+
readonly clientSecret?: string | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Talk to an `http:` issuer that is NOT on a loopback host. Default `false`,
|
|
82
|
+
* and a `ConfigInvalid` at boot without it: the client secret and every
|
|
83
|
+
* token cross the wire in the open. An option rather than a variable,
|
|
84
|
+
* because its silent change is a security regression.
|
|
85
|
+
*/
|
|
86
|
+
readonly allowInsecureIssuer?: boolean;
|
|
87
|
+
/** What a token's lifetime is measured against (default: the kernel's `systemClock`). */
|
|
88
|
+
readonly clock?: Clock | undefined;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The client credentials starter: `TokenSource` over an OAuth 2.0
|
|
92
|
+
* authorization server, so a service calls another as itself.
|
|
93
|
+
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* Module("Gateway")({ imports: [oauthClient()], provides: [ordersClient] });
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* - **Configured from `OAUTH_ISSUER`, `OAUTH_CLIENT_ID` and
|
|
99
|
+
* `OAUTH_CLIENT_SECRET`**, each pinned by its option, validated as the graph
|
|
100
|
+
* is built. A cleartext issuer off loopback is a `ConfigInvalid` unless
|
|
101
|
+
* `allowInsecureIssuer` is pinned.
|
|
102
|
+
* - **Discovery is lazy**: the first token asks for the issuer's metadata, and
|
|
103
|
+
* a failed discovery is asked again by the next. An authorization server
|
|
104
|
+
* down at deploy fails the calls that need a token, not the boot.
|
|
105
|
+
* - **A token is kept per audience and scope** until a minute before it
|
|
106
|
+
* expires (half its lifetime, if that is shorter), and callers asking for
|
|
107
|
+
* the same one while it is being fetched share that one request. A failure
|
|
108
|
+
* is never kept.
|
|
109
|
+
* - **Every fetch is reported to `Observers`** (`component: "oauth-client"`);
|
|
110
|
+
* a token served from memory reports nothing.
|
|
111
|
+
* - **It contributes the `oauth-client` health check**, failing while the
|
|
112
|
+
* last fetch of any audience failed โ a rotated secret and an unreachable
|
|
113
|
+
* issuer both show there, where a reachability probe would see only the
|
|
114
|
+
* second.
|
|
115
|
+
*/
|
|
116
|
+
export declare const oauthClient: <const O extends OAuthClientOptions = Record<never, never>>(options?: O & OptionsAs<O, OAuthClientOptions>) => Module<TokenSource | HealthChecks, ConfigInvalid, EnvReading<Unpinned<O, "issuer", "OAUTH_ISSUER"> | Unpinned<O, "clientId", "OAUTH_CLIENT_ID"> | Unpinned<O, "clientSecret", "OAUTH_CLIENT_SECRET">, MaybePinned<O, "issuer", "OAUTH_ISSUER"> | MaybePinned<O, "clientId", "OAUTH_CLIENT_ID"> | MaybePinned<O, "clientSecret", "OAUTH_CLIENT_SECRET">>>;
|
|
117
|
+
//#endregion
|
|
118
|
+
export type { AccessToken, OAuthClientOptions, TokenRequest, TokenSourceService };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ConfigInvalid, EnvReading, MaybePinned, OptionsAs, Unpinned } from "@btravstack/config";
|
|
2
|
+
import { Clock, HealthChecks } from "@btravstack/core";
|
|
3
|
+
import { Module, Provider } from "@btravstack/di";
|
|
4
|
+
import { AsyncResult } from "unthrown";
|
|
5
|
+
//#region src/token-source.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* What a token is asked for: the service it will be presented to, and
|
|
8
|
+
* optionally the scopes it must carry (space-delimited, as OAuth spells them).
|
|
9
|
+
*
|
|
10
|
+
* A record rather than positional arguments, so a later field โ the actor a
|
|
11
|
+
* call is made on behalf of โ joins it without moving anything already here.
|
|
12
|
+
*/
|
|
13
|
+
type TokenRequest = {
|
|
14
|
+
/** The callee, as its authorization server names it: the `aud` the token is minted for. */
|
|
15
|
+
readonly audience: string;
|
|
16
|
+
readonly scope?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A token to present, as `Authorization: Bearer <bearer>`.
|
|
20
|
+
*
|
|
21
|
+
* No expiry rides it: keeping a token until shortly before it expires is the
|
|
22
|
+
* source's job, and a caller that held the expiry would be a caller about to
|
|
23
|
+
* cache it a second time.
|
|
24
|
+
*/
|
|
25
|
+
type AccessToken = {
|
|
26
|
+
readonly bearer: string;
|
|
27
|
+
};
|
|
28
|
+
declare const TokenRefused_base: import("unthrown").TaggedErrorConstructor<"TokenRefused">;
|
|
29
|
+
/**
|
|
30
|
+
* The authorization server answered, and the answer was no โ an OAuth error
|
|
31
|
+
* (`invalid_client`, `invalid_scope`, `invalid_request`, `unauthorized_client`โฆ).
|
|
32
|
+
* A configuration or a registration is wrong, so asking again will not help.
|
|
33
|
+
*
|
|
34
|
+
* It carries the server's `error` code and description, and never the client
|
|
35
|
+
* secret.
|
|
36
|
+
*/
|
|
37
|
+
export declare class TokenRefused extends TokenRefused_base<{
|
|
38
|
+
readonly audience: string;
|
|
39
|
+
readonly error: string;
|
|
40
|
+
readonly description: string | undefined;
|
|
41
|
+
}> {}
|
|
42
|
+
declare const TokenIssuerUnavailable_base: import("unthrown").TaggedErrorConstructor<"TokenIssuerUnavailable">;
|
|
43
|
+
/**
|
|
44
|
+
* The authorization server could not be asked: discovery failed, the network
|
|
45
|
+
* did, or it answered with a server error. Transient, so a later call asks
|
|
46
|
+
* again โ a failure is never cached.
|
|
47
|
+
*/
|
|
48
|
+
export declare class TokenIssuerUnavailable extends TokenIssuerUnavailable_base<{
|
|
49
|
+
readonly issuer: string;
|
|
50
|
+
readonly cause: unknown;
|
|
51
|
+
}> {}
|
|
52
|
+
type TokenSourceService = {
|
|
53
|
+
readonly token: (request: TokenRequest) => AsyncResult<AccessToken, TokenRefused | TokenIssuerUnavailable>;
|
|
54
|
+
};
|
|
55
|
+
declare const TokenSource_base: import("@btravstack/di").PortClass<"TokenSource">;
|
|
56
|
+
/**
|
|
57
|
+
* The port an application depends on to call another service as itself: a
|
|
58
|
+
* bearer token for an audience. `oauthClient()` provides it over the client
|
|
59
|
+
* credentials grant; `staticTokenSource` is the in-process stand-in.
|
|
60
|
+
*/
|
|
61
|
+
export declare class TokenSource extends TokenSource_base<TokenSourceService> {}
|
|
62
|
+
/**
|
|
63
|
+
* The in-process adapter: the same `bearer` for every request, and it cannot
|
|
64
|
+
* fail. The shape `@btravstack/testing`'s `overridden` takes, so a spec swaps
|
|
65
|
+
* the real source under the real root.
|
|
66
|
+
*/
|
|
67
|
+
export declare const staticTokenSource: (bearer: string) => Provider<TokenSource, never, never> & {
|
|
68
|
+
readonly port: typeof TokenSource;
|
|
69
|
+
};
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/client.d.ts
|
|
72
|
+
/** What {@link oauthClient} is handed. Each of the first three pins the variable named beside it. */
|
|
73
|
+
type OAuthClientOptions = {
|
|
74
|
+
/** Pins `OAUTH_ISSUER` โ the authorization server, as its discovery document names itself. */
|
|
75
|
+
readonly issuer?: string | undefined;
|
|
76
|
+
/** Pins `OAUTH_CLIENT_ID`. */
|
|
77
|
+
readonly clientId?: string | undefined;
|
|
78
|
+
/** Pins `OAUTH_CLIENT_SECRET`. A deployment sets the variable, from a Secret. */
|
|
79
|
+
readonly clientSecret?: string | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Talk to an `http:` issuer that is NOT on a loopback host. Default `false`,
|
|
82
|
+
* and a `ConfigInvalid` at boot without it: the client secret and every
|
|
83
|
+
* token cross the wire in the open. An option rather than a variable,
|
|
84
|
+
* because its silent change is a security regression.
|
|
85
|
+
*/
|
|
86
|
+
readonly allowInsecureIssuer?: boolean;
|
|
87
|
+
/** What a token's lifetime is measured against (default: the kernel's `systemClock`). */
|
|
88
|
+
readonly clock?: Clock | undefined;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The client credentials starter: `TokenSource` over an OAuth 2.0
|
|
92
|
+
* authorization server, so a service calls another as itself.
|
|
93
|
+
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* Module("Gateway")({ imports: [oauthClient()], provides: [ordersClient] });
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* - **Configured from `OAUTH_ISSUER`, `OAUTH_CLIENT_ID` and
|
|
99
|
+
* `OAUTH_CLIENT_SECRET`**, each pinned by its option, validated as the graph
|
|
100
|
+
* is built. A cleartext issuer off loopback is a `ConfigInvalid` unless
|
|
101
|
+
* `allowInsecureIssuer` is pinned.
|
|
102
|
+
* - **Discovery is lazy**: the first token asks for the issuer's metadata, and
|
|
103
|
+
* a failed discovery is asked again by the next. An authorization server
|
|
104
|
+
* down at deploy fails the calls that need a token, not the boot.
|
|
105
|
+
* - **A token is kept per audience and scope** until a minute before it
|
|
106
|
+
* expires (half its lifetime, if that is shorter), and callers asking for
|
|
107
|
+
* the same one while it is being fetched share that one request. A failure
|
|
108
|
+
* is never kept.
|
|
109
|
+
* - **Every fetch is reported to `Observers`** (`component: "oauth-client"`);
|
|
110
|
+
* a token served from memory reports nothing.
|
|
111
|
+
* - **It contributes the `oauth-client` health check**, failing while the
|
|
112
|
+
* last fetch of any audience failed โ a rotated secret and an unreachable
|
|
113
|
+
* issuer both show there, where a reachability probe would see only the
|
|
114
|
+
* second.
|
|
115
|
+
*/
|
|
116
|
+
export declare const oauthClient: <const O extends OAuthClientOptions = Record<never, never>>(options?: O & OptionsAs<O, OAuthClientOptions>) => Module<TokenSource | HealthChecks, ConfigInvalid, EnvReading<Unpinned<O, "issuer", "OAUTH_ISSUER"> | Unpinned<O, "clientId", "OAUTH_CLIENT_ID"> | Unpinned<O, "clientSecret", "OAUTH_CLIENT_SECRET">, MaybePinned<O, "issuer", "OAUTH_ISSUER"> | MaybePinned<O, "clientId", "OAUTH_CLIENT_ID"> | MaybePinned<O, "clientSecret", "OAUTH_CLIENT_SECRET">>>;
|
|
117
|
+
//#endregion
|
|
118
|
+
export type { AccessToken, OAuthClientOptions, TokenRequest, TokenSourceService };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Config, Env, cleartext, cleartextRefused } from "@btravstack/config";
|
|
2
|
+
import { HealthCheckFailed, HealthChecks, Observers, noObserverMember, observed, systemClock } from "@btravstack/core";
|
|
3
|
+
import { Module, Port, Provider } from "@btravstack/di";
|
|
4
|
+
import { ClientSecretBasic, ResponseBodyError, WWWAuthenticateChallengeError, allowInsecureRequests, clientCredentialsGrant, discovery } from "openid-client";
|
|
5
|
+
import { ErrAsync, OkAsync, TaggedError, fromPromise } from "unthrown";
|
|
6
|
+
//#region src/token-source.ts
|
|
7
|
+
/**
|
|
8
|
+
* The authorization server answered, and the answer was no โ an OAuth error
|
|
9
|
+
* (`invalid_client`, `invalid_scope`, `invalid_request`, `unauthorized_client`โฆ).
|
|
10
|
+
* A configuration or a registration is wrong, so asking again will not help.
|
|
11
|
+
*
|
|
12
|
+
* It carries the server's `error` code and description, and never the client
|
|
13
|
+
* secret.
|
|
14
|
+
*/
|
|
15
|
+
var TokenRefused = class extends TaggedError("TokenRefused") {};
|
|
16
|
+
/**
|
|
17
|
+
* The authorization server could not be asked: discovery failed, the network
|
|
18
|
+
* did, or it answered with a server error. Transient, so a later call asks
|
|
19
|
+
* again โ a failure is never cached.
|
|
20
|
+
*/
|
|
21
|
+
var TokenIssuerUnavailable = class extends TaggedError("TokenIssuerUnavailable") {};
|
|
22
|
+
/**
|
|
23
|
+
* The port an application depends on to call another service as itself: a
|
|
24
|
+
* bearer token for an audience. `oauthClient()` provides it over the client
|
|
25
|
+
* credentials grant; `staticTokenSource` is the in-process stand-in.
|
|
26
|
+
*/
|
|
27
|
+
var TokenSource = class extends Port("TokenSource") {};
|
|
28
|
+
/**
|
|
29
|
+
* The in-process adapter: the same `bearer` for every request, and it cannot
|
|
30
|
+
* fail. The shape `@btravstack/testing`'s `overridden` takes, so a spec swaps
|
|
31
|
+
* the real source under the real root.
|
|
32
|
+
*/
|
|
33
|
+
const staticTokenSource = (bearer) => Provider(TokenSource)({
|
|
34
|
+
inject: {},
|
|
35
|
+
value: { token: () => OkAsync({ bearer }) }
|
|
36
|
+
});
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/client.ts
|
|
39
|
+
/**
|
|
40
|
+
* How long before its stated expiry a token stops being handed out: it must
|
|
41
|
+
* outlive the request it rides, through the callee's queue and a clock that
|
|
42
|
+
* disagrees with this one. Capped at half a lifetime, so a short-lived token
|
|
43
|
+
* is still reused.
|
|
44
|
+
*/
|
|
45
|
+
const SKEW_MS = 6e4;
|
|
46
|
+
/** The source and its health, built once, so the check reads the same fetches the callers make. */
|
|
47
|
+
var TokenFetches = class extends Port("OAuthTokenFetches") {};
|
|
48
|
+
const fetching = (request) => ({
|
|
49
|
+
component: "oauth-client",
|
|
50
|
+
name: "token",
|
|
51
|
+
attributes: {
|
|
52
|
+
operation: "token",
|
|
53
|
+
"btravstack.oauth.audience": request.audience
|
|
54
|
+
},
|
|
55
|
+
details: { "btravstack.oauth.scope": request.scope ?? "" }
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* What the issuer said no with, when the answer was a refusal rather than an
|
|
59
|
+
* outage. A challenge is RFC 6749 ยง5.2's `401` for a rejected client, which
|
|
60
|
+
* is `invalid_client` by definition.
|
|
61
|
+
*/
|
|
62
|
+
const refusalOf = (cause) => cause instanceof WWWAuthenticateChallengeError ? {
|
|
63
|
+
error: "invalid_client",
|
|
64
|
+
description: void 0
|
|
65
|
+
} : cause instanceof ResponseBodyError && cause.status < 500 ? {
|
|
66
|
+
error: cause.error,
|
|
67
|
+
description: cause.error_description
|
|
68
|
+
} : void 0;
|
|
69
|
+
const reasonOf = (audience, failure) => failure.tag === "Err" && failure.error._tag === "TokenRefused" ? `${audience}: refused (${failure.error.error})` : `${audience}: the issuer could not be asked`;
|
|
70
|
+
const tokenFetches = (settings, observers, clock) => {
|
|
71
|
+
let configuration;
|
|
72
|
+
const cached = /* @__PURE__ */ new Map();
|
|
73
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
74
|
+
const failing = /* @__PURE__ */ new Map();
|
|
75
|
+
const discover = () => {
|
|
76
|
+
configuration ??= fromPromise(Promise.resolve().then(() => discovery(new URL(settings.issuer), settings.clientId, void 0, ClientSecretBasic(settings.clientSecret), { execute: settings.insecure ? [allowInsecureRequests] : [] })), (cause) => new TokenIssuerUnavailable({
|
|
77
|
+
issuer: settings.issuer,
|
|
78
|
+
cause
|
|
79
|
+
})).map((config) => {
|
|
80
|
+
if (settings.insecure) allowInsecureRequests(config);
|
|
81
|
+
return config;
|
|
82
|
+
}).tapFailure(() => {
|
|
83
|
+
configuration = void 0;
|
|
84
|
+
});
|
|
85
|
+
return configuration;
|
|
86
|
+
};
|
|
87
|
+
const grant = (request, config) => fromPromise(clientCredentialsGrant(config, {
|
|
88
|
+
audience: request.audience,
|
|
89
|
+
...request.scope === void 0 ? {} : { scope: request.scope }
|
|
90
|
+
}), (cause) => {
|
|
91
|
+
const refusal = refusalOf(cause);
|
|
92
|
+
return refusal === void 0 ? new TokenIssuerUnavailable({
|
|
93
|
+
issuer: settings.issuer,
|
|
94
|
+
cause
|
|
95
|
+
}) : new TokenRefused({
|
|
96
|
+
audience: request.audience,
|
|
97
|
+
...refusal
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
const fetchToken = (key, request) => {
|
|
101
|
+
const asked = clock.now();
|
|
102
|
+
const fetched = observed(observers, fetching(request), () => discover().flatMap((config) => grant(request, config))).map((response) => {
|
|
103
|
+
const lifetimeMs = (response.expires_in ?? 0) * 1e3;
|
|
104
|
+
return {
|
|
105
|
+
token: { bearer: response.access_token },
|
|
106
|
+
refreshAt: asked + lifetimeMs - Math.min(SKEW_MS, lifetimeMs / 2)
|
|
107
|
+
};
|
|
108
|
+
}).tap((entry) => {
|
|
109
|
+
inFlight.delete(key);
|
|
110
|
+
failing.delete(key);
|
|
111
|
+
cached.set(key, entry);
|
|
112
|
+
}).tapFailure((failure) => {
|
|
113
|
+
inFlight.delete(key);
|
|
114
|
+
failing.set(key, reasonOf(request.audience, failure));
|
|
115
|
+
}).map((entry) => entry.token);
|
|
116
|
+
inFlight.set(key, fetched);
|
|
117
|
+
return fetched;
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
source: { token: (request) => {
|
|
121
|
+
const key = JSON.stringify([request.audience, request.scope ?? null]);
|
|
122
|
+
const hit = cached.get(key);
|
|
123
|
+
if (hit !== void 0 && clock.now() < hit.refreshAt) return OkAsync(hit.token);
|
|
124
|
+
return inFlight.get(key) ?? fetchToken(key, request);
|
|
125
|
+
} },
|
|
126
|
+
health: () => failing.size === 0 ? OkAsync() : ErrAsync(new HealthCheckFailed({ reason: [...failing.values()].join("; ") }))
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* The client credentials starter: `TokenSource` over an OAuth 2.0
|
|
131
|
+
* authorization server, so a service calls another as itself.
|
|
132
|
+
*
|
|
133
|
+
* ```ts
|
|
134
|
+
* Module("Gateway")({ imports: [oauthClient()], provides: [ordersClient] });
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* - **Configured from `OAUTH_ISSUER`, `OAUTH_CLIENT_ID` and
|
|
138
|
+
* `OAUTH_CLIENT_SECRET`**, each pinned by its option, validated as the graph
|
|
139
|
+
* is built. A cleartext issuer off loopback is a `ConfigInvalid` unless
|
|
140
|
+
* `allowInsecureIssuer` is pinned.
|
|
141
|
+
* - **Discovery is lazy**: the first token asks for the issuer's metadata, and
|
|
142
|
+
* a failed discovery is asked again by the next. An authorization server
|
|
143
|
+
* down at deploy fails the calls that need a token, not the boot.
|
|
144
|
+
* - **A token is kept per audience and scope** until a minute before it
|
|
145
|
+
* expires (half its lifetime, if that is shorter), and callers asking for
|
|
146
|
+
* the same one while it is being fetched share that one request. A failure
|
|
147
|
+
* is never kept.
|
|
148
|
+
* - **Every fetch is reported to `Observers`** (`component: "oauth-client"`);
|
|
149
|
+
* a token served from memory reports nothing.
|
|
150
|
+
* - **It contributes the `oauth-client` health check**, failing while the
|
|
151
|
+
* last fetch of any audience failed โ a rotated secret and an unreachable
|
|
152
|
+
* issuer both show there, where a reachability probe would see only the
|
|
153
|
+
* second.
|
|
154
|
+
*/
|
|
155
|
+
const oauthClient = (options = {}) => {
|
|
156
|
+
const clock = options.clock ?? systemClock;
|
|
157
|
+
const schema = Config.object({
|
|
158
|
+
issuer: Config.pinned(options.issuer, Config.url("OAUTH_ISSUER")),
|
|
159
|
+
clientId: Config.pinned(options.clientId, Config.string("OAUTH_CLIENT_ID")),
|
|
160
|
+
clientSecret: Config.pinned(options.clientSecret, Config.string("OAUTH_CLIENT_SECRET"))
|
|
161
|
+
});
|
|
162
|
+
const fetches = Provider(TokenFetches)({
|
|
163
|
+
inject: {
|
|
164
|
+
env: Env,
|
|
165
|
+
observers: Observers
|
|
166
|
+
},
|
|
167
|
+
make: ({ env, observers }) => Config.parse("OAuthClient", schema)(env).flatMap((bound) => {
|
|
168
|
+
const insecure = cleartext(bound.issuer, options.allowInsecureIssuer ?? false);
|
|
169
|
+
return insecure === "refused" ? ErrAsync(cleartextRefused({
|
|
170
|
+
port: "OAuthClient",
|
|
171
|
+
variable: "OAUTH_ISSUER",
|
|
172
|
+
option: "allowInsecureIssuer",
|
|
173
|
+
on: "oauthClient()",
|
|
174
|
+
what: "it sends the client secret and every token in the open"
|
|
175
|
+
})) : OkAsync(tokenFetches({
|
|
176
|
+
...bound,
|
|
177
|
+
insecure
|
|
178
|
+
}, observers, clock));
|
|
179
|
+
})
|
|
180
|
+
});
|
|
181
|
+
return Module("OAuthClient")({
|
|
182
|
+
needs: [Env],
|
|
183
|
+
provides: [
|
|
184
|
+
noObserverMember,
|
|
185
|
+
fetches,
|
|
186
|
+
Provider(TokenSource)({
|
|
187
|
+
inject: { fetches: TokenFetches },
|
|
188
|
+
sync: ({ fetches: built }) => built.source
|
|
189
|
+
}),
|
|
190
|
+
Provider.member(HealthChecks)({
|
|
191
|
+
inject: { fetches: TokenFetches },
|
|
192
|
+
sync: ({ fetches: built }) => ({
|
|
193
|
+
name: "oauth-client",
|
|
194
|
+
check: built.health
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
],
|
|
198
|
+
exports: [TokenSource, HealthChecks]
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
//#endregion
|
|
202
|
+
export { TokenIssuerUnavailable, TokenRefused, TokenSource, oauthClient, staticTokenSource };
|
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@btravstack/oauth-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.29.0",
|
|
4
|
+
"description": "A TokenSource port for @btravstack/core: the OAuth 2.0 client credentials grant, so a service calls another as itself โ cached per audience until shortly before expiry, one fetch shared by concurrent callers, and a static adapter for tests",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"client-credentials",
|
|
7
|
+
"dependency-injection",
|
|
8
|
+
"oauth2",
|
|
9
|
+
"openid-client",
|
|
10
|
+
"typescript",
|
|
11
|
+
"unthrown"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/btravstack/btravstack#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/btravstack/btravstack/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/btravstack/btravstack.git",
|
|
22
|
+
"directory": "packages/oauth-client"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"main": "./dist/index.cjs",
|
|
30
|
+
"module": "./dist/index.mjs",
|
|
31
|
+
"types": "./dist/index.d.cts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"import": {
|
|
35
|
+
"types": "./dist/index.d.mts",
|
|
36
|
+
"default": "./dist/index.mjs"
|
|
37
|
+
},
|
|
38
|
+
"require": {
|
|
39
|
+
"types": "./dist/index.d.cts",
|
|
40
|
+
"default": "./dist/index.cjs"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@btravstack/config": "0.29.0",
|
|
47
|
+
"@btravstack/core": "0.29.0",
|
|
48
|
+
"@btravstack/di": "0.29.0",
|
|
49
|
+
"@btravstack/internal-test-infra": "0.0.0",
|
|
50
|
+
"@btravstack/testing": "0.29.0",
|
|
51
|
+
"@btravstack/tsconfig": "0.4.0",
|
|
52
|
+
"@types/node": "26.6.4",
|
|
53
|
+
"@unthrown/vitest": "5.12.0",
|
|
54
|
+
"@vitest/coverage-v8": "5.0.3",
|
|
55
|
+
"openid-client": "6.8.8",
|
|
56
|
+
"tsdown": "0.23.0",
|
|
57
|
+
"typescript": "7.0.2",
|
|
58
|
+
"unthrown": "5.12.0",
|
|
59
|
+
"vitest": "5.0.3"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@btravstack/config": "^0.29.0",
|
|
63
|
+
"@btravstack/core": "^0.29.0",
|
|
64
|
+
"@btravstack/di": "^0.29.0",
|
|
65
|
+
"openid-client": "^6",
|
|
66
|
+
"unthrown": "^5.0.0"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": ">=22.12"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "tsdown src/index.ts --format cjs,esm --dts --clean",
|
|
73
|
+
"dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
|
|
74
|
+
"test": "vitest run --coverage",
|
|
75
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
|
|
76
|
+
}
|
|
6
77
|
}
|