@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 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
- # Temporary Holding Version
1
+ # @btravstack/oauth-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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;
@@ -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 };
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }