@ultimat3/auth 8.0.0 → 10.0.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/CLAUDE.md +87 -5
- package/README.md +35 -5
- package/package.json +4 -4
- package/src/auth.ts +49 -11
- package/src/errors.ts +24 -172
- package/src/id-token.ts +36 -2
- package/src/index.ts +21 -12
- package/src/jwks.ts +17 -5
- package/src/limiter-install.ts +90 -0
- package/src/memory-adapter.ts +19 -0
- package/src/oauth-cookie.ts +1 -1
- package/src/oauth-discovery.ts +1 -1
- package/src/oauth-errors.ts +178 -0
- package/src/oauth-exchange.ts +58 -10
- package/src/oauth-login.ts +17 -6
- package/src/oauth-profile.ts +1 -1
- package/src/oauth-registry.ts +1 -1
- package/src/oauth-route.ts +91 -16
- package/src/oauth.ts +1 -1
- package/src/rate-limit.ts +7 -0
- package/src/tables.ts +29 -2
- package/src/verify.ts +17 -3
package/CLAUDE.md
CHANGED
|
@@ -48,7 +48,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
48
48
|
`maxKeys` is **not** compared: it bounds one process' table, so a shared limiter has no opinion
|
|
49
49
|
on it. The point is that `Auth.rateLimit` is what an operator reads as "what this deployment
|
|
50
50
|
enforces", so an injected limiter may not quietly enforce something else. Nothing here reads the
|
|
51
|
-
environment to guess a replica count.
|
|
51
|
+
environment to guess a replica count.
|
|
52
52
|
- **`postgresAuthLimiter` is the shared limiter, and a row per FAILURE is what makes it correct**
|
|
53
53
|
(`As of 2026-08`). `assertAuthLimiterPolicy` refused a per-process limiter under
|
|
54
54
|
`scope: 'shared'` and there was nothing else to pass, so the declaration was unsatisfiable while
|
|
@@ -71,6 +71,34 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
71
71
|
declared structurally
|
|
72
72
|
even though this package already depends on `@ultimat3/db`: the connection is the HOST's, so the
|
|
73
73
|
limiter takes the pool the boot opened rather than opening a second one.
|
|
74
|
+
- **`configureAuthLimiters` is the HOST's install point and it takes a FACTORY, not a limiter**
|
|
75
|
+
(`As of 2026-08`). `defineAuth({ limiter })` is still the app's, and it still wins; what was
|
|
76
|
+
missing is that `postgresAuthLimiter` shipped with **nowhere a host could install it from**.
|
|
77
|
+
`defineAuth` is the APP's call and the app does not know which pool this process opened —
|
|
78
|
+
`@ultimat3/cli`'s `startServices` resolves that long before `loadApp` imports a single app
|
|
79
|
+
module — so a scaffolded app got a per-POD lockout while `x new` scaffolds `replicas: 2` and
|
|
80
|
+
`docker/helm` runs three: `maxAttempts × N` guesses per account, and a lockout one replica
|
|
81
|
+
established invisible to the rest.
|
|
82
|
+
|
|
83
|
+
A **factory** because the boot cannot know the app's numbers. `assertAuthLimiterPolicy` compares
|
|
84
|
+
what a limiter enforces against what the app declared, so a limiter built at boot on
|
|
85
|
+
`DEFAULT_AUTH_RATE_LIMIT` is `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned one. The
|
|
86
|
+
factory is called with the RESOLVED policy, once per bucket, so the two halves cannot disagree —
|
|
87
|
+
and the comparison still runs on what comes back, so a factory that ignores its argument is
|
|
88
|
+
refused exactly as an injected limiter is. Precedence: `config.limiter` → the installed factory →
|
|
89
|
+
`createAuthLimiter`. `installedAuthLimiter` is deliberately NOT in `src/index.ts`, for the reason
|
|
90
|
+
`registerJob` is not in `@ultimat3/jobs`': a second caller building limiters out of band is a
|
|
91
|
+
second answer to where failures are counted.
|
|
92
|
+
|
|
93
|
+
`purgeAuthLimits()` is the other half, and it exists because `PostgresAuthLimiter.purgeExpired()`
|
|
94
|
+
had **no caller anywhere** — every failure row and every dead lockout was kept forever. It sweeps
|
|
95
|
+
only the **widest** window among the limiters the factory built: they all write the same two
|
|
96
|
+
tables, so a sweep measured on a narrower window deletes failures a wider limiter is still
|
|
97
|
+
counting, which is a sprayer buying attempts back from the cleanup job. No `nowMs` argument —
|
|
98
|
+
a limiter built through the seam holds the clock its host handed it, and that is the clock every
|
|
99
|
+
`at_ms` in those tables was written from. `AuthLimiter.purgeExpired` is OPTIONAL so
|
|
100
|
+
`createAuthLimiter` can keep bounding itself; a limiter with no table declares nothing.
|
|
101
|
+
|
|
74
102
|
- **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
|
|
75
103
|
adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
|
|
76
104
|
`findUserByEmail` and `createUser`; `BuiltinAdapter` issues `where email = $1` against a plain
|
|
@@ -174,7 +202,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
174
202
|
provider, so it is unrepresentable rather than discouraged. An app that wants it wraps
|
|
175
203
|
`signInWithOAuth`.
|
|
176
204
|
- **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is
|
|
177
|
-
read by both `errors.ts` and `oauth-route.ts`, so a `fix:` line naming
|
|
205
|
+
read by both `oauth-errors.ts` and `oauth-route.ts`, so a `fix:` line naming
|
|
178
206
|
`GET /auth/oauth/<provider>` cannot outlive the route again — which is exactly what it did
|
|
179
207
|
through 1.2.0, when the library functions shipped with no route to mount them in. Every
|
|
180
208
|
"start over" fix is built from `restartAt(provider)`.
|
|
@@ -186,9 +214,60 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
186
214
|
implementation of the open-redirect check and a second copy is one that drifts.
|
|
187
215
|
- The handshake cookie is cleared on **every** callback outcome, success and failure alike:
|
|
188
216
|
the code it authorised is spent either way.
|
|
189
|
-
- Refresh is **not implemented
|
|
190
|
-
|
|
191
|
-
|
|
217
|
+
- Refresh is **not implemented**, and the framework therefore **stores no provider token** (`As of
|
|
218
|
+
2026-08-23`). `accountFor` (`oauth-login.ts`) writes `accessToken: null, refreshToken: null`:
|
|
219
|
+
nothing in this package ever read either column back — `oauth-profile.ts` uses the token from the
|
|
220
|
+
exchange, in flight — while `tables.ts`'s header promised "no column holds a plaintext secret".
|
|
221
|
+
Declared and never wired, the deletion this repo already ran for `jobs.driver`, except this one
|
|
222
|
+
turned a database dump into a set of usable third-party credentials. The COLUMNS and the
|
|
223
|
+
`AuthAccount` fields both stay: the type is the documented adapter seam, and an app that
|
|
224
|
+
deliberately stores tokens implements `linkAccount` itself. `expiresAt` is still written.
|
|
225
|
+
- **`defineAuth({ providers })` defaults to `[]`**, never the live registry (`As of 2026-08-23`,
|
|
226
|
+
BREAKING). It was `oauthProviderIds()`, so nothing was ever "left out" and the uniform-404 the
|
|
227
|
+
option exists for could not fire — while a registry any dependency writes into decided which
|
|
228
|
+
login endpoints the app served. Name what you enabled.
|
|
229
|
+
- **A provider with no credentials answers the SAME 404 an unknown one does.** Past `assertEnabled`,
|
|
230
|
+
`oauthCredentials` threw `X_ENV_MISSING` and the route published a 500 carrying the app's own env
|
|
231
|
+
var names, so `500 = registered here, 404 = not` re-opened the oracle one request later.
|
|
232
|
+
`credentialsFor` (`oauth-route.ts`) catches it on BOTH legs, logs the real cause under
|
|
233
|
+
`auth.oauth.credentials_missing`, and re-throws `oauthProviderUnknown`.
|
|
234
|
+
- **Nothing this package does not own reaches a published `cause:`.** Both legs of the OAuth flow
|
|
235
|
+
are anonymous by definition and `publicBody` serialises `cause`. Two paths carried an internal
|
|
236
|
+
message into it and both now log instead: an uncoded throw from an adapter (`uncoded()` in
|
|
237
|
+
`oauth-route.ts` → `auth.oauth.uncoded_failure`) and a rejecting `OAuthFetch`
|
|
238
|
+
(`postForm` → `auth.oauth.token_fetch_failed`). The `POST /token` leg also reads its response
|
|
239
|
+
body as `providerDetail(response, 'coded-only')` — that request carries `client_secret`, and an
|
|
240
|
+
echoing endpoint put 38 of 42 characters of it into a 502 body. `error`/`error_description` still
|
|
241
|
+
come through; userinfo, discovery and jwks keep the raw fallback, because their requests carry
|
|
242
|
+
no secret.
|
|
243
|
+
- **`x_users.mfa_secret` is a PLAINTEXT secret and `tables.ts` now says so, column by column.** A
|
|
244
|
+
TOTP seed is symmetric, so a digest cannot verify a code; encrypting it needs a key-management
|
|
245
|
+
seam this package does not have. **Deferred deliberately** — `mfa.ts`'s "a database dump is not a
|
|
246
|
+
permanent MFA bypass" is true of the recovery CODES and not of the seed.
|
|
247
|
+
- **`issueVerification`/`consumeVerification` normalise too** — the fourth identity door.
|
|
248
|
+
`putVerification` upserts on `(purpose, identifier)` and `adapter.ts` promises a new token
|
|
249
|
+
invalidates the previous one; that promise was per SPELLING, so N live reset tokens for one
|
|
250
|
+
address could be held at once by varying the case, and each is a password.
|
|
251
|
+
- **`providerJwks` memoises only the DEFAULT client.** The memo was keyed on the provider id alone
|
|
252
|
+
and silently discarded a later caller's options, so an app pinning a corporate egress proxy got
|
|
253
|
+
it only if it called first — the `jobs.driver` shape, with a network path as the substituted
|
|
254
|
+
value. A caller that supplies options gets its own client.
|
|
255
|
+
- **`verifyIdToken` checks `nbf` and `azp`.** `workload.ts` imports `ID_TOKEN_CLOCK_SKEW_MS` from
|
|
256
|
+
`id-token.ts` and then enforced a bound `id-token.ts` did not: an `nbf` ten years out verified.
|
|
257
|
+
`azp` is OIDC Core 3.1.3.7 step 5 — with more than one audience, `aud` naming this client says
|
|
258
|
+
only that the token MENTIONS it. Both only narrow what is accepted.
|
|
259
|
+
- **A success clears the ACCOUNT bucket and nothing else.** `recordSuccess(ipKey(ip))` used to run
|
|
260
|
+
on every login and deleted the whole address bucket, which made it inert against the attack it
|
|
261
|
+
exists for: a stuffing run never spends `maxAttempts` guesses on one account, so
|
|
262
|
+
`4 wrong + 1 login to an account the attacker owns, repeat` never locked. Measured: 5 guesses to
|
|
263
|
+
`X_ACCOUNT_LOCKED` without the reset, 160 and unlocked with it. The cost is a shared NAT
|
|
264
|
+
accumulating failures, which is what `windowMs` bounds and what `X_ACCOUNT_LOCKED`'s `fix:`
|
|
265
|
+
already names the manual escape for.
|
|
266
|
+
- **`MemoryAdapter.createUser` enforces the two UNIQUE constraints `BuiltinAdapter` leans on** —
|
|
267
|
+
`x_users.email` and `x_users.external_id`, `authUniqueViolation` (`X_AUTH_WRITE_FAILED`). It is
|
|
268
|
+
what `x new` scaffolds and what every test runs against, so the duplicate path was only ever
|
|
269
|
+
exercised against the permissive half of the seam: two `register()` calls at one address made two
|
|
270
|
+
rows, and the second was unreachable forever. `adapter-parity.test.ts` pins both halves.
|
|
192
271
|
- The new `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
|
|
193
272
|
`deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`). A required
|
|
194
273
|
member is a breaking change to every third-party adapter; the callers throw
|
|
@@ -283,6 +362,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
283
362
|
| `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
|
|
284
363
|
| `rate-limit.ts` | per-ip, per-account and per-org buckets, lockout, scope check, `loginFailed()` |
|
|
285
364
|
| `rate-limit-postgres.ts` | the SHARED limiter: two tables, a row per failure, over a structural `PgExecutor` |
|
|
365
|
+
| `limiter-install.ts` | the host's one install point for that limiter — the factory, what it built, and the purge over it |
|
|
286
366
|
| `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
|
|
287
367
|
| `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
|
|
288
368
|
| `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
|
|
@@ -300,6 +380,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
300
380
|
| `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
|
|
301
381
|
| `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
|
|
302
382
|
| `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
|
|
383
|
+
| `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
|
|
384
|
+
| `oauth-errors.ts` | the OAuth half of those factories, and `restartAt`. Split off at the 500-line ceiling; declares no code and registers nothing |
|
|
303
385
|
| `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
|
|
304
386
|
| `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it |
|
|
305
387
|
| `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ export const auth = defineAuth({
|
|
|
12
12
|
session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
|
|
13
13
|
password: { minLength: 12 },
|
|
14
14
|
mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
|
|
15
|
-
providers: ['github', 'google'],
|
|
15
|
+
providers: ['github', 'google'], // REQUIRED to serve any OAuth route — the default is []
|
|
16
16
|
link: 'verified-email', // the default; `'never'` is the only other value
|
|
17
17
|
});
|
|
18
18
|
|
|
@@ -152,8 +152,38 @@ defineAuth({
|
|
|
152
152
|
Both limiters share one table: the keys are prefixed (`account:`, `ip:`, `org:`) and every limit
|
|
153
153
|
travels as a statement parameter, so the tenant bucket's wider allowance cannot leak into the
|
|
154
154
|
account bucket's. It reports `maxKeys: undefined` — there is no in-process table to bound — and
|
|
155
|
-
neither table forgets on its own
|
|
156
|
-
|
|
155
|
+
neither table forgets on its own.
|
|
156
|
+
|
|
157
|
+
**An app does not have to write any of that, `As of 2026-08-22`.** The boot fills a seam and every
|
|
158
|
+
`defineAuth` in the process picks it up:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { configureAuthLimiters, type PgExecutor, postgresAuthLimiter } from '@ultimat3/auth';
|
|
162
|
+
import type { Clock } from '@ultimat3/core';
|
|
163
|
+
|
|
164
|
+
declare const bootExecutor: PgExecutor;
|
|
165
|
+
declare const bootClock: Clock;
|
|
166
|
+
|
|
167
|
+
// In the HOST, before the app's modules import.
|
|
168
|
+
configureAuthLimiters((policy) =>
|
|
169
|
+
postgresAuthLimiter({ executor: bootExecutor, clock: bootClock, policy }),
|
|
170
|
+
);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A **factory** and not a limiter, because the host runs before the app: `defineAuth` compares what a
|
|
174
|
+
limiter enforces against what the app declared, so a limiter built at boot on the framework
|
|
175
|
+
defaults would be `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned its numbers. The
|
|
176
|
+
factory is called once per bucket, with the resolved policy, so the two halves cannot disagree.
|
|
177
|
+
Precedence is `defineAuth({ limiter })` → the installed factory → `createAuthLimiter`, and
|
|
178
|
+
`resetAuthLimiters()` puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
|
|
179
|
+
on every boot, so a scaffolded app gets a fleet-wide lockout with nothing to remember.
|
|
180
|
+
|
|
181
|
+
Neither table forgets on its own, and `purgeAuthLimits()` is the framework's reader for that:
|
|
182
|
+
it drops failures past the window and lockouts that have expired, measured against the clock the
|
|
183
|
+
host handed the limiter, and it sweeps only the WIDEST window installed — a sweep on a narrower
|
|
184
|
+
one deletes failures another limiter is still counting, which hands a sprayer its attempts back.
|
|
185
|
+
`@ultimat3/jobs`' `purge()` job is what calls it hourly; `x dev` and every role container declare
|
|
186
|
+
that sweep at boot.
|
|
157
187
|
|
|
158
188
|
## Providers are a registry, not a union
|
|
159
189
|
|
|
@@ -193,7 +223,7 @@ registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'ht
|
|
|
193
223
|
| `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
|
|
194
224
|
| `hasOAuthProvider(id)` | whether the id is registered |
|
|
195
225
|
| `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal names |
|
|
196
|
-
| `oauthProviderIds()` | every registered id, live
|
|
226
|
+
| `oauthProviderIds()` | every registered id, live. **NOT** what `defineAuth({ providers })` defaults to — that is `[]`, so an app names what it enabled |
|
|
197
227
|
|
|
198
228
|
`discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
|
|
199
229
|
to check an id token's signature against, and the token-endpoint TLS exemption below is a thing a
|
|
@@ -614,7 +644,7 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
|
|
|
614
644
|
| `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
|
|
615
645
|
| `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
|
|
616
646
|
| `X_OAUTH_TOKEN_INVALID` | the id token failed its signature, issuer, audience or expiry check, or no key in the published set matched its `kid` |
|
|
617
|
-
| `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered,
|
|
647
|
+
| `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered, one `defineAuth({ providers })` did not enable, or one whose `*_CLIENT_ID`/`*_CLIENT_SECRET` are unset — all three answer 404 with the same body, because telling an anonymous caller which is which describes this deployment for free. The real reason is logged. The refusal lists only the three built-ins, never your registry |
|
|
618
648
|
| `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
|
|
619
649
|
| `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
|
|
620
650
|
| `X_PASSWORD_WEAK` | strength check rejected the password |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "10.0.0",
|
|
35
|
+
"@ultimat3/db": "10.0.0",
|
|
36
|
+
"@ultimat3/schema": "10.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/auth.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { t } from '@ultimat3/schema';
|
|
|
8
8
|
import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
|
|
9
9
|
import { normaliseEmail } from './email';
|
|
10
10
|
import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
|
|
11
|
+
import { installedAuthLimiter } from './limiter-install';
|
|
11
12
|
import type { OAuthProviderId } from './oauth';
|
|
12
|
-
import { oauthProviderIds } from './oauth-registry';
|
|
13
13
|
import {
|
|
14
14
|
checkPasswordStrength,
|
|
15
15
|
DEFAULT_PASSWORD_POLICY,
|
|
@@ -146,6 +146,11 @@ export interface AuthConfigInput {
|
|
|
146
146
|
*/
|
|
147
147
|
readonly orgLimiter?: AuthLimiter | undefined;
|
|
148
148
|
readonly mfa?: Partial<AuthMfaPolicy> | undefined;
|
|
149
|
+
/**
|
|
150
|
+
* The OAuth providers this app serves login routes for. Defaults to `[]` — an empty list is
|
|
151
|
+
* "no OAuth", and every `/auth/oauth/<id>` answers `X_OAUTH_PROVIDER_UNKNOWN`. Never the live
|
|
152
|
+
* registry, which a dependency can write into.
|
|
153
|
+
*/
|
|
149
154
|
readonly providers?: readonly OAuthProviderId[] | undefined;
|
|
150
155
|
/** Defaults to `'verified-email'` — both halves proven. See `OAuthLinkPolicy`. */
|
|
151
156
|
readonly link?: OAuthLinkPolicy | undefined;
|
|
@@ -171,16 +176,28 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
171
176
|
const session: SessionPolicy = { ...DEFAULT_SESSION_POLICY, ...config.session };
|
|
172
177
|
const password: PasswordPolicy = { ...DEFAULT_PASSWORD_POLICY, ...config.password };
|
|
173
178
|
const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, ...config.rateLimit };
|
|
174
|
-
|
|
179
|
+
// Three answers in precedence order, and the middle one is why the seam exists: what this call
|
|
180
|
+
// passed, then what the HOST installed (`configureAuthLimiters`, filled by the boot that owns
|
|
181
|
+
// the database connection), then one process' worth of state. Without the middle arm a
|
|
182
|
+
// scaffolded app had to remember to build a shared limiter itself, which is the opposite of
|
|
183
|
+
// what this framework promises — and `x new` scaffolds two replicas.
|
|
184
|
+
const limiter =
|
|
185
|
+
config.limiter ?? installedAuthLimiter(rateLimit) ?? createAuthLimiter(clock, rateLimit);
|
|
175
186
|
assertAuthLimiterPolicy(rateLimit, limiter);
|
|
176
187
|
// The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
|
|
177
188
|
// that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
|
|
178
189
|
// for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
|
|
179
|
-
// throughput ceiling and discloses nothing.
|
|
180
|
-
//
|
|
190
|
+
// throughput ceiling and discloses nothing. Only the LOCAL fallback is exempt, and exempting it
|
|
191
|
+
// is what buys that: `createAuthLimiter` always reports `'process'`, which is the one arm the
|
|
192
|
+
// scope check would refuse. A limiter somebody else supplied — injected by the app or built by
|
|
193
|
+
// the host's factory — is compared exactly as the general bucket's is, because a factory that
|
|
194
|
+
// ignores the policy it was handed otherwise enforces numbers the app never declared while
|
|
195
|
+
// `Auth.orgRateLimit` reports the app's. That asymmetry was the whole defect: the same factory
|
|
196
|
+
// is refused for one bucket and trusted for the other.
|
|
181
197
|
const orgLimits = orgRateLimit(rateLimit);
|
|
182
|
-
const
|
|
183
|
-
|
|
198
|
+
const suppliedOrgLimiter = config.orgLimiter ?? installedAuthLimiter(orgLimits);
|
|
199
|
+
const orgLimiter = suppliedOrgLimiter ?? createAuthLimiter(clock, orgLimits);
|
|
200
|
+
if (suppliedOrgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, suppliedOrgLimiter);
|
|
184
201
|
// Read through a widened local on purpose: the field's type is the literal `false`, so this
|
|
185
202
|
// branch is unreachable from TypeScript and reachable from every JS caller and every config
|
|
186
203
|
// parsed out of JSON — the same split `invariantColumns()` keeps its Proxy behind a compile
|
|
@@ -200,7 +217,17 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
200
217
|
orgRateLimit: orgLimiter.policy,
|
|
201
218
|
orgLimiter,
|
|
202
219
|
mfa,
|
|
203
|
-
|
|
220
|
+
// BREAKING (majors only): the default is `[]`, never the live registry.
|
|
221
|
+
//
|
|
222
|
+
// It was `oauthProviderIds()`, so `defineAuth({ providers })`'s own documented purpose — the
|
|
223
|
+
// uniform 404 for a provider this app "left out" — could never fire: nothing was ever left
|
|
224
|
+
// out. Worse, a registry any dependency writes into with `registerOAuthProvider` decided which
|
|
225
|
+
// login endpoints an app serves. A capability is DECLARED, never inherited.
|
|
226
|
+
//
|
|
227
|
+
// Migration is one line: `defineAuth({ providers: ['github', 'google'] })`, naming what the app
|
|
228
|
+
// actually enabled. Nothing else changes — an unnamed provider is the 404 it was always meant
|
|
229
|
+
// to be.
|
|
230
|
+
providers: config.providers ?? [],
|
|
204
231
|
link: config.link ?? 'verified-email',
|
|
205
232
|
});
|
|
206
233
|
}
|
|
@@ -273,10 +300,21 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
|
|
|
273
300
|
}
|
|
274
301
|
|
|
275
302
|
await auth.limiter.recordSuccess(account);
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// the tenant
|
|
303
|
+
// ONLY the account bucket is cleared. The ACCOUNT window belongs to one person, so a success
|
|
304
|
+
// proves the typos before it were theirs and clearing it is what stops a typo costing a lockout.
|
|
305
|
+
//
|
|
306
|
+
// Neither the IP nor the tenant bucket is cleared, and it is the same argument for both: they
|
|
307
|
+
// count traffic from a SHARED source, so a success is not evidence the failures beside it were
|
|
308
|
+
// benign. `recordSuccess(ipKey(ip))` used to run here and deleted the whole address bucket —
|
|
309
|
+
// which made it inert against the attack it exists for. A credential-stuffing run never spends
|
|
310
|
+
// `maxAttempts` guesses on one account, so the per-account bucket never fires; the address
|
|
311
|
+
// bucket is the only one that sees the pattern, and `4 wrong guesses + 1 login to an account
|
|
312
|
+
// the attacker owns, repeat` wiped it every fifth request. Measured: 5 guesses to
|
|
313
|
+
// `X_ACCOUNT_LOCKED` without the reset, 160 and never locked with it.
|
|
314
|
+
//
|
|
315
|
+
// The cost is a shared NAT accumulating failures from unrelated people, which is exactly what
|
|
316
|
+
// `windowMs` bounds — and `X_ACCOUNT_LOCKED`'s `fix:` already names `recordSuccess(<key>)` as
|
|
317
|
+
// the deliberate manual escape for that case.
|
|
280
318
|
|
|
281
319
|
// Parameters were raised since this hash was written: upgrade it now, while we hold the
|
|
282
320
|
// plaintext. This is the only moment it is possible without asking the user for anything.
|
package/src/errors.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
renderFixLiteral,
|
|
10
10
|
UltimateError,
|
|
11
11
|
} from '@ultimat3/core';
|
|
12
|
-
import { oauthStartPath } from './oauth-paths';
|
|
13
12
|
|
|
14
13
|
/** Codes this package declares and owns. `X_UNAUTHENTICATED` is auth's; http only borrows it. */
|
|
15
14
|
export const AUTH_OWNED_ERROR_CODES = [
|
|
@@ -95,11 +94,12 @@ export class AuthError extends UltimateError {
|
|
|
95
94
|
fix: string;
|
|
96
95
|
meta?: Readonly<Record<string, unknown>> | undefined;
|
|
97
96
|
}) {
|
|
97
|
+
// No `docs:`: `UltimateError` fills it from `describeErrorCode(code).docs`. The
|
|
98
|
+
// `https://ultimate.dev/errors/<code>` link this built until 9.x answered 404, host and all.
|
|
98
99
|
super({
|
|
99
100
|
code: init.code,
|
|
100
101
|
cause: init.cause,
|
|
101
102
|
fix: init.fix,
|
|
102
|
-
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
103
103
|
meta: init.meta,
|
|
104
104
|
});
|
|
105
105
|
}
|
|
@@ -216,176 +216,6 @@ export const mfaRequiredUnenforceable = (): AuthError =>
|
|
|
216
216
|
fix: 'drop required from defineAuth({ mfa }) and gate it in your own sign-in handler, which is where the enrolment route lives: if (user.mfaSecret === null) send them to enrolTotp(auth, { account: user.email }) instead of createSession(auth.sessions, ...)',
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
-
/**
|
|
220
|
-
* The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
|
|
221
|
-
* line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
|
|
222
|
-
* so every caller who followed it hit a 404. One declaration, read by the mount and by the fix,
|
|
223
|
-
* is what stops that recurring — `oauthLogin()` cannot move without moving this sentence.
|
|
224
|
-
*/
|
|
225
|
-
export const oauthStateInvalid = (provider: string, part: string): AuthError =>
|
|
226
|
-
new AuthError({
|
|
227
|
-
code: 'X_OAUTH_STATE_INVALID',
|
|
228
|
-
cause: `${provider} callback rejected: ${part}`,
|
|
229
|
-
fix: `${restartAt(provider)} — a callback URL is single-use`,
|
|
230
|
-
meta: { provider },
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
/** The one phrase every "start over" fix is built from, so none of them can name a dead route. */
|
|
234
|
-
export const restartAt = (provider: string): string =>
|
|
235
|
-
`restart the flow at GET ${oauthStartPath(provider)}`;
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* The provider came back with `error=` and no code — almost always the user pressing Cancel.
|
|
239
|
-
* A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
|
|
240
|
-
* misconfigured, and folding the single commonest non-success outcome of a login into the code
|
|
241
|
-
* that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
|
|
242
|
-
*/
|
|
243
|
-
export const oauthDenied = (
|
|
244
|
-
provider: string,
|
|
245
|
-
reason: string,
|
|
246
|
-
description: string | null,
|
|
247
|
-
): AuthError =>
|
|
248
|
-
new AuthError({
|
|
249
|
-
code: 'X_OAUTH_DENIED',
|
|
250
|
-
// `reason` and `description` are query parameters off the callback URL — whatever the browser
|
|
251
|
-
// was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
|
|
252
|
-
// string literals, so a forged `error_description` cannot forge a second log line or break the
|
|
253
|
-
// sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
|
|
254
|
-
cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
|
|
255
|
-
description === null ? '' : ` (${renderCauseValue(description)})`
|
|
256
|
-
}`,
|
|
257
|
-
fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
|
|
258
|
-
meta: { provider, reason },
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
|
|
263
|
-
* was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
|
|
264
|
-
* describes the app's configuration to an unauthenticated caller, and the fix is the same sentence
|
|
265
|
-
* either way.
|
|
266
|
-
*
|
|
267
|
-
* **`supported` is the CALLER's to scope, because the two callers have two audiences.**
|
|
268
|
-
* `oauth-route.ts` passes `BUILTIN_OAUTH_PROVIDER_IDS` — its reader is an anonymous stranger who
|
|
269
|
-
* typed a URL, and the three built-ins are a framework constant already in the public docs, while
|
|
270
|
-
* the live registry holds whatever internal OP this deployment registered. `providerFor()` passes
|
|
271
|
-
* `oauthProviderIds()` — its reader is a developer holding a stack trace, and there the full list
|
|
272
|
-
* is exactly what makes the fix runnable. Neither ever passes `defineAuth({ providers })`: naming
|
|
273
|
-
* what this deployment turned on is the disclosure the shared refusal exists to prevent.
|
|
274
|
-
*
|
|
275
|
-
* The fix names `registerOAuthProvider` first so it stays executable for the branch the narrowed
|
|
276
|
-
* list cannot cover — a segment nothing registered cannot be added to `providers` at all, so
|
|
277
|
-
* "add it" alone was an instruction that could not be followed.
|
|
278
|
-
*
|
|
279
|
-
* The segment itself is a URL path the caller typed, so it goes through `renderCauseValue` in the
|
|
280
|
-
* sentence and `renderFixLiteral` in the command — a fix has to parse after a hostile value lands
|
|
281
|
-
* in it.
|
|
282
|
-
*/
|
|
283
|
-
export const oauthProviderUnknown = (provider: string, supported: readonly string[]): AuthError =>
|
|
284
|
-
new AuthError({
|
|
285
|
-
code: 'X_OAUTH_PROVIDER_UNKNOWN',
|
|
286
|
-
cause: `no oauth provider is mounted at ${renderCauseValue(oauthStartPath(provider))}`,
|
|
287
|
-
fix: `registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) if it is not built in, then add that id to defineAuth({ providers: [...] }) — known here: ${supported.map((id) => `'${id}'`).join(', ')}`,
|
|
288
|
-
meta: { provider },
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
|
|
293
|
-
* module imported second decide where every login for that id goes — including which `issuers`
|
|
294
|
-
* an id token may claim — so the second registration refuses at boot instead.
|
|
295
|
-
*/
|
|
296
|
-
export const oauthProviderDuplicate = (provider: string): AuthError =>
|
|
297
|
-
new AuthError({
|
|
298
|
-
code: 'X_OAUTH_PROVIDER_DUPLICATE',
|
|
299
|
-
cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
|
|
300
|
-
fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
|
|
301
|
-
meta: { provider },
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
export interface OAuthExchangeFailure {
|
|
305
|
-
readonly provider: string;
|
|
306
|
-
/**
|
|
307
|
-
* Which leg of the server-to-server conversation failed. `discovery` and `jwks` are the two
|
|
308
|
-
* boot/verification legs an enterprise OP adds: reading `/.well-known/openid-configuration`,
|
|
309
|
-
* and reading the key set an id token's signature is checked against.
|
|
310
|
-
*/
|
|
311
|
-
readonly stage: 'token' | 'userinfo' | 'discovery' | 'jwks';
|
|
312
|
-
readonly detail: string;
|
|
313
|
-
readonly status?: number | undefined;
|
|
314
|
-
readonly fix: string;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Deliberately specific, unlike every credential error above it. This one describes a
|
|
319
|
-
* conversation between two servers — naming the stage, the provider and its own status
|
|
320
|
-
* discloses nothing about any user, and is the difference between a fixable misconfiguration
|
|
321
|
-
* and a shrug.
|
|
322
|
-
*/
|
|
323
|
-
export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
|
|
324
|
-
new AuthError({
|
|
325
|
-
code: 'X_OAUTH_EXCHANGE_FAILED',
|
|
326
|
-
cause:
|
|
327
|
-
`${failure.provider} ${failure.stage} request failed` +
|
|
328
|
-
`${failure.status === undefined ? '' : ` with HTTP ${failure.status}`}: ${failure.detail}`,
|
|
329
|
-
fix: failure.fix,
|
|
330
|
-
meta: {
|
|
331
|
-
provider: failure.provider,
|
|
332
|
-
stage: failure.stage,
|
|
333
|
-
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* The address is proven to the provider, and an account that never proved it already holds it.
|
|
339
|
-
* Naming that is not account enumeration — this caller just demonstrated they own the address —
|
|
340
|
-
* and staying silent would leave them with a login that fails forever and no way out.
|
|
341
|
-
*
|
|
342
|
-
* The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
|
|
343
|
-
* key, and cannot redact an address that was already interpolated into a sentence.
|
|
344
|
-
*/
|
|
345
|
-
export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
|
|
346
|
-
new AuthError({
|
|
347
|
-
code: 'X_UNAUTHENTICATED',
|
|
348
|
-
cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
|
|
349
|
-
fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
|
|
350
|
-
meta: { provider, email },
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* `link: 'never'` and a local account already holds the address. Same code and same disclosure
|
|
355
|
-
* rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
|
|
356
|
-
* so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
|
|
357
|
-
*/
|
|
358
|
-
export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
|
|
359
|
-
new AuthError({
|
|
360
|
-
code: 'X_UNAUTHENTICATED',
|
|
361
|
-
cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
|
|
362
|
-
fix: "sign in with that account's own credentials, or set link: 'verified-email' in defineAuth to let a provider-verified address claim a locally-verified account",
|
|
363
|
-
meta: { provider, email },
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
|
|
368
|
-
* write. Falling back to the unstamped row would mint a session for a user every later login
|
|
369
|
-
* reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
|
|
370
|
-
* flow fails closed on an adapter that loses the stamp instead of half-succeeding.
|
|
371
|
-
*/
|
|
372
|
-
export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
|
|
373
|
-
new AuthError({
|
|
374
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
375
|
-
cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
|
|
376
|
-
fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
|
|
377
|
-
meta: { provider, userId },
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
/** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
|
|
381
|
-
export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
|
|
382
|
-
new AuthError({
|
|
383
|
-
code: 'X_OAUTH_TOKEN_INVALID',
|
|
384
|
-
cause: `${provider} id token rejected: ${reason}`,
|
|
385
|
-
fix,
|
|
386
|
-
meta: { provider },
|
|
387
|
-
});
|
|
388
|
-
|
|
389
219
|
export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
390
220
|
new AuthError({
|
|
391
221
|
code: 'X_PASSWORD_WEAK',
|
|
@@ -411,6 +241,9 @@ export const accountLocked = (key: string, retryAfterSeconds: number): AuthError
|
|
|
411
241
|
// escaping, not throw-safety.
|
|
412
242
|
cause: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
|
|
413
243
|
fix: `wait ${retryAfterSeconds}s — or clear this one bucket: auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}), auth.orgLimiter for an org: key — or raise defineAuth({ rateLimit })`,
|
|
244
|
+
// `kdfOverloaded`'s shape: `@ultimat3/http`'s `retryAfterOf` reads exactly this field. `key`
|
|
245
|
+
// stays out — `cause`/`fix` escape it on purpose and `meta` is read by surfaces that do not.
|
|
246
|
+
meta: { retryAfterSeconds },
|
|
414
247
|
});
|
|
415
248
|
|
|
416
249
|
/** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
|
|
@@ -436,6 +269,25 @@ export const authWriteFailed = (operation: string, table: string): AuthError =>
|
|
|
436
269
|
meta: { operation, table },
|
|
437
270
|
});
|
|
438
271
|
|
|
272
|
+
/**
|
|
273
|
+
* A write the table's own UNIQUE constraint refuses. Same code as the write above, because it is
|
|
274
|
+
* the same failure to an operator — the row is not there and the caller must not assume it is —
|
|
275
|
+
* and the cause says which of the two happened.
|
|
276
|
+
*
|
|
277
|
+
* `MemoryAdapter` enforced neither `x_users.email` nor `x_users.external_id`, while `BuiltinAdapter`
|
|
278
|
+
* leans on both: two `register()` calls at one address made TWO rows in memory, and the second was
|
|
279
|
+
* unreachable forever because `findUserByEmail` returns the first. That adapter is what `x new`
|
|
280
|
+
* scaffolds and what every test runs against, so the duplicate path was only exercised against the
|
|
281
|
+
* permissive half of the seam.
|
|
282
|
+
*/
|
|
283
|
+
export const authUniqueViolation = (operation: string, table: string, column: string): AuthError =>
|
|
284
|
+
new AuthError({
|
|
285
|
+
code: 'X_AUTH_WRITE_FAILED',
|
|
286
|
+
cause: `${operation} would add a second ${table} row with the same ${column}, which that column's unique constraint refuses`,
|
|
287
|
+
fix: `look the row up first and update it — findUserByEmail(normaliseEmail(email)) — or write a different ${column}`,
|
|
288
|
+
meta: { operation, table, column },
|
|
289
|
+
});
|
|
290
|
+
|
|
439
291
|
/**
|
|
440
292
|
* At `defineAuth`, never at a login. `replicas: 3` behind one policy means each process counts
|
|
441
293
|
* failures on its own, so the account survives `maxAttempts × 3` guesses and a lockout established
|
package/src/id-token.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Clock } from '@ultimat3/core';
|
|
9
9
|
import { renderCauseValue } from '@ultimat3/core';
|
|
10
|
-
import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './errors';
|
|
11
10
|
import { decodeJwtSegment } from './json';
|
|
12
11
|
import { type IdTokenKeys, verifyJwtSignature } from './jwks';
|
|
13
12
|
import type { OAuthProvider, OAuthProviderId } from './oauth';
|
|
13
|
+
import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './oauth-errors';
|
|
14
14
|
import { providerFor } from './oauth-registry';
|
|
15
15
|
import { timingSafeEqual } from './tokens';
|
|
16
16
|
|
|
@@ -21,6 +21,13 @@ export interface IdTokenClaims {
|
|
|
21
21
|
readonly sub: string;
|
|
22
22
|
readonly exp: number;
|
|
23
23
|
readonly iat?: number | undefined;
|
|
24
|
+
/** Not-before. Checked with the same skew `workload.ts` allows, from the same constant. */
|
|
25
|
+
readonly nbf?: number | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Authorised party — the client this token was minted FOR. OIDC Core 3.1.3.7 step 5: with more
|
|
28
|
+
* than one audience, `aud` naming this client is not evidence the token is addressed to it.
|
|
29
|
+
*/
|
|
30
|
+
readonly azp?: string | undefined;
|
|
24
31
|
readonly nonce?: string | undefined;
|
|
25
32
|
readonly email?: string | undefined;
|
|
26
33
|
/** Google sends a boolean, Apple a `"true"` string. Both mean the same thing. */
|
|
@@ -75,6 +82,8 @@ export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTok
|
|
|
75
82
|
}
|
|
76
83
|
// `exactOptionalPropertyTypes`: an absent claim must be absent, not present-and-undefined.
|
|
77
84
|
const iat = payload['iat'];
|
|
85
|
+
const nbf = payload['nbf'];
|
|
86
|
+
const azp = stringOrUndefined(payload['azp']);
|
|
78
87
|
const verified = payload['email_verified'];
|
|
79
88
|
const nonce = stringOrUndefined(payload['nonce']);
|
|
80
89
|
const email = stringOrUndefined(payload['email']);
|
|
@@ -85,6 +94,8 @@ export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTok
|
|
|
85
94
|
sub,
|
|
86
95
|
exp,
|
|
87
96
|
...(typeof iat === 'number' ? { iat } : {}),
|
|
97
|
+
...(typeof nbf === 'number' ? { nbf } : {}),
|
|
98
|
+
...(azp === undefined ? {} : { azp }),
|
|
88
99
|
...(nonce === undefined ? {} : { nonce }),
|
|
89
100
|
...(email === undefined ? {} : { email }),
|
|
90
101
|
...(typeof verified === 'boolean' || typeof verified === 'string'
|
|
@@ -167,7 +178,19 @@ export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenC
|
|
|
167
178
|
);
|
|
168
179
|
}
|
|
169
180
|
|
|
170
|
-
|
|
181
|
+
// OIDC Core 3.1.3.7 steps 4-5. With more than one audience, `aud` naming this client says only
|
|
182
|
+
// that the token MENTIONS it — the authorised party is what says it was minted for it. Without
|
|
183
|
+
// this, a token an OP issued for another client that also lists ours verified here.
|
|
184
|
+
if (audience.length > 1 && claims.azp !== input.clientId) {
|
|
185
|
+
throw oauthTokenInvalid(
|
|
186
|
+
provider.id,
|
|
187
|
+
'the token names several audiences and its azp is not this client',
|
|
188
|
+
`ask ${provider.id} for a token whose azp is ${provider.clientIdEnv}'s client id, or one with a single aud`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const nowMs = input.clock.now().getTime();
|
|
193
|
+
if (claims.exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= nowMs) {
|
|
171
194
|
throw oauthTokenInvalid(
|
|
172
195
|
provider.id,
|
|
173
196
|
'the token is already expired',
|
|
@@ -175,6 +198,17 @@ export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenC
|
|
|
175
198
|
);
|
|
176
199
|
}
|
|
177
200
|
|
|
201
|
+
// The bound `workload.ts` already checks, with the same skew, from THIS FILE's constant — it
|
|
202
|
+
// imports `ID_TOKEN_CLOCK_SKEW_MS` from here and then enforced a limit here did not. An `nbf`
|
|
203
|
+
// ten years out verified.
|
|
204
|
+
if (claims.nbf !== undefined && claims.nbf * 1000 - ID_TOKEN_CLOCK_SKEW_MS > nowMs) {
|
|
205
|
+
throw oauthTokenInvalid(
|
|
206
|
+
provider.id,
|
|
207
|
+
'the token is not valid yet',
|
|
208
|
+
`sync this host's clock (\`timedatectl status\`), then ${restartAt(provider.id)}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
178
212
|
if (provider.usesNonce && !timingSafeEqual(input.nonce, claims.nonce ?? '')) {
|
|
179
213
|
throw oauthStateInvalid(provider.id, 'the id token nonce did not match the stored handshake');
|
|
180
214
|
}
|