@ultimat3/auth 27.11.0 → 27.12.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 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.11.0",
3
+ "version": "27.12.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.11.0",
36
- "@ultimat3/db": "27.11.0",
37
- "@ultimat3/schema": "27.11.0"
35
+ "@ultimat3/core": "27.12.0",
36
+ "@ultimat3/db": "27.12.0",
37
+ "@ultimat3/schema": "27.12.0"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -175,6 +175,7 @@ export type {
175
175
  OAuthProvider,
176
176
  OAuthProviderId,
177
177
  PkcePair,
178
+ TokenEndpointAuth,
178
179
  } from './oauth';
179
180
  export { assertOAuthCallback, beginOAuth, pkcePair } from './oauth';
180
181
  export {
@@ -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
  }
@@ -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
- const clientSecret = env[clientSecretEnv]?.trim() ?? '';
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