@btravstack/oauth-client 0.29.0 → 0.30.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/README.md +11 -6
- package/dist/index.cjs +60 -12
- package/dist/index.d.cts +23 -9
- package/dist/index.d.mts +23 -9
- package/dist/index.mjs +61 -13
- package/package.json +9 -9
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
|
|
5
|
-
>
|
|
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
|
|
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
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
122
|
-
const
|
|
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
|
-
*
|
|
132
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
121
|
-
const
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
4
|
-
"description": "A TokenSource port for @btravstack/core:
|
|
3
|
+
"version": "0.30.0",
|
|
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,11 +43,11 @@
|
|
|
43
43
|
"./package.json": "./package.json"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@btravstack/config": "0.
|
|
47
|
-
"@btravstack/core": "0.
|
|
48
|
-
"@btravstack/di": "0.
|
|
46
|
+
"@btravstack/config": "0.30.0",
|
|
47
|
+
"@btravstack/core": "0.30.0",
|
|
48
|
+
"@btravstack/di": "0.30.0",
|
|
49
49
|
"@btravstack/internal-test-infra": "0.0.0",
|
|
50
|
-
"@btravstack/testing": "0.
|
|
50
|
+
"@btravstack/testing": "0.30.0",
|
|
51
51
|
"@btravstack/tsconfig": "0.4.0",
|
|
52
52
|
"@types/node": "26.6.4",
|
|
53
53
|
"@unthrown/vitest": "5.12.0",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"vitest": "5.0.3"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
|
-
"@btravstack/config": "^0.
|
|
63
|
-
"@btravstack/core": "^0.
|
|
64
|
-
"@btravstack/di": "^0.
|
|
62
|
+
"@btravstack/config": "^0.30.0",
|
|
63
|
+
"@btravstack/core": "^0.30.0",
|
|
64
|
+
"@btravstack/di": "^0.30.0",
|
|
65
65
|
"openid-client": "^6",
|
|
66
66
|
"unthrown": "^5.0.0"
|
|
67
67
|
},
|