@btravstack/oauth-client 0.29.0 → 0.30.1

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/README.md CHANGED
@@ -1,8 +1,8 @@
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
4
+ > service calls another as itself or for a subject, over client credentials
5
+ > or RFC 8693 token exchange — kept per audience until shortly before it expires, one fetch shared
6
6
  > by concurrent callers, and a static adapter for tests.
7
7
 
8
8
  📖 **[Documentation](https://btravstack.github.io/btravstack/reference/oauth-client)** ·
@@ -77,14 +77,19 @@ which is this list's one detailed home.
77
77
 
78
78
  **It decides** that discovery is lazy, so an issuer down at deploy fails the
79
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
80
+ minute before it expires, with fixed FIFO capacity so subject turnover stays
81
+ bounded; that a refusal (`TokenRefused`, with the issuer's
81
82
  `error` code) and an outage (`TokenIssuerUnavailable`) are two typed errors;
82
83
  that every fetch is reported to `Observers`; and that an `oauth-client` health
83
- check fails while an audience's last fetch failed.
84
+ check follows the newest-started fetch per audience and scope, even when
85
+ responses finish out of order. Each request is snapshotted at entry so caller
86
+ mutation cannot change its grant or poison another credential's cache.
84
87
 
85
88
  **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
89
+ to your transport. On behalf of a subject, `token({ audience, onBehalfOf:
90
+ { token, tokenType, actor? } })` uses RFC 8693 exchange. The issuer must
91
+ authorize delegation and mint the `act` claim; an actor credential is supplied
92
+ when it requires one. The reasoning is in
88
93
  [`AGENTS.md`](./AGENTS.md).
89
94
 
90
95
  ## License
package/dist/index.cjs CHANGED
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_crypto = require("node:crypto");
2
3
  let _btravstack_config = require("@btravstack/config");
3
4
  let _btravstack_core = require("@btravstack/core");
4
5
  let _btravstack_di = require("@btravstack/di");
@@ -21,9 +22,9 @@ var TokenRefused = class extends (0, unthrown.TaggedError)("TokenRefused") {};
21
22
  */
22
23
  var TokenIssuerUnavailable = class extends (0, unthrown.TaggedError)("TokenIssuerUnavailable") {};
23
24
  /**
24
- * The port an application depends on to call another service as itself: a
25
+ * The port an application depends on to call another service as itself or for a subject: a
25
26
  * bearer token for an audience. `oauthClient()` provides it over the client
26
- * credentials grant; `staticTokenSource` is the in-process stand-in.
27
+ * credentials grant or RFC 8693 exchange; `staticTokenSource` is the in-process stand-in.
27
28
  */
28
29
  var TokenSource = class extends (0, _btravstack_di.Port)("TokenSource") {};
29
30
  /**
@@ -44,6 +45,7 @@ const staticTokenSource = (bearer) => (0, _btravstack_di.Provider)(TokenSource)(
44
45
  * is still reused.
45
46
  */
46
47
  const SKEW_MS = 6e4;
48
+ const CACHE_CAPACITY = 1024;
47
49
  /** The source and its health, built once, so the check reads the same fetches the callers make. */
48
50
  var TokenFetches = class extends (0, _btravstack_di.Port)("OAuthTokenFetches") {};
49
51
  const fetching = (request) => ({
@@ -73,6 +75,7 @@ const tokenFetches = (settings, observers, clock) => {
73
75
  const cached = /* @__PURE__ */ new Map();
74
76
  const inFlight = /* @__PURE__ */ new Map();
75
77
  const failing = /* @__PURE__ */ new Map();
78
+ const latestFetch = /* @__PURE__ */ new Map();
76
79
  const discover = () => {
77
80
  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
81
  issuer: settings.issuer,
@@ -85,9 +88,19 @@ const tokenFetches = (settings, observers, clock) => {
85
88
  });
86
89
  return configuration;
87
90
  };
88
- const grant = (request, config) => (0, unthrown.fromPromise)((0, openid_client.clientCredentialsGrant)(config, {
91
+ const grant = (request, config) => (0, unthrown.fromPromise)(request.onBehalfOf === void 0 ? (0, openid_client.clientCredentialsGrant)(config, {
89
92
  audience: request.audience,
90
93
  ...request.scope === void 0 ? {} : { scope: request.scope }
94
+ }) : (0, openid_client.genericGrantRequest)(config, "urn:ietf:params:oauth:grant-type:token-exchange", {
95
+ audience: request.audience,
96
+ ...request.scope === void 0 ? {} : { scope: request.scope },
97
+ subject_token: request.onBehalfOf.token,
98
+ subject_token_type: request.onBehalfOf.tokenType,
99
+ requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
100
+ ...request.onBehalfOf.actor === void 0 ? {} : {
101
+ actor_token: request.onBehalfOf.actor.token,
102
+ actor_token_type: request.onBehalfOf.actor.tokenType
103
+ }
91
104
  }), (cause) => {
92
105
  const refusal = refusalOf(cause);
93
106
  return refusal === void 0 ? new TokenIssuerUnavailable({
@@ -100,6 +113,9 @@ const tokenFetches = (settings, observers, clock) => {
100
113
  });
101
114
  const fetchToken = (key, request) => {
102
115
  const asked = clock.now();
116
+ const healthKey = JSON.stringify([request.audience, request.scope ?? null]);
117
+ const generation = Symbol();
118
+ latestFetch.set(healthKey, generation);
103
119
  const fetched = (0, _btravstack_core.observed)(observers, fetching(request), () => discover().flatMap((config) => grant(request, config))).map((response) => {
104
120
  const lifetimeMs = (response.expires_in ?? 0) * 1e3;
105
121
  return {
@@ -108,28 +124,57 @@ const tokenFetches = (settings, observers, clock) => {
108
124
  };
109
125
  }).tap((entry) => {
110
126
  inFlight.delete(key);
111
- failing.delete(key);
127
+ if (latestFetch.get(healthKey) === generation) {
128
+ latestFetch.delete(healthKey);
129
+ failing.delete(healthKey);
130
+ }
112
131
  cached.set(key, entry);
132
+ if (cached.size > CACHE_CAPACITY) cached.delete(cached.keys().next().value);
113
133
  }).tapFailure((failure) => {
114
134
  inFlight.delete(key);
115
- failing.set(key, reasonOf(request.audience, failure));
135
+ if (latestFetch.get(healthKey) === generation) {
136
+ latestFetch.delete(healthKey);
137
+ failing.set(healthKey, reasonOf(request.audience, failure));
138
+ }
116
139
  }).map((entry) => entry.token);
117
140
  inFlight.set(key, fetched);
118
141
  return fetched;
119
142
  };
120
143
  return {
121
- source: { token: (request) => {
122
- const key = JSON.stringify([request.audience, request.scope ?? null]);
144
+ source: { token: (input) => {
145
+ const { audience, scope, onBehalfOf } = input;
146
+ const actor = onBehalfOf?.actor;
147
+ const request = {
148
+ audience,
149
+ ...scope === void 0 ? {} : { scope },
150
+ ...onBehalfOf === void 0 ? {} : { onBehalfOf: {
151
+ token: onBehalfOf.token,
152
+ tokenType: onBehalfOf.tokenType,
153
+ ...actor === void 0 ? {} : { actor: {
154
+ token: actor.token,
155
+ tokenType: actor.tokenType
156
+ } }
157
+ } }
158
+ };
159
+ const key = (0, node_crypto.createHash)("sha256").update(JSON.stringify([
160
+ request.audience,
161
+ request.scope ?? null,
162
+ request.onBehalfOf?.token ?? null,
163
+ request.onBehalfOf?.tokenType ?? null,
164
+ request.onBehalfOf?.actor?.token ?? null,
165
+ request.onBehalfOf?.actor?.tokenType ?? null
166
+ ])).digest("hex");
123
167
  const hit = cached.get(key);
124
168
  if (hit !== void 0 && clock.now() < hit.refreshAt) return (0, unthrown.OkAsync)(hit.token);
169
+ cached.delete(key);
125
170
  return inFlight.get(key) ?? fetchToken(key, request);
126
171
  } },
127
172
  health: () => failing.size === 0 ? (0, unthrown.OkAsync)() : (0, unthrown.ErrAsync)(new _btravstack_core.HealthCheckFailed({ reason: [...failing.values()].join("; ") }))
128
173
  };
129
174
  };
130
175
  /**
131
- * The client credentials starter: `TokenSource` over an OAuth 2.0
132
- * authorization server, so a service calls another as itself.
176
+ * `TokenSource` over an OAuth 2.0 authorization server: client credentials
177
+ * for the service itself, or RFC 8693 token exchange for a subject.
133
178
  *
134
179
  * ```ts
135
180
  * Module("Gateway")({ imports: [oauthClient()], provides: [ordersClient] });
@@ -142,14 +187,17 @@ const tokenFetches = (settings, observers, clock) => {
142
187
  * - **Discovery is lazy**: the first token asks for the issuer's metadata, and
143
188
  * a failed discovery is asked again by the next. An authorization server
144
189
  * 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
190
+ * - **A token is kept per audience, scope and subject/actor credentials** until a minute before it
146
191
  * expires (half its lifetime, if that is shorter), and callers asking for
147
192
  * the same one while it is being fetched share that one request. A failure
148
- * is never kept.
193
+ * is never kept. The source keeps at most 1024 tokens, evicting the oldest
194
+ * insertion at capacity and removing stale entries when accessed.
195
+ * The complete request is snapshotted at entry, so caller mutation cannot
196
+ * change the credential or grant associated with its cache key.
149
197
  * - **Every fetch is reported to `Observers`** (`component: "oauth-client"`);
150
198
  * a token served from memory reports nothing.
151
199
  * - **It contributes the `oauth-client` health check**, failing while the
152
- * last fetch of any audience failed — a rotated secret and an unreachable
200
+ * newest-started fetch of any audience/scope pair failed — a rotated secret and an unreachable
153
201
  * issuer both show there, where a reachability probe would see only the
154
202
  * second.
155
203
  */
package/dist/index.d.cts CHANGED
@@ -7,13 +7,24 @@ import { AsyncResult } from "unthrown";
7
7
  * What a token is asked for: the service it will be presented to, and
8
8
  * optionally the scopes it must carry (space-delimited, as OAuth spells them).
9
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.
10
+ * On behalf of a subject, supply its token and the RFC 8693 token type. The
11
+ * service still authenticates with its client credentials. An issuer using
12
+ * explicit delegation also takes the acting service's token in `actor`.
12
13
  */
13
14
  type TokenRequest = {
14
15
  /** The callee, as its authorization server names it: the `aud` the token is minted for. */
15
16
  readonly audience: string;
16
17
  readonly scope?: string;
18
+ /** A subject credential to exchange; the issuer must authorize delegation and mint the `act` claim. */
19
+ readonly onBehalfOf?: {
20
+ readonly token: string;
21
+ readonly tokenType: string;
22
+ /** The acting service's credential, where the issuer requires RFC 8693 `actor_token`. */
23
+ readonly actor?: {
24
+ readonly token: string;
25
+ readonly tokenType: string;
26
+ };
27
+ };
17
28
  };
18
29
  /**
19
30
  * A token to present, as `Authorization: Bearer <bearer>`.
@@ -54,9 +65,9 @@ type TokenSourceService = {
54
65
  };
55
66
  declare const TokenSource_base: import("@btravstack/di").PortClass<"TokenSource">;
56
67
  /**
57
- * The port an application depends on to call another service as itself: a
68
+ * The port an application depends on to call another service as itself or for a subject: a
58
69
  * bearer token for an audience. `oauthClient()` provides it over the client
59
- * credentials grant; `staticTokenSource` is the in-process stand-in.
70
+ * credentials grant or RFC 8693 exchange; `staticTokenSource` is the in-process stand-in.
60
71
  */
61
72
  export declare class TokenSource extends TokenSource_base<TokenSourceService> {}
62
73
  /**
@@ -88,8 +99,8 @@ type OAuthClientOptions = {
88
99
  readonly clock?: Clock | undefined;
89
100
  };
90
101
  /**
91
- * The client credentials starter: `TokenSource` over an OAuth 2.0
92
- * authorization server, so a service calls another as itself.
102
+ * `TokenSource` over an OAuth 2.0 authorization server: client credentials
103
+ * for the service itself, or RFC 8693 token exchange for a subject.
93
104
  *
94
105
  * ```ts
95
106
  * Module("Gateway")({ imports: [oauthClient()], provides: [ordersClient] });
@@ -102,14 +113,17 @@ type OAuthClientOptions = {
102
113
  * - **Discovery is lazy**: the first token asks for the issuer's metadata, and
103
114
  * a failed discovery is asked again by the next. An authorization server
104
115
  * 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
116
+ * - **A token is kept per audience, scope and subject/actor credentials** until a minute before it
106
117
  * expires (half its lifetime, if that is shorter), and callers asking for
107
118
  * the same one while it is being fetched share that one request. A failure
108
- * is never kept.
119
+ * is never kept. The source keeps at most 1024 tokens, evicting the oldest
120
+ * insertion at capacity and removing stale entries when accessed.
121
+ * The complete request is snapshotted at entry, so caller mutation cannot
122
+ * change the credential or grant associated with its cache key.
109
123
  * - **Every fetch is reported to `Observers`** (`component: "oauth-client"`);
110
124
  * a token served from memory reports nothing.
111
125
  * - **It contributes the `oauth-client` health check**, failing while the
112
- * last fetch of any audience failed — a rotated secret and an unreachable
126
+ * newest-started fetch of any audience/scope pair failed — a rotated secret and an unreachable
113
127
  * issuer both show there, where a reachability probe would see only the
114
128
  * second.
115
129
  */
package/dist/index.d.mts CHANGED
@@ -7,13 +7,24 @@ import { AsyncResult } from "unthrown";
7
7
  * What a token is asked for: the service it will be presented to, and
8
8
  * optionally the scopes it must carry (space-delimited, as OAuth spells them).
9
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.
10
+ * On behalf of a subject, supply its token and the RFC 8693 token type. The
11
+ * service still authenticates with its client credentials. An issuer using
12
+ * explicit delegation also takes the acting service's token in `actor`.
12
13
  */
13
14
  type TokenRequest = {
14
15
  /** The callee, as its authorization server names it: the `aud` the token is minted for. */
15
16
  readonly audience: string;
16
17
  readonly scope?: string;
18
+ /** A subject credential to exchange; the issuer must authorize delegation and mint the `act` claim. */
19
+ readonly onBehalfOf?: {
20
+ readonly token: string;
21
+ readonly tokenType: string;
22
+ /** The acting service's credential, where the issuer requires RFC 8693 `actor_token`. */
23
+ readonly actor?: {
24
+ readonly token: string;
25
+ readonly tokenType: string;
26
+ };
27
+ };
17
28
  };
18
29
  /**
19
30
  * A token to present, as `Authorization: Bearer <bearer>`.
@@ -54,9 +65,9 @@ type TokenSourceService = {
54
65
  };
55
66
  declare const TokenSource_base: import("@btravstack/di").PortClass<"TokenSource">;
56
67
  /**
57
- * The port an application depends on to call another service as itself: a
68
+ * The port an application depends on to call another service as itself or for a subject: a
58
69
  * bearer token for an audience. `oauthClient()` provides it over the client
59
- * credentials grant; `staticTokenSource` is the in-process stand-in.
70
+ * credentials grant or RFC 8693 exchange; `staticTokenSource` is the in-process stand-in.
60
71
  */
61
72
  export declare class TokenSource extends TokenSource_base<TokenSourceService> {}
62
73
  /**
@@ -88,8 +99,8 @@ type OAuthClientOptions = {
88
99
  readonly clock?: Clock | undefined;
89
100
  };
90
101
  /**
91
- * The client credentials starter: `TokenSource` over an OAuth 2.0
92
- * authorization server, so a service calls another as itself.
102
+ * `TokenSource` over an OAuth 2.0 authorization server: client credentials
103
+ * for the service itself, or RFC 8693 token exchange for a subject.
93
104
  *
94
105
  * ```ts
95
106
  * Module("Gateway")({ imports: [oauthClient()], provides: [ordersClient] });
@@ -102,14 +113,17 @@ type OAuthClientOptions = {
102
113
  * - **Discovery is lazy**: the first token asks for the issuer's metadata, and
103
114
  * a failed discovery is asked again by the next. An authorization server
104
115
  * 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
116
+ * - **A token is kept per audience, scope and subject/actor credentials** until a minute before it
106
117
  * expires (half its lifetime, if that is shorter), and callers asking for
107
118
  * the same one while it is being fetched share that one request. A failure
108
- * is never kept.
119
+ * is never kept. The source keeps at most 1024 tokens, evicting the oldest
120
+ * insertion at capacity and removing stale entries when accessed.
121
+ * The complete request is snapshotted at entry, so caller mutation cannot
122
+ * change the credential or grant associated with its cache key.
109
123
  * - **Every fetch is reported to `Observers`** (`component: "oauth-client"`);
110
124
  * a token served from memory reports nothing.
111
125
  * - **It contributes the `oauth-client` health check**, failing while the
112
- * last fetch of any audience failed — a rotated secret and an unreachable
126
+ * newest-started fetch of any audience/scope pair failed — a rotated secret and an unreachable
113
127
  * issuer both show there, where a reachability probe would see only the
114
128
  * second.
115
129
  */
package/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { Config, Env, cleartext, cleartextRefused } from "@btravstack/config";
2
3
  import { HealthCheckFailed, HealthChecks, Observers, noObserverMember, observed, systemClock } from "@btravstack/core";
3
4
  import { Module, Port, Provider } from "@btravstack/di";
4
- import { ClientSecretBasic, ResponseBodyError, WWWAuthenticateChallengeError, allowInsecureRequests, clientCredentialsGrant, discovery } from "openid-client";
5
+ import { ClientSecretBasic, ResponseBodyError, WWWAuthenticateChallengeError, allowInsecureRequests, clientCredentialsGrant, discovery, genericGrantRequest } from "openid-client";
5
6
  import { ErrAsync, OkAsync, TaggedError, fromPromise } from "unthrown";
6
7
  //#region src/token-source.ts
7
8
  /**
@@ -20,9 +21,9 @@ var TokenRefused = class extends TaggedError("TokenRefused") {};
20
21
  */
21
22
  var TokenIssuerUnavailable = class extends TaggedError("TokenIssuerUnavailable") {};
22
23
  /**
23
- * The port an application depends on to call another service as itself: a
24
+ * The port an application depends on to call another service as itself or for a subject: a
24
25
  * bearer token for an audience. `oauthClient()` provides it over the client
25
- * credentials grant; `staticTokenSource` is the in-process stand-in.
26
+ * credentials grant or RFC 8693 exchange; `staticTokenSource` is the in-process stand-in.
26
27
  */
27
28
  var TokenSource = class extends Port("TokenSource") {};
28
29
  /**
@@ -43,6 +44,7 @@ const staticTokenSource = (bearer) => Provider(TokenSource)({
43
44
  * is still reused.
44
45
  */
45
46
  const SKEW_MS = 6e4;
47
+ const CACHE_CAPACITY = 1024;
46
48
  /** The source and its health, built once, so the check reads the same fetches the callers make. */
47
49
  var TokenFetches = class extends Port("OAuthTokenFetches") {};
48
50
  const fetching = (request) => ({
@@ -72,6 +74,7 @@ const tokenFetches = (settings, observers, clock) => {
72
74
  const cached = /* @__PURE__ */ new Map();
73
75
  const inFlight = /* @__PURE__ */ new Map();
74
76
  const failing = /* @__PURE__ */ new Map();
77
+ const latestFetch = /* @__PURE__ */ new Map();
75
78
  const discover = () => {
76
79
  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
80
  issuer: settings.issuer,
@@ -84,9 +87,19 @@ const tokenFetches = (settings, observers, clock) => {
84
87
  });
85
88
  return configuration;
86
89
  };
87
- const grant = (request, config) => fromPromise(clientCredentialsGrant(config, {
90
+ const grant = (request, config) => fromPromise(request.onBehalfOf === void 0 ? clientCredentialsGrant(config, {
88
91
  audience: request.audience,
89
92
  ...request.scope === void 0 ? {} : { scope: request.scope }
93
+ }) : genericGrantRequest(config, "urn:ietf:params:oauth:grant-type:token-exchange", {
94
+ audience: request.audience,
95
+ ...request.scope === void 0 ? {} : { scope: request.scope },
96
+ subject_token: request.onBehalfOf.token,
97
+ subject_token_type: request.onBehalfOf.tokenType,
98
+ requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
99
+ ...request.onBehalfOf.actor === void 0 ? {} : {
100
+ actor_token: request.onBehalfOf.actor.token,
101
+ actor_token_type: request.onBehalfOf.actor.tokenType
102
+ }
90
103
  }), (cause) => {
91
104
  const refusal = refusalOf(cause);
92
105
  return refusal === void 0 ? new TokenIssuerUnavailable({
@@ -99,6 +112,9 @@ const tokenFetches = (settings, observers, clock) => {
99
112
  });
100
113
  const fetchToken = (key, request) => {
101
114
  const asked = clock.now();
115
+ const healthKey = JSON.stringify([request.audience, request.scope ?? null]);
116
+ const generation = Symbol();
117
+ latestFetch.set(healthKey, generation);
102
118
  const fetched = observed(observers, fetching(request), () => discover().flatMap((config) => grant(request, config))).map((response) => {
103
119
  const lifetimeMs = (response.expires_in ?? 0) * 1e3;
104
120
  return {
@@ -107,28 +123,57 @@ const tokenFetches = (settings, observers, clock) => {
107
123
  };
108
124
  }).tap((entry) => {
109
125
  inFlight.delete(key);
110
- failing.delete(key);
126
+ if (latestFetch.get(healthKey) === generation) {
127
+ latestFetch.delete(healthKey);
128
+ failing.delete(healthKey);
129
+ }
111
130
  cached.set(key, entry);
131
+ if (cached.size > CACHE_CAPACITY) cached.delete(cached.keys().next().value);
112
132
  }).tapFailure((failure) => {
113
133
  inFlight.delete(key);
114
- failing.set(key, reasonOf(request.audience, failure));
134
+ if (latestFetch.get(healthKey) === generation) {
135
+ latestFetch.delete(healthKey);
136
+ failing.set(healthKey, reasonOf(request.audience, failure));
137
+ }
115
138
  }).map((entry) => entry.token);
116
139
  inFlight.set(key, fetched);
117
140
  return fetched;
118
141
  };
119
142
  return {
120
- source: { token: (request) => {
121
- const key = JSON.stringify([request.audience, request.scope ?? null]);
143
+ source: { token: (input) => {
144
+ const { audience, scope, onBehalfOf } = input;
145
+ const actor = onBehalfOf?.actor;
146
+ const request = {
147
+ audience,
148
+ ...scope === void 0 ? {} : { scope },
149
+ ...onBehalfOf === void 0 ? {} : { onBehalfOf: {
150
+ token: onBehalfOf.token,
151
+ tokenType: onBehalfOf.tokenType,
152
+ ...actor === void 0 ? {} : { actor: {
153
+ token: actor.token,
154
+ tokenType: actor.tokenType
155
+ } }
156
+ } }
157
+ };
158
+ const key = createHash("sha256").update(JSON.stringify([
159
+ request.audience,
160
+ request.scope ?? null,
161
+ request.onBehalfOf?.token ?? null,
162
+ request.onBehalfOf?.tokenType ?? null,
163
+ request.onBehalfOf?.actor?.token ?? null,
164
+ request.onBehalfOf?.actor?.tokenType ?? null
165
+ ])).digest("hex");
122
166
  const hit = cached.get(key);
123
167
  if (hit !== void 0 && clock.now() < hit.refreshAt) return OkAsync(hit.token);
168
+ cached.delete(key);
124
169
  return inFlight.get(key) ?? fetchToken(key, request);
125
170
  } },
126
171
  health: () => failing.size === 0 ? OkAsync() : ErrAsync(new HealthCheckFailed({ reason: [...failing.values()].join("; ") }))
127
172
  };
128
173
  };
129
174
  /**
130
- * The client credentials starter: `TokenSource` over an OAuth 2.0
131
- * authorization server, so a service calls another as itself.
175
+ * `TokenSource` over an OAuth 2.0 authorization server: client credentials
176
+ * for the service itself, or RFC 8693 token exchange for a subject.
132
177
  *
133
178
  * ```ts
134
179
  * Module("Gateway")({ imports: [oauthClient()], provides: [ordersClient] });
@@ -141,14 +186,17 @@ const tokenFetches = (settings, observers, clock) => {
141
186
  * - **Discovery is lazy**: the first token asks for the issuer's metadata, and
142
187
  * a failed discovery is asked again by the next. An authorization server
143
188
  * 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
189
+ * - **A token is kept per audience, scope and subject/actor credentials** until a minute before it
145
190
  * expires (half its lifetime, if that is shorter), and callers asking for
146
191
  * the same one while it is being fetched share that one request. A failure
147
- * is never kept.
192
+ * is never kept. The source keeps at most 1024 tokens, evicting the oldest
193
+ * insertion at capacity and removing stale entries when accessed.
194
+ * The complete request is snapshotted at entry, so caller mutation cannot
195
+ * change the credential or grant associated with its cache key.
148
196
  * - **Every fetch is reported to `Observers`** (`component: "oauth-client"`);
149
197
  * a token served from memory reports nothing.
150
198
  * - **It contributes the `oauth-client` health check**, failing while the
151
- * last fetch of any audience failed — a rotated secret and an unreachable
199
+ * newest-started fetch of any audience/scope pair failed — a rotated secret and an unreachable
152
200
  * issuer both show there, where a reachability probe would see only the
153
201
  * second.
154
202
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@btravstack/oauth-client",
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",
3
+ "version": "0.30.1",
4
+ "description": "A TokenSource port for @btravstack/core: OAuth 2.0 client credentials and RFC 8693 token exchange, so a service calls another as itself or for a subject — cached per audience until shortly before expiry, one fetch shared by concurrent callers, and a static adapter for tests",
5
5
  "keywords": [
6
6
  "client-credentials",
7
7
  "dependency-injection",
@@ -43,25 +43,25 @@
43
43
  "./package.json": "./package.json"
44
44
  },
45
45
  "devDependencies": {
46
- "@btravstack/config": "0.29.0",
47
- "@btravstack/core": "0.29.0",
48
- "@btravstack/di": "0.29.0",
46
+ "@btravstack/config": "0.30.1",
47
+ "@btravstack/core": "0.30.1",
48
+ "@btravstack/di": "0.30.1",
49
49
  "@btravstack/internal-test-infra": "0.0.0",
50
- "@btravstack/testing": "0.29.0",
50
+ "@btravstack/testing": "0.30.1",
51
51
  "@btravstack/tsconfig": "0.4.0",
52
52
  "@types/node": "26.6.4",
53
- "@unthrown/vitest": "5.12.0",
53
+ "@unthrown/vitest": "5.12.1",
54
54
  "@vitest/coverage-v8": "5.0.3",
55
55
  "openid-client": "6.8.8",
56
56
  "tsdown": "0.23.0",
57
57
  "typescript": "7.0.2",
58
- "unthrown": "5.12.0",
58
+ "unthrown": "5.12.1",
59
59
  "vitest": "5.0.3"
60
60
  },
61
61
  "peerDependencies": {
62
- "@btravstack/config": "^0.29.0",
63
- "@btravstack/core": "^0.29.0",
64
- "@btravstack/di": "^0.29.0",
62
+ "@btravstack/config": "^0.30.1",
63
+ "@btravstack/core": "^0.30.1",
64
+ "@btravstack/di": "^0.30.1",
65
65
  "openid-client": "^6",
66
66
  "unthrown": "^5.0.0"
67
67
  },