@fluixi/oauth2 1.0.0-alpha.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/LICENSE +21 -0
- package/dist/api-guard.cjs +185 -0
- package/dist/api-guard.d.ts +94 -0
- package/dist/api-guard.d.ts.map +1 -0
- package/dist/api-guard.js +127 -0
- package/dist/api-guard.mjs +141 -0
- package/dist/browser-FDUOEUM2.mjs +3544 -0
- package/dist/browser-ONMHX3Z3.mjs +3545 -0
- package/dist/browser.cjs +735 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +448 -0
- package/dist/browser.mjs +704 -0
- package/dist/callback.cjs +125 -0
- package/dist/callback.d.ts +113 -0
- package/dist/callback.d.ts.map +1 -0
- package/dist/callback.js +183 -0
- package/dist/callback.mjs +106 -0
- package/dist/chunk-25WE6UWI.mjs +10 -0
- package/dist/chunk-4NSOGPLM.mjs +15 -0
- package/dist/chunk-7P6ASYW6.mjs +9 -0
- package/dist/chunk-OGSFNTKA.mjs +13 -0
- package/dist/client-auth.cjs +115 -0
- package/dist/client-auth.d.ts +43 -0
- package/dist/client-auth.d.ts.map +1 -0
- package/dist/client-auth.js +112 -0
- package/dist/client-auth.mjs +84 -0
- package/dist/discovery.cjs +86 -0
- package/dist/discovery.d.ts +15 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +51 -0
- package/dist/discovery.mjs +63 -0
- package/dist/dpop-LVPWWADA.mjs +80 -0
- package/dist/dpop-NS7NKOWM.mjs +79 -0
- package/dist/dpop-store.cjs +152 -0
- package/dist/dpop-store.d.ts +37 -0
- package/dist/dpop-store.d.ts.map +1 -0
- package/dist/dpop-store.js +119 -0
- package/dist/dpop-store.mjs +121 -0
- package/dist/dpop.cjs +117 -0
- package/dist/dpop.d.ts +72 -0
- package/dist/dpop.d.ts.map +1 -0
- package/dist/dpop.js +114 -0
- package/dist/dpop.mjs +86 -0
- package/dist/env-config.cjs +366 -0
- package/dist/env-config.d.ts +103 -0
- package/dist/env-config.d.ts.map +1 -0
- package/dist/env-config.js +184 -0
- package/dist/env-config.mjs +343 -0
- package/dist/env-credentials.cjs +82 -0
- package/dist/env-credentials.d.ts +68 -0
- package/dist/env-credentials.d.ts.map +1 -0
- package/dist/env-credentials.js +76 -0
- package/dist/env-credentials.mjs +51 -0
- package/dist/index.cjs +940 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.mjs +909 -0
- package/dist/pkce.cjs +87 -0
- package/dist/pkce.d.ts +58 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +112 -0
- package/dist/pkce.mjs +66 -0
- package/dist/presets.cjs +240 -0
- package/dist/presets.d.ts +140 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +182 -0
- package/dist/presets.mjs +217 -0
- package/dist/return-to.cjs +46 -0
- package/dist/return-to.d.ts +49 -0
- package/dist/return-to.d.ts.map +1 -0
- package/dist/return-to.js +71 -0
- package/dist/return-to.mjs +25 -0
- package/dist/roles.cjs +56 -0
- package/dist/roles.d.ts +35 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +37 -0
- package/dist/roles.mjs +35 -0
- package/dist/server-client.cjs +134 -0
- package/dist/server-client.d.ts +12 -0
- package/dist/server-client.d.ts.map +1 -0
- package/dist/server-client.js +107 -0
- package/dist/server-client.mjs +111 -0
- package/dist/server.cjs +470 -0
- package/dist/server.d.ts +89 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +360 -0
- package/dist/server.mjs +441 -0
- package/dist/service-client.cjs +244 -0
- package/dist/service-client.d.ts +89 -0
- package/dist/service-client.d.ts.map +1 -0
- package/dist/service-client.js +112 -0
- package/dist/service-client.mjs +213 -0
- package/dist/service.cjs +903 -0
- package/dist/service.d.ts +96 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +129 -0
- package/dist/service.mjs +874 -0
- package/dist/tokens.cjs +29 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +21 -0
- package/dist/tokens.mjs +8 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/types.cjs +33 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.mjs +12 -0
- package/dist/verify.cjs +724 -0
- package/dist/verify.d.ts +205 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +371 -0
- package/dist/verify.mjs +590 -0
- package/package.json +121 -0
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@fluixi/oauth2/verify`: check an access token at the API that receives it.
|
|
3
|
+
*
|
|
4
|
+
* This is the half that actually decides anything. A client holds a token and sends it;
|
|
5
|
+
* the service on the other end is the one that must not take it on trust. Signature,
|
|
6
|
+
* issuer, audience and expiry, on every request.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here is Fluixi-specific. It takes a `Request` or a bare string and returns
|
|
9
|
+
* claims, so an Express service, a worker, a Hono route or a plain Node handler can use
|
|
10
|
+
* it without adopting anything else.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const verify = createTokenVerifier({
|
|
14
|
+
* issuer: process.env.OIDC_ISSUER!,
|
|
15
|
+
* audience: 'adafri-admin',
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* const claims = await verify.fromRequest(request); // throws when it does not hold
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* ## What this does not do
|
|
22
|
+
*
|
|
23
|
+
* The cryptography is `jose`'s, deliberately. A hand-written verifier is how `alg: none`
|
|
24
|
+
* and RS256-to-HS256 confusion get shipped, and both turn a check into a way in. What
|
|
25
|
+
* this adds is the configuration around it: algorithms pinned rather than read from the
|
|
26
|
+
* token, an issuer and audience that must be stated, and the JWKS URL found by discovery
|
|
27
|
+
* so a service does not hardcode it.
|
|
28
|
+
*
|
|
29
|
+
* `jose` is an optional peer. An app that never verifies never installs it.
|
|
30
|
+
*
|
|
31
|
+
* ## Providers that omit `aud`
|
|
32
|
+
*
|
|
33
|
+
* Keycloak issues access tokens with no `aud` until an audience mapper is configured,
|
|
34
|
+
* naming the client in `azp` instead. A service in front of one wants:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* createTokenVerifier({ issuer, audience: false, authorizedParty: 'adafri-admin' });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Not `audience: false` on its own: that accepts a token minted for any client of the
|
|
41
|
+
* issuer, which is the check being skipped rather than moved.
|
|
42
|
+
*/
|
|
43
|
+
import { type ClientAuthMethodInput } from './client-auth.js';
|
|
44
|
+
import { OAuthError } from './types.js';
|
|
45
|
+
/** The claims a verified token carried. Shapes vary by provider, so this stays open. */
|
|
46
|
+
export type TokenClaims = Record<string, unknown> & {
|
|
47
|
+
sub?: string;
|
|
48
|
+
iss?: string;
|
|
49
|
+
aud?: string | string[];
|
|
50
|
+
/** The client a token was issued to. `azp` in OIDC, `client_id` on some providers. */
|
|
51
|
+
azp?: string;
|
|
52
|
+
client_id?: string;
|
|
53
|
+
exp?: number;
|
|
54
|
+
iat?: number;
|
|
55
|
+
scope?: string;
|
|
56
|
+
};
|
|
57
|
+
export interface VerifierOptions {
|
|
58
|
+
/**
|
|
59
|
+
* The issuer this service accepts, and the only one.
|
|
60
|
+
*
|
|
61
|
+
* Required, and never taken from the token: a token that names its own issuer proves
|
|
62
|
+
* nothing, and accepting it is how a service ends up trusting an attacker's provider.
|
|
63
|
+
*
|
|
64
|
+
* A list is for a provider that names itself more than one way. Google publishes both
|
|
65
|
+
* `accounts.google.com` and `https://accounts.google.com`, and a service pinned to one
|
|
66
|
+
* rejects tokens carrying the other. Discovery uses the first.
|
|
67
|
+
*/
|
|
68
|
+
issuer: string | string[];
|
|
69
|
+
/**
|
|
70
|
+
* Who the token was minted for, checked against `aud`.
|
|
71
|
+
*
|
|
72
|
+
* Required unless `audience: false` says the service really does accept any. Skipping
|
|
73
|
+
* it lets a token issued for another client of the same provider be replayed here,
|
|
74
|
+
* which is a real and easy mistake.
|
|
75
|
+
*/
|
|
76
|
+
audience: string | string[] | false;
|
|
77
|
+
/**
|
|
78
|
+
* The client the token was issued to, checked against `azp`.
|
|
79
|
+
*
|
|
80
|
+
* Some providers put no `aud` on an access token at all and name the client elsewhere:
|
|
81
|
+
* `azp` for Keycloak until an audience mapper is configured, `client_id` for Cognito.
|
|
82
|
+
* Both are read, so a service in front of either wants `audience: false` with this set,
|
|
83
|
+
* rather than no check of who the token was for.
|
|
84
|
+
*
|
|
85
|
+
* Configuring an audience mapper at the provider and using `audience` is the better
|
|
86
|
+
* end state. This is for the common case where you do not control that.
|
|
87
|
+
*/
|
|
88
|
+
authorizedParty?: string | string[];
|
|
89
|
+
/**
|
|
90
|
+
* Signing algorithms this service accepts. Default `['RS256']`.
|
|
91
|
+
*
|
|
92
|
+
* Pinned, and never read from the token header. That is what stops a token switching
|
|
93
|
+
* to `HS256` and being signed with the public key, and what makes `alg: none`
|
|
94
|
+
* unrepresentable.
|
|
95
|
+
*/
|
|
96
|
+
algorithms?: string[];
|
|
97
|
+
/** The JWKS URL, when discovery should not be used to find it. */
|
|
98
|
+
jwksUri?: string;
|
|
99
|
+
/**
|
|
100
|
+
* The keys themselves, for a service that has them already.
|
|
101
|
+
*
|
|
102
|
+
* Pinned keys, an air-gapped deployment, or a test. Nothing is fetched when this is
|
|
103
|
+
* given, which also means nothing rotates: a provider that rolls its signing key stops
|
|
104
|
+
* being accepted until this is updated. Prefer `jwksUri` or discovery unless that
|
|
105
|
+
* tradeoff is the one you want.
|
|
106
|
+
*/
|
|
107
|
+
jwks?: {
|
|
108
|
+
keys: unknown[];
|
|
109
|
+
};
|
|
110
|
+
/** Allowance for clock skew between this service and the provider. Default 5s. */
|
|
111
|
+
clockToleranceSec?: number;
|
|
112
|
+
/** Extra checks once the token holds: scope, a tenant claim, whatever the app needs. */
|
|
113
|
+
require?: (claims: TokenClaims) => boolean | string;
|
|
114
|
+
/**
|
|
115
|
+
* Require the token to be bound to a key the caller proves it holds, per RFC 9449.
|
|
116
|
+
*
|
|
117
|
+
* `true` refuses a token with no `cnf.jkt`, and refuses a bound one whose accompanying
|
|
118
|
+
* proof does not match. Without this a bound token is accepted as though it were a
|
|
119
|
+
* bearer token, which is the state a client sending proofs to an unchecking service is
|
|
120
|
+
* already in: protected in appearance only.
|
|
121
|
+
*
|
|
122
|
+
* Needs the request, so use `fromRequest`. Verifying a bare string cannot check a proof.
|
|
123
|
+
*/
|
|
124
|
+
dpop?: boolean;
|
|
125
|
+
/** Seconds of clock skew allowed on a proof's `iat`. Default 10. */
|
|
126
|
+
dpopProofAgeSec?: number;
|
|
127
|
+
}
|
|
128
|
+
export interface TokenVerifier {
|
|
129
|
+
/** Verify a bare token. Throws unless everything holds. */
|
|
130
|
+
(token: string): Promise<TokenClaims>;
|
|
131
|
+
/** Verify the bearer token on a request. Throws when there is none. */
|
|
132
|
+
fromRequest(request: Request): Promise<TokenClaims>;
|
|
133
|
+
/** The bearer token on a request, or null. No verification. */
|
|
134
|
+
bearerOf(request: Request): string | null;
|
|
135
|
+
}
|
|
136
|
+
/** Thrown for a token this service will not accept. */
|
|
137
|
+
export declare class TokenError extends OAuthError {
|
|
138
|
+
constructor(message: string, code?: string);
|
|
139
|
+
}
|
|
140
|
+
export declare function createTokenVerifier(options: VerifierOptions): TokenVerifier;
|
|
141
|
+
/**
|
|
142
|
+
* For a token this service cannot read on its own.
|
|
143
|
+
*
|
|
144
|
+
* A provider may issue an opaque reference token rather than a JWT, and some are
|
|
145
|
+
* configured that way on purpose: the token carries nothing, so it leaks nothing, and the
|
|
146
|
+
* provider stays the only thing that knows what it means. Verifying one locally is not
|
|
147
|
+
* possible, so the service asks instead.
|
|
148
|
+
*
|
|
149
|
+
* The exchange is worth understanding before choosing it. Introspection is a network call
|
|
150
|
+
* on the path of every request, and it reaches the provider rather than a cached key. What
|
|
151
|
+
* you get for that is the property a signature cannot give: a revoked token stops working
|
|
152
|
+
* at once, because `active` is answered live.
|
|
153
|
+
*/
|
|
154
|
+
export interface IntrospectorOptions {
|
|
155
|
+
/**
|
|
156
|
+
* The issuer, used to find the endpoint and checked against the response.
|
|
157
|
+
*
|
|
158
|
+
* A list covers a provider that names itself more than one way. Discovery uses the
|
|
159
|
+
* first.
|
|
160
|
+
*/
|
|
161
|
+
issuer: string | string[];
|
|
162
|
+
/**
|
|
163
|
+
* This service's own credentials at the provider.
|
|
164
|
+
*
|
|
165
|
+
* The introspection endpoint is authenticated, and that is not incidental: an open one
|
|
166
|
+
* would let anyone test whether a stolen token is live. A service without credentials
|
|
167
|
+
* cannot use it.
|
|
168
|
+
*/
|
|
169
|
+
clientId: string;
|
|
170
|
+
/** Not needed with `private_key_jwt`. */
|
|
171
|
+
clientSecret?: string;
|
|
172
|
+
/** The endpoint, when discovery should not be used to find it. */
|
|
173
|
+
introspectionEndpoint?: string;
|
|
174
|
+
/** Checked against `aud` when the response carries one. `false` to skip. */
|
|
175
|
+
audience?: string | string[] | false;
|
|
176
|
+
/** Checked against `client_id` on the response, for a token issued to one client. */
|
|
177
|
+
authorizedParty?: string | string[];
|
|
178
|
+
/**
|
|
179
|
+
* How long an `active` answer may be reused, in milliseconds. Default 0, meaning never.
|
|
180
|
+
*
|
|
181
|
+
* Caching trades away the reason to introspect. A revoked token keeps working until the
|
|
182
|
+
* entry expires, so a value here should be small and deliberate. The default asks every
|
|
183
|
+
* time.
|
|
184
|
+
*/
|
|
185
|
+
cacheMs?: number;
|
|
186
|
+
/**
|
|
187
|
+
* How this service proves who it is to the introspection endpoint.
|
|
188
|
+
*
|
|
189
|
+
* `private_key_jwt` signs a short-lived assertion instead of sending a secret, which
|
|
190
|
+
* some providers require. It needs `privateKey`.
|
|
191
|
+
*/
|
|
192
|
+
clientAuthMethod?: ClientAuthMethodInput;
|
|
193
|
+
/** The signing key for `private_key_jwt`, as a JWK. */
|
|
194
|
+
privateKey?: Record<string, unknown>;
|
|
195
|
+
/** The `kid` to name in the assertion, when the provider holds several keys. */
|
|
196
|
+
keyId?: string;
|
|
197
|
+
/** Signing algorithm for the assertion. Default `RS256`. */
|
|
198
|
+
assertionAlgorithm?: string;
|
|
199
|
+
/** Extra checks once the token is active. */
|
|
200
|
+
require?: (claims: TokenClaims) => boolean | string;
|
|
201
|
+
}
|
|
202
|
+
export declare function createTokenIntrospector(options: IntrospectorOptions): TokenVerifier;
|
|
203
|
+
export { credentialsFromEnv, explainCredentials, type EnvCredentials, type EnvCredentialsOptions, } from './env-credentials.js';
|
|
204
|
+
export { createApiGuard, rolesFrom, type ApiGuard, type ApiGuardOptions, type Requirements, type Refusal, } from './api-guard.js';
|
|
205
|
+
//# sourceMappingURL=verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@fluixi/oauth2/verify`: check an access token at the API that receives it.
|
|
3
|
+
*
|
|
4
|
+
* This is the half that actually decides anything. A client holds a token and sends it;
|
|
5
|
+
* the service on the other end is the one that must not take it on trust. Signature,
|
|
6
|
+
* issuer, audience and expiry, on every request.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here is Fluixi-specific. It takes a `Request` or a bare string and returns
|
|
9
|
+
* claims, so an Express service, a worker, a Hono route or a plain Node handler can use
|
|
10
|
+
* it without adopting anything else.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const verify = createTokenVerifier({
|
|
14
|
+
* issuer: process.env.OIDC_ISSUER!,
|
|
15
|
+
* audience: 'adafri-admin',
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* const claims = await verify.fromRequest(request); // throws when it does not hold
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* ## What this does not do
|
|
22
|
+
*
|
|
23
|
+
* The cryptography is `jose`'s, deliberately. A hand-written verifier is how `alg: none`
|
|
24
|
+
* and RS256-to-HS256 confusion get shipped, and both turn a check into a way in. What
|
|
25
|
+
* this adds is the configuration around it: algorithms pinned rather than read from the
|
|
26
|
+
* token, an issuer and audience that must be stated, and the JWKS URL found by discovery
|
|
27
|
+
* so a service does not hardcode it.
|
|
28
|
+
*
|
|
29
|
+
* `jose` is an optional peer. An app that never verifies never installs it.
|
|
30
|
+
*
|
|
31
|
+
* ## Providers that omit `aud`
|
|
32
|
+
*
|
|
33
|
+
* Keycloak issues access tokens with no `aud` until an audience mapper is configured,
|
|
34
|
+
* naming the client in `azp` instead. A service in front of one wants:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* createTokenVerifier({ issuer, audience: false, authorizedParty: 'adafri-admin' });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Not `audience: false` on its own: that accepts a token minted for any client of the
|
|
41
|
+
* issuer, which is the check being skipped rather than moved.
|
|
42
|
+
*/
|
|
43
|
+
import { applyClientAuth, assertUsable, resolveMethod, } from './client-auth.js';
|
|
44
|
+
import { discover } from './discovery.js';
|
|
45
|
+
import { OAuthError } from './types.js';
|
|
46
|
+
/** Thrown for a token this service will not accept. */
|
|
47
|
+
export class TokenError extends OAuthError {
|
|
48
|
+
constructor(message, code = 'invalid_token') {
|
|
49
|
+
super(message, code);
|
|
50
|
+
this.name = 'TokenError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The bearer token on a request, or null. Case-insensitive, as the HTTP scheme is. */
|
|
54
|
+
function bearerOf(request) {
|
|
55
|
+
const header = request.headers.get('authorization');
|
|
56
|
+
if (!header)
|
|
57
|
+
return null;
|
|
58
|
+
// `DPoP` as well as `Bearer`: a token bound to a key is presented under that scheme, and
|
|
59
|
+
// reading only Bearer would refuse every bound token before looking at it.
|
|
60
|
+
const match = header.match(/^(?:Bearer|DPoP)\s+(.+)$/i);
|
|
61
|
+
return match ? match[1].trim() : null;
|
|
62
|
+
}
|
|
63
|
+
/** `jose`, loaded only when something actually verifies. */
|
|
64
|
+
async function loadJose() {
|
|
65
|
+
try {
|
|
66
|
+
return await import('jose');
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new OAuthError("Token verification needs `jose`. Install it: npm i jose. It is an optional peer so an app that does not verify does not carry it.", 'jose_missing');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function createTokenVerifier(options) {
|
|
73
|
+
const issuers = (Array.isArray(options.issuer) ? options.issuer : [options.issuer]).filter(Boolean);
|
|
74
|
+
if (!issuers.length) {
|
|
75
|
+
throw new Error('[fluixi/oauth2] createTokenVerifier needs an issuer to accept tokens from.');
|
|
76
|
+
}
|
|
77
|
+
// Discovery needs one URL. The rest are alternate spellings the same provider uses.
|
|
78
|
+
const discoveryIssuer = issuers[0];
|
|
79
|
+
if (options.audience === undefined) {
|
|
80
|
+
throw new Error('[fluixi/oauth2] createTokenVerifier needs an audience, or `audience: false` to say this service really does accept a token minted for any client of the issuer.');
|
|
81
|
+
}
|
|
82
|
+
if (options.audience === false && options.authorizedParty === undefined) {
|
|
83
|
+
// Not fatal, because a service may genuinely accept any client of its issuer. Worth
|
|
84
|
+
// saying out loud, because arriving here by forgetting is far more likely.
|
|
85
|
+
console.warn('[fluixi/oauth2] This verifier checks neither `aud` nor `azp`, so a token minted for any client of the issuer is accepted. Set `authorizedParty` if that is not what you meant.');
|
|
86
|
+
}
|
|
87
|
+
const algorithms = options.algorithms ?? ['RS256'];
|
|
88
|
+
if (algorithms.some((alg) => !alg || alg.toLowerCase() === 'none')) {
|
|
89
|
+
throw new Error('[fluixi/oauth2] `none` is not a signing algorithm and cannot be accepted.');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Built once and reused: `createRemoteJWKSet` caches the keys, refetches when a `kid`
|
|
93
|
+
* is unknown, and rate-limits that refetch. Building it per call would drop the cache
|
|
94
|
+
* and turn unknown key ids into a way to hammer the provider.
|
|
95
|
+
*/
|
|
96
|
+
let keys = null;
|
|
97
|
+
async function keySet() {
|
|
98
|
+
if (keys)
|
|
99
|
+
return keys;
|
|
100
|
+
const jose = await loadJose();
|
|
101
|
+
if (options.jwks) {
|
|
102
|
+
keys = jose.createLocalJWKSet(options.jwks);
|
|
103
|
+
return keys;
|
|
104
|
+
}
|
|
105
|
+
let uri = options.jwksUri;
|
|
106
|
+
if (!uri) {
|
|
107
|
+
const endpoints = await discover(discoveryIssuer);
|
|
108
|
+
uri = endpoints.jwks;
|
|
109
|
+
}
|
|
110
|
+
if (!uri) {
|
|
111
|
+
throw new OAuthError(`${discoveryIssuer} published no jwks_uri. Give the verifier one directly.`, 'no_jwks');
|
|
112
|
+
}
|
|
113
|
+
keys = jose.createRemoteJWKSet(new URL(uri));
|
|
114
|
+
return keys;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Check that a proof belongs to the token and to this request.
|
|
118
|
+
*
|
|
119
|
+
* The proof carries its own public key, so it verifies itself; what makes it meaningful
|
|
120
|
+
* is that the key's thumbprint matches the token's `cnf.jkt`. Anyone can sign a proof
|
|
121
|
+
* with a key they made up, and only the key the provider bound the token to counts.
|
|
122
|
+
*/
|
|
123
|
+
const checkProof = async (claims, token, request) => {
|
|
124
|
+
const jose = await loadJose();
|
|
125
|
+
const { boundKeyOf, thumbprintOf } = await import('./dpop.js');
|
|
126
|
+
const bound = boundKeyOf(claims);
|
|
127
|
+
if (!bound) {
|
|
128
|
+
throw new TokenError('This token is not bound to a key, and this service requires binding.', 'token_not_bound');
|
|
129
|
+
}
|
|
130
|
+
const proof = request.headers.get('dpop');
|
|
131
|
+
if (!proof)
|
|
132
|
+
throw new TokenError('The request carried no DPoP proof.', 'missing_dpop_proof');
|
|
133
|
+
let payload;
|
|
134
|
+
let header;
|
|
135
|
+
try {
|
|
136
|
+
// `embeddedJWK` verifies the proof against the key inside its own header, which is
|
|
137
|
+
// what makes the thumbprint comparison below the thing that matters.
|
|
138
|
+
const result = await jose.jwtVerify(proof, jose.EmbeddedJWK, { typ: 'dpop+jwt' });
|
|
139
|
+
payload = result.payload;
|
|
140
|
+
header = result.protectedHeader;
|
|
141
|
+
}
|
|
142
|
+
catch (cause) {
|
|
143
|
+
throw new TokenError(`The DPoP proof did not verify: ${cause instanceof Error ? cause.message : String(cause)}`, 'invalid_dpop_proof');
|
|
144
|
+
}
|
|
145
|
+
const presented = await thumbprintOf(header.jwk);
|
|
146
|
+
if (presented !== bound) {
|
|
147
|
+
throw new TokenError('The DPoP proof was signed with a different key from the one this token is bound to.', 'dpop_key_mismatch');
|
|
148
|
+
}
|
|
149
|
+
// Bound to this request, so a proof captured from one cannot be replayed against
|
|
150
|
+
// another endpoint or method.
|
|
151
|
+
const url = new URL(request.url);
|
|
152
|
+
url.search = '';
|
|
153
|
+
url.hash = '';
|
|
154
|
+
if (payload.htm !== request.method.toUpperCase()) {
|
|
155
|
+
throw new TokenError('The DPoP proof was made for a different method.', 'invalid_dpop_proof');
|
|
156
|
+
}
|
|
157
|
+
if (payload.htu !== url.toString()) {
|
|
158
|
+
throw new TokenError('The DPoP proof was made for a different URL.', 'invalid_dpop_proof');
|
|
159
|
+
}
|
|
160
|
+
// And to this token, so one captured beside a different token is useless.
|
|
161
|
+
if (typeof payload.ath === 'string') {
|
|
162
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
|
|
163
|
+
let binary = '';
|
|
164
|
+
for (const byte of new Uint8Array(digest))
|
|
165
|
+
binary += String.fromCharCode(byte);
|
|
166
|
+
const expected = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
167
|
+
if (payload.ath !== expected) {
|
|
168
|
+
throw new TokenError('The DPoP proof was made for a different token.', 'invalid_dpop_proof');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Fresh, so a captured proof is worth only the skew allowance.
|
|
172
|
+
const age = Math.abs(Date.now() / 1000 - payload.iat);
|
|
173
|
+
if (!Number.isFinite(age) || age > (options.dpopProofAgeSec ?? 10)) {
|
|
174
|
+
throw new TokenError('The DPoP proof is too old to accept.', 'invalid_dpop_proof');
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const verify = async (token, request) => {
|
|
178
|
+
if (!token)
|
|
179
|
+
throw new TokenError('No token to verify.', 'no_token');
|
|
180
|
+
const jose = await loadJose();
|
|
181
|
+
let payload;
|
|
182
|
+
try {
|
|
183
|
+
({ payload } = await jose.jwtVerify(token, await keySet(), {
|
|
184
|
+
// jose takes a list and accepts any of them, which is what a provider naming
|
|
185
|
+
// itself two ways needs.
|
|
186
|
+
issuer: issuers,
|
|
187
|
+
...(options.audience === false ? {} : { audience: options.audience }),
|
|
188
|
+
algorithms,
|
|
189
|
+
clockTolerance: options.clockToleranceSec ?? 5,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
catch (cause) {
|
|
193
|
+
// One message whatever failed. Saying which check a token missed tells whoever
|
|
194
|
+
// sent it how to get closer.
|
|
195
|
+
throw new TokenError(`The token was not accepted: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
196
|
+
}
|
|
197
|
+
// `azp` names the client a token was issued to. Checked here rather than left to
|
|
198
|
+
// jose, which knows only the registered `aud`.
|
|
199
|
+
if (options.authorizedParty !== undefined) {
|
|
200
|
+
const allowed = Array.isArray(options.authorizedParty)
|
|
201
|
+
? options.authorizedParty
|
|
202
|
+
: [options.authorizedParty];
|
|
203
|
+
// `azp` is the OIDC claim; `client_id` is what some providers put on an access
|
|
204
|
+
// token instead, Cognito among them, and reading only `azp` rejected all of them.
|
|
205
|
+
const issuedTo = (payload.azp ?? payload.client_id);
|
|
206
|
+
if (typeof issuedTo !== 'string' || !allowed.includes(issuedTo)) {
|
|
207
|
+
throw new TokenError('The token was issued to a different client.', 'invalid_azp');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (options.dpop) {
|
|
211
|
+
if (!request) {
|
|
212
|
+
throw new Error('[fluixi/oauth2] `dpop: true` needs the request to read its proof. Use `verify.fromRequest(request)` rather than verifying a bare token.');
|
|
213
|
+
}
|
|
214
|
+
await checkProof(payload, token, request);
|
|
215
|
+
}
|
|
216
|
+
if (options.require) {
|
|
217
|
+
const verdict = options.require(payload);
|
|
218
|
+
if (verdict !== true) {
|
|
219
|
+
throw new TokenError(typeof verdict === 'string' ? verdict : 'The token did not meet this service\'s requirements.', 'insufficient_claims');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return payload;
|
|
223
|
+
};
|
|
224
|
+
const verifier = verify;
|
|
225
|
+
verifier.bearerOf = bearerOf;
|
|
226
|
+
verifier.fromRequest = async (request) => {
|
|
227
|
+
const token = verifier.bearerOf(request);
|
|
228
|
+
if (!token) {
|
|
229
|
+
throw new TokenError('The request carried no bearer token.', 'no_token');
|
|
230
|
+
}
|
|
231
|
+
// The request goes through: a DPoP proof lives in its headers, and checking binding
|
|
232
|
+
// without it is not possible.
|
|
233
|
+
return verify(token, request);
|
|
234
|
+
};
|
|
235
|
+
return verifier;
|
|
236
|
+
}
|
|
237
|
+
export function createTokenIntrospector(options) {
|
|
238
|
+
const issuers = (Array.isArray(options.issuer) ? options.issuer : [options.issuer]).filter(Boolean);
|
|
239
|
+
if (!issuers.length) {
|
|
240
|
+
throw new Error('[fluixi/oauth2] createTokenIntrospector needs an issuer.');
|
|
241
|
+
}
|
|
242
|
+
const discoveryIssuer = issuers[0];
|
|
243
|
+
const auth = {
|
|
244
|
+
clientId: options.clientId,
|
|
245
|
+
method: options.clientAuthMethod,
|
|
246
|
+
clientSecret: options.clientSecret,
|
|
247
|
+
privateKey: options.privateKey,
|
|
248
|
+
keyId: options.keyId,
|
|
249
|
+
assertionAlgorithm: options.assertionAlgorithm,
|
|
250
|
+
};
|
|
251
|
+
// The introspection endpoint is authenticated, and an open one would let anyone test a
|
|
252
|
+
// stolen token, so a client with nothing to prove is refused rather than tried.
|
|
253
|
+
if (resolveMethod(auth) === 'none') {
|
|
254
|
+
throw new Error('[fluixi/oauth2] createTokenIntrospector needs credentials: a secret, or private_key_jwt with a key.');
|
|
255
|
+
}
|
|
256
|
+
assertUsable(auth, 'createTokenIntrospector');
|
|
257
|
+
if (options.audience === undefined) {
|
|
258
|
+
throw new Error('[fluixi/oauth2] createTokenIntrospector needs an audience, or `audience: false` to say this service accepts a token minted for any client of the issuer.');
|
|
259
|
+
}
|
|
260
|
+
const ttl = options.cacheMs ?? 0;
|
|
261
|
+
/** Only positive answers, and only while `cacheMs` says so. */
|
|
262
|
+
const cache = new Map();
|
|
263
|
+
let endpoint = options.introspectionEndpoint ?? null;
|
|
264
|
+
async function resolveEndpoint() {
|
|
265
|
+
if (endpoint)
|
|
266
|
+
return endpoint;
|
|
267
|
+
const endpoints = await discover(discoveryIssuer);
|
|
268
|
+
if (!endpoints.introspection) {
|
|
269
|
+
throw new OAuthError(`${discoveryIssuer} published no introspection_endpoint. Give the introspector one directly, or verify JWTs with createTokenVerifier.`, 'no_introspection');
|
|
270
|
+
}
|
|
271
|
+
endpoint = endpoints.introspection;
|
|
272
|
+
return endpoint;
|
|
273
|
+
}
|
|
274
|
+
const introspect = async (token) => {
|
|
275
|
+
if (!token)
|
|
276
|
+
throw new TokenError('No token to verify.', 'no_token');
|
|
277
|
+
const cached = cache.get(token);
|
|
278
|
+
if (cached && cached.until > Date.now())
|
|
279
|
+
return cached.claims;
|
|
280
|
+
const url = await resolveEndpoint();
|
|
281
|
+
const headers = {
|
|
282
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
283
|
+
accept: 'application/json',
|
|
284
|
+
};
|
|
285
|
+
// `token_type_hint` lets the provider look in the right table first. It is a hint and
|
|
286
|
+
// a provider is free to ignore it.
|
|
287
|
+
const form = new URLSearchParams({ token, token_type_hint: 'access_token' });
|
|
288
|
+
await applyClientAuth(auth, url, headers, form);
|
|
289
|
+
const response = await fetch(url, { method: 'POST', headers, body: form.toString() });
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
// A provider that refuses the service's own credentials is a configuration problem
|
|
292
|
+
// here, not a bad token, and saying so saves a long hunt. What to check depends on how
|
|
293
|
+
// this client authenticates: naming a secret to someone using a key sends them looking
|
|
294
|
+
// for a field their client does not have.
|
|
295
|
+
const method = resolveMethod(auth);
|
|
296
|
+
const check = method === 'private_key_jwt'
|
|
297
|
+
? `Check clientId, and that the provider holds the public half of this key${auth.keyId ? ` under kid ${auth.keyId}` : ''}. A key registered to a different client than clientId is refused this way.`
|
|
298
|
+
: 'Check clientId and clientSecret.';
|
|
299
|
+
// The provider's own words, when it gave any: a 401 with a reason beats a 401.
|
|
300
|
+
const said = await response.text().catch(() => '');
|
|
301
|
+
throw new OAuthError(`The introspection endpoint refused this service: ${response.status}. ${check}${said ? ` It said: ${said}` : ''}`, 'introspection_failed');
|
|
302
|
+
}
|
|
303
|
+
const body = (await response.json());
|
|
304
|
+
// The one normative answer: anything but `active: true` means no.
|
|
305
|
+
//
|
|
306
|
+
// A provider says only that much, so a genuine refusal and a misconfiguration look
|
|
307
|
+
// identical here. The hint names the usual cause: several providers, Keycloak among
|
|
308
|
+
// them, refuse to introspect a token whose audience does not include the asking
|
|
309
|
+
// client, and answer `active: false` rather than explaining.
|
|
310
|
+
if (body.active !== true) {
|
|
311
|
+
throw new TokenError('The provider reports this token is not active. If it was just issued, check that this client is in the token audience: some providers refuse to introspect a token minted for someone else and say only this.', 'inactive_token');
|
|
312
|
+
}
|
|
313
|
+
// The response carries these only sometimes, so they are checked when present rather
|
|
314
|
+
// than required. A provider that omits `iss` has already told us the token is live at
|
|
315
|
+
// the issuer we asked.
|
|
316
|
+
if (typeof body.iss === 'string' && !issuers.includes(body.iss)) {
|
|
317
|
+
throw new TokenError('The token was issued by a different issuer.', 'invalid_issuer');
|
|
318
|
+
}
|
|
319
|
+
if (options.audience !== false && body.aud !== undefined) {
|
|
320
|
+
const wanted = Array.isArray(options.audience) ? options.audience : [options.audience];
|
|
321
|
+
const got = Array.isArray(body.aud) ? body.aud : [body.aud];
|
|
322
|
+
if (!got.some((a) => wanted.includes(a))) {
|
|
323
|
+
throw new TokenError('The token was minted for a different audience.', 'invalid_audience');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (options.authorizedParty !== undefined) {
|
|
327
|
+
const allowed = Array.isArray(options.authorizedParty)
|
|
328
|
+
? options.authorizedParty
|
|
329
|
+
: [options.authorizedParty];
|
|
330
|
+
// `client_id` is what RFC 7662 names; `azp` is what an OIDC provider may add.
|
|
331
|
+
const issuedTo = (body.client_id ?? body.azp);
|
|
332
|
+
if (!issuedTo || !allowed.includes(issuedTo)) {
|
|
333
|
+
throw new TokenError('The token was issued to a different client.', 'invalid_azp');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (options.require) {
|
|
337
|
+
const verdict = options.require(body);
|
|
338
|
+
if (verdict !== true) {
|
|
339
|
+
throw new TokenError(typeof verdict === 'string' ? verdict : "The token did not meet this service's requirements.", 'insufficient_claims');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (ttl > 0) {
|
|
343
|
+
// Bounded, and never a refusal: caching one would keep a reinstated token out.
|
|
344
|
+
if (cache.size > 500) {
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
for (const [key, entry] of cache)
|
|
347
|
+
if (entry.until <= now)
|
|
348
|
+
cache.delete(key);
|
|
349
|
+
if (cache.size > 500)
|
|
350
|
+
cache.delete(cache.keys().next().value);
|
|
351
|
+
}
|
|
352
|
+
cache.set(token, { claims: body, until: Date.now() + ttl });
|
|
353
|
+
}
|
|
354
|
+
return body;
|
|
355
|
+
};
|
|
356
|
+
const verifier = introspect;
|
|
357
|
+
verifier.bearerOf = bearerOf;
|
|
358
|
+
verifier.fromRequest = async (request) => {
|
|
359
|
+
const token = bearerOf(request);
|
|
360
|
+
if (!token)
|
|
361
|
+
throw new TokenError('The request carried no bearer token.', 'no_token');
|
|
362
|
+
return introspect(token);
|
|
363
|
+
};
|
|
364
|
+
return verifier;
|
|
365
|
+
}
|
|
366
|
+
// Reading credentials out of the environment lives next door. Re-exported here because a
|
|
367
|
+
// caller configuring an introspector needs both and should not have to find two modules.
|
|
368
|
+
export { credentialsFromEnv, explainCredentials, } from './env-credentials.js';
|
|
369
|
+
// The guard that turns a verifier into a route wrapper, and the role shapes providers use.
|
|
370
|
+
// Next door because a service configuring a verifier almost always wants both.
|
|
371
|
+
export { createApiGuard, rolesFrom, } from './api-guard.js';
|