@ultimat3/auth 27.11.0 → 27.13.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 +25 -0
- package/package.json +4 -4
- package/src/index.ts +1 -0
- package/src/oauth-discovery.ts +6 -1
- package/src/oauth-exchange.ts +31 -7
- package/src/oauth.ts +11 -0
package/README.md
CHANGED
|
@@ -253,6 +253,31 @@ to check an id token's signature against, and the token-endpoint TLS exemption b
|
|
|
253
253
|
caller declares, not one a provider inherits by omission. It refuses a document whose `issuer` is
|
|
254
254
|
not the one asked for, too (OIDC Discovery §4.3).
|
|
255
255
|
|
|
256
|
+
**Public clients** (`As of 2026-10-18`). `tokenEndpointAuth` on the provider — or on
|
|
257
|
+
`discoverOAuthProvider({ tokenEndpointAuth })`, or per exchange on `credentials` — says how the
|
|
258
|
+
client authenticates at the token endpoint:
|
|
259
|
+
|
|
260
|
+
| `tokenEndpointAuth` | Secret read from `clientSecretEnv` | Sent |
|
|
261
|
+
|---|---|---|
|
|
262
|
+
| absent / `'client_secret_post'` | required (`X_ENV_MISSING`) | `client_secret` in the form body — the behaviour before the option |
|
|
263
|
+
| `'client_secret_basic'` | required | `Authorization: Basic base64(urlencode(id):urlencode(secret))`; no `client_secret` in the body |
|
|
264
|
+
| `'none'` | not read; a leftover value is ignored | no secret at all — `client_id` and the PKCE `code_verifier` only |
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { discoverOAuthProvider, registerOAuthProvider } from '@ultimat3/auth';
|
|
268
|
+
|
|
269
|
+
registerOAuthProvider(
|
|
270
|
+
await discoverOAuthProvider({
|
|
271
|
+
id: 'equipo',
|
|
272
|
+
issuer: 'https://sso.example.test',
|
|
273
|
+
tokenEndpointAuth: 'none', // needs EQUIPO_CLIENT_ID only
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
PKCE stays mandatory for all three: for a public client it is the only thing binding the code to
|
|
279
|
+
the browser that started the flow.
|
|
280
|
+
|
|
256
281
|
**SAML is out of scope and will stay out of scope.** XML-DSig canonicalisation has no Bun native
|
|
257
282
|
and implementing it would mean a real dependency in the primitive vocabulary, which
|
|
258
283
|
[`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md) does not permit. The honest
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.13.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "27.
|
|
36
|
-
"@ultimat3/db": "27.
|
|
37
|
-
"@ultimat3/schema": "27.
|
|
35
|
+
"@ultimat3/core": "27.13.0",
|
|
36
|
+
"@ultimat3/db": "27.13.0",
|
|
37
|
+
"@ultimat3/schema": "27.13.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/index.ts
CHANGED
package/src/oauth-discovery.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
renderThrowable,
|
|
11
11
|
} from '@ultimat3/core';
|
|
12
12
|
import { isRecord } from './json';
|
|
13
|
-
import type { OAuthProvider } from './oauth';
|
|
13
|
+
import type { OAuthProvider, TokenEndpointAuth } from './oauth';
|
|
14
14
|
import { oauthExchangeFailed } from './oauth-errors';
|
|
15
15
|
import type { OAuthFetch } from './oauth-exchange';
|
|
16
16
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
@@ -36,6 +36,8 @@ export interface DiscoverOAuthProviderInput {
|
|
|
36
36
|
readonly scopes?: readonly string[] | undefined;
|
|
37
37
|
readonly clientIdEnv?: string | undefined;
|
|
38
38
|
readonly clientSecretEnv?: string | undefined;
|
|
39
|
+
/** `'none'` for a public client (no secret read, none sent). Absent: `client_secret_post`. */
|
|
40
|
+
readonly tokenEndpointAuth?: TokenEndpointAuth | undefined;
|
|
39
41
|
/** Injected in tests; production uses the global. */
|
|
40
42
|
readonly fetch?: OAuthFetch | undefined;
|
|
41
43
|
readonly timeoutMs?: number | undefined;
|
|
@@ -173,5 +175,8 @@ export async function discoverOAuthProvider(
|
|
|
173
175
|
usesNonce: true,
|
|
174
176
|
clientIdEnv: input.clientIdEnv ?? `${prefix}_CLIENT_ID`,
|
|
175
177
|
clientSecretEnv: input.clientSecretEnv ?? `${prefix}_CLIENT_SECRET`,
|
|
178
|
+
...(input.tokenEndpointAuth === undefined
|
|
179
|
+
? {}
|
|
180
|
+
: { tokenEndpointAuth: input.tokenEndpointAuth }),
|
|
176
181
|
};
|
|
177
182
|
}
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type OAuthHandshake,
|
|
22
22
|
type OAuthProvider,
|
|
23
23
|
type OAuthProviderId,
|
|
24
|
+
type TokenEndpointAuth,
|
|
24
25
|
} from './oauth';
|
|
25
26
|
import { oauthExchangeFailed, restartAt } from './oauth-errors';
|
|
26
27
|
import { providerFor } from './oauth-registry';
|
|
@@ -41,7 +42,10 @@ const MAX_DETAIL_LENGTH = 200;
|
|
|
41
42
|
|
|
42
43
|
export interface OAuthClientCredentials {
|
|
43
44
|
readonly clientId: string;
|
|
45
|
+
/** `''` for a public client (`tokenEndpointAuth: 'none'`), which sends none. */
|
|
44
46
|
readonly clientSecret: string;
|
|
47
|
+
/** Overrides the provider's `tokenEndpointAuth` for this exchange. Absent: the provider's. */
|
|
48
|
+
readonly tokenEndpointAuth?: TokenEndpointAuth | undefined;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
export interface OAuthTokens {
|
|
@@ -79,12 +83,14 @@ export function oauthCredentials(
|
|
|
79
83
|
provider: OAuthProviderId,
|
|
80
84
|
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
81
85
|
): OAuthClientCredentials {
|
|
82
|
-
const { clientIdEnv, clientSecretEnv } = providerFor(provider);
|
|
86
|
+
const { clientIdEnv, clientSecretEnv, tokenEndpointAuth } = providerFor(provider);
|
|
87
|
+
const isPublic = tokenEndpointAuth === 'none';
|
|
83
88
|
const clientId = env[clientIdEnv]?.trim() ?? '';
|
|
84
|
-
|
|
89
|
+
// A public client has no secret to read, and a leftover one in the env is never sent.
|
|
90
|
+
const clientSecret = isPublic ? '' : (env[clientSecretEnv]?.trim() ?? '');
|
|
85
91
|
const missing = [
|
|
86
92
|
...(clientId === '' ? [clientIdEnv] : []),
|
|
87
|
-
...(clientSecret === '' ? [clientSecretEnv] : []),
|
|
93
|
+
...(clientSecret === '' && !isPublic ? [clientSecretEnv] : []),
|
|
88
94
|
];
|
|
89
95
|
if (missing.length > 0) {
|
|
90
96
|
throw new EnvMissingError({
|
|
@@ -93,7 +99,7 @@ export function oauthCredentials(
|
|
|
93
99
|
meta: { provider, missing },
|
|
94
100
|
});
|
|
95
101
|
}
|
|
96
|
-
return { clientId, clientSecret };
|
|
102
|
+
return isPublic ? { clientId, clientSecret, tokenEndpointAuth } : { clientId, clientSecret };
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
/**
|
|
@@ -160,8 +166,11 @@ export async function providerDetail(
|
|
|
160
166
|
}
|
|
161
167
|
|
|
162
168
|
function fixForStatus(provider: OAuthProviderId, status: number): string {
|
|
163
|
-
const { clientIdEnv, clientSecretEnv } = providerFor(provider);
|
|
169
|
+
const { clientIdEnv, clientSecretEnv, tokenEndpointAuth } = providerFor(provider);
|
|
164
170
|
if (status === 401 || status === 403) {
|
|
171
|
+
if (tokenEndpointAuth === 'none') {
|
|
172
|
+
return `set ${clientIdEnv} to the current value in the ${provider} app settings, and confirm the client is registered as public (token_endpoint_auth_method: none)`;
|
|
173
|
+
}
|
|
165
174
|
return `set ${clientIdEnv} and ${clientSecretEnv} to the current values in the ${provider} app settings`;
|
|
166
175
|
}
|
|
167
176
|
if (status === 400) {
|
|
@@ -174,6 +183,7 @@ async function postForm(
|
|
|
174
183
|
provider: OAuthProviderId,
|
|
175
184
|
body: URLSearchParams,
|
|
176
185
|
options: OAuthExchangeOptions,
|
|
186
|
+
authorization?: string,
|
|
177
187
|
): Promise<Record<string, unknown>> {
|
|
178
188
|
const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
179
189
|
// Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
|
|
@@ -196,6 +206,7 @@ async function postForm(
|
|
|
196
206
|
Accept: 'application/json',
|
|
197
207
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
198
208
|
'User-Agent': OAUTH_USER_AGENT,
|
|
209
|
+
...(authorization === undefined ? {} : { Authorization: authorization }),
|
|
199
210
|
},
|
|
200
211
|
body: body.toString(),
|
|
201
212
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -311,13 +322,20 @@ export async function exchangeOAuthCode(
|
|
|
311
322
|
code: callback.code,
|
|
312
323
|
redirect_uri: handshake.redirectUri,
|
|
313
324
|
client_id: options.credentials.clientId,
|
|
314
|
-
client_secret: options.credentials.clientSecret,
|
|
315
325
|
});
|
|
326
|
+
const auth = options.credentials.tokenEndpointAuth ?? provider.tokenEndpointAuth;
|
|
327
|
+
// `client_secret_post` (the default) puts the secret in the body; `'none'` sends no secret at
|
|
328
|
+
// all, and `client_secret_basic` sends it in the header (RFC 6749 §2.3.1).
|
|
329
|
+
if (auth === undefined || auth === 'client_secret_post') {
|
|
330
|
+
body.set('client_secret', options.credentials.clientSecret);
|
|
331
|
+
}
|
|
332
|
+
const authorization =
|
|
333
|
+
auth === 'client_secret_basic' ? basicAuthorization(options.credentials) : undefined;
|
|
316
334
|
// Unconditional: PKCE is not provider-dependent, and `OAuthProvider.usesPkce` is the type-level
|
|
317
335
|
// statement of that — there is no configuration in which this line is skipped.
|
|
318
336
|
body.set('code_verifier', handshake.verifier);
|
|
319
337
|
|
|
320
|
-
const payload = await postForm(handshake.provider, body, options);
|
|
338
|
+
const payload = await postForm(handshake.provider, body, options, authorization);
|
|
321
339
|
const accessToken = payload['access_token'];
|
|
322
340
|
if (typeof accessToken !== 'string' || accessToken === '') {
|
|
323
341
|
throw oauthExchangeFailed({
|
|
@@ -365,3 +383,9 @@ export async function exchangeOAuthCode(
|
|
|
365
383
|
}),
|
|
366
384
|
};
|
|
367
385
|
}
|
|
386
|
+
|
|
387
|
+
/** RFC 6749 §2.3.1: each half form-encoded BEFORE the base64, so a `:` in either survives. */
|
|
388
|
+
function basicAuthorization(credentials: OAuthClientCredentials): string {
|
|
389
|
+
const encode = (value: string): string => new URLSearchParams({ v: value }).toString().slice(2);
|
|
390
|
+
return `Basic ${btoa(`${encode(credentials.clientId)}:${encode(credentials.clientSecret)}`)}`;
|
|
391
|
+
}
|
package/src/oauth.ts
CHANGED
|
@@ -43,8 +43,19 @@ export interface OAuthProvider {
|
|
|
43
43
|
readonly usesNonce: boolean;
|
|
44
44
|
readonly clientIdEnv: string;
|
|
45
45
|
readonly clientSecretEnv: string;
|
|
46
|
+
/**
|
|
47
|
+
* How this client authenticates at the token endpoint (OIDC Core 9). Absent:
|
|
48
|
+
* `'client_secret_post'`, the behaviour before the option existed. `'none'` is a PUBLIC client
|
|
49
|
+
* — no secret is read from `clientSecretEnv` and none is sent; PKCE, mandatory for every
|
|
50
|
+
* provider, is what binds the code to the browser. `'client_secret_basic'` sends the pair in
|
|
51
|
+
* `Authorization: Basic` instead of the body.
|
|
52
|
+
*/
|
|
53
|
+
readonly tokenEndpointAuth?: TokenEndpointAuth | undefined;
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
/** The three token-endpoint client authentication methods this package speaks. */
|
|
57
|
+
export type TokenEndpointAuth = 'none' | 'client_secret_post' | 'client_secret_basic';
|
|
58
|
+
|
|
48
59
|
/**
|
|
49
60
|
* Any registered provider's id — `string`, not a closed union, since 1.3.0. The union of three
|
|
50
61
|
* consumer IdPs made an enterprise OP unrepresentable at the type level, which is a constraint no
|