@fonderie/auth 7.2.0 → 7.3.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/brain/outcomes.md +9 -2
- package/dist/index.cjs +85 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +86 -10
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +2 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.js +2 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/016_consumed_tokens.sql +13 -0
- package/package.json +1 -1
package/brain/outcomes.md
CHANGED
|
@@ -9,6 +9,13 @@ downloading tarballs.
|
|
|
9
9
|
|
|
10
10
|
## Database tables (after all migrations)
|
|
11
11
|
|
|
12
|
+
### `fonderie_consumed_tokens`
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
token_hash TEXT PRIMARY KEY
|
|
16
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
17
|
+
```
|
|
18
|
+
|
|
12
19
|
### `fonderie_email_verifications`
|
|
13
20
|
|
|
14
21
|
```sql
|
|
@@ -130,12 +137,12 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
|
|
|
130
137
|
| Method | Path | Middleware chain (auth / validation / handler) |
|
|
131
138
|
|---|---|---|
|
|
132
139
|
| GET | `/auth/apple` | `oauth.appleInit` |
|
|
133
|
-
| POST | `/auth/apple/callback` | `oauth.appleCallback` |
|
|
140
|
+
| POST | `/auth/apple/callback` | `ipLimit('login') → oauth.appleCallback` |
|
|
134
141
|
| POST | `/auth/apple/native` | `ipLimit('login') → validate(appleNativeSchema) → oauth.appleNative` |
|
|
135
142
|
| POST | `/auth/email/forgot` | `ipLimit('forgot') → validate(forgotPasswordSchema) → acctLimit('forgot') → auth.forgotPassword` |
|
|
136
143
|
| POST | `/auth/email/reset` | `ipLimit('reset') → validate(resetPasswordSchema) → auth.resetPassword` |
|
|
137
144
|
| GET | `/auth/google` | `oauth.googleInit` |
|
|
138
|
-
| GET | `/auth/google/callback` | `oauth.googleCallback` |
|
|
145
|
+
| GET | `/auth/google/callback` | `ipLimit('login') → oauth.googleCallback` |
|
|
139
146
|
| POST | `/auth/login` | `ipLimit('login') → validate(loginSchema) → acctLimit('login') → auth.login` |
|
|
140
147
|
| GET | `/auth/login-history` | `requireAuth → user.loginHistory` |
|
|
141
148
|
| POST | `/auth/logout` | `requireAuth → validate(refreshSchema) → auth.logout` |
|
package/dist/index.cjs
CHANGED
|
@@ -736,7 +736,8 @@ var UserModel = class {
|
|
|
736
736
|
`INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
|
|
737
737
|
VALUES ($1, now(), $2, $3)
|
|
738
738
|
ON CONFLICT (email) DO UPDATE
|
|
739
|
-
SET provider = $2, provider_id = $3
|
|
739
|
+
SET provider = $2, provider_id = $3,
|
|
740
|
+
email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
|
|
740
741
|
RETURNING id`,
|
|
741
742
|
[email2, provider, providerId]
|
|
742
743
|
);
|
|
@@ -2304,6 +2305,34 @@ function userController(store, config, bus) {
|
|
|
2304
2305
|
var import_node_crypto7 = require("crypto");
|
|
2305
2306
|
var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
|
|
2306
2307
|
var import_core8 = require("@fonderie/core");
|
|
2308
|
+
|
|
2309
|
+
// src/models/consumed-token.model.ts
|
|
2310
|
+
var ConsumedTokenModel = class {
|
|
2311
|
+
constructor(store) {
|
|
2312
|
+
this.store = store;
|
|
2313
|
+
}
|
|
2314
|
+
store;
|
|
2315
|
+
// True if the token had not been consumed before (caller may proceed); false
|
|
2316
|
+
// if this is a replay. `expiresAt` is the token's own exp — its TTL here.
|
|
2317
|
+
async consumeOnce(tokenHash, expiresAt) {
|
|
2318
|
+
const rows = await this.store.query(
|
|
2319
|
+
`INSERT INTO fonderie_consumed_tokens (token_hash, expires_at)
|
|
2320
|
+
VALUES ($1, $2)
|
|
2321
|
+
ON CONFLICT (token_hash) DO NOTHING
|
|
2322
|
+
RETURNING token_hash`,
|
|
2323
|
+
[tokenHash, expiresAt]
|
|
2324
|
+
);
|
|
2325
|
+
return rows.length > 0;
|
|
2326
|
+
}
|
|
2327
|
+
// Opportunistic, fire-and-forget cleanup of expired rows (index-backed).
|
|
2328
|
+
// Recording history must never block or fail a login.
|
|
2329
|
+
sweepExpiredSafe() {
|
|
2330
|
+
void this.store.query(`DELETE FROM fonderie_consumed_tokens WHERE expires_at < now()`).catch(() => {
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
};
|
|
2334
|
+
|
|
2335
|
+
// src/controllers/oauth.controller.ts
|
|
2307
2336
|
var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
|
|
2308
2337
|
var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
|
|
2309
2338
|
var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
|
|
@@ -2317,18 +2346,34 @@ function mintAppleClientSecret(apple) {
|
|
|
2317
2346
|
);
|
|
2318
2347
|
}
|
|
2319
2348
|
var appleKeyCache = null;
|
|
2349
|
+
var appleKeyInflight = null;
|
|
2350
|
+
var appleKeyLastAttempt = 0;
|
|
2320
2351
|
var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2352
|
+
var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
|
|
2353
|
+
var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
|
|
2354
|
+
function refreshAppleKeys() {
|
|
2355
|
+
if (appleKeyInflight) return appleKeyInflight;
|
|
2356
|
+
appleKeyLastAttempt = Date.now();
|
|
2357
|
+
appleKeyInflight = (async () => {
|
|
2324
2358
|
try {
|
|
2325
|
-
const res = await fetch(APPLE_KEYS_URL);
|
|
2359
|
+
const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
|
|
2326
2360
|
const data = await res.json();
|
|
2327
2361
|
if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
|
|
2328
2362
|
} catch {
|
|
2363
|
+
} finally {
|
|
2364
|
+
appleKeyInflight = null;
|
|
2329
2365
|
}
|
|
2366
|
+
})();
|
|
2367
|
+
return appleKeyInflight;
|
|
2368
|
+
}
|
|
2369
|
+
async function getApplePublicKey(kid) {
|
|
2370
|
+
const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
|
|
2371
|
+
const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
|
|
2372
|
+
let jwk = find();
|
|
2373
|
+
if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
|
|
2374
|
+
await refreshAppleKeys();
|
|
2375
|
+
jwk = find();
|
|
2330
2376
|
}
|
|
2331
|
-
const jwk = appleKeyCache?.keys.find((k) => k.kid === kid);
|
|
2332
2377
|
if (!jwk) return null;
|
|
2333
2378
|
try {
|
|
2334
2379
|
return (0, import_node_crypto7.createPublicKey)({ key: jwk, format: "jwk" });
|
|
@@ -2364,6 +2409,7 @@ function oauthController(store, config) {
|
|
|
2364
2409
|
const users = new UserModel(store);
|
|
2365
2410
|
const sessions = new SessionModel(store);
|
|
2366
2411
|
const loginEvents = new LoginEventModel(store);
|
|
2412
|
+
const consumedTokens = new ConsumedTokenModel(store);
|
|
2367
2413
|
const completeAppleLogin = async (ctx, claims) => {
|
|
2368
2414
|
const meta = requestMeta(ctx);
|
|
2369
2415
|
if (!claims.email) {
|
|
@@ -2673,6 +2719,26 @@ function oauthController(store, config) {
|
|
|
2673
2719
|
});
|
|
2674
2720
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
2675
2721
|
}
|
|
2722
|
+
const tokenHash = (0, import_node_crypto7.createHash)("sha256").update(identityToken).digest("hex");
|
|
2723
|
+
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
2724
|
+
const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
|
|
2725
|
+
if (!firstUse) {
|
|
2726
|
+
const meta = requestMeta(ctx);
|
|
2727
|
+
loginEvents.recordSafe({
|
|
2728
|
+
userId: null,
|
|
2729
|
+
emailAttempted: claims.email ?? null,
|
|
2730
|
+
method: "oauth-apple",
|
|
2731
|
+
outcome: "failed",
|
|
2732
|
+
failureReason: "token_replayed",
|
|
2733
|
+
...meta
|
|
2734
|
+
});
|
|
2735
|
+
return (0, import_core8.setApiResponse)(
|
|
2736
|
+
import_core8.HTTP.UNAUTHORIZED,
|
|
2737
|
+
"APPLE_AUTH_FAILED",
|
|
2738
|
+
"This Apple identity token has already been used"
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
consumedTokens.sweepExpiredSafe();
|
|
2676
2742
|
return completeAppleLogin(ctx, {
|
|
2677
2743
|
email: claims.email,
|
|
2678
2744
|
sub: claims.sub,
|
|
@@ -2744,15 +2810,18 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2744
2810
|
if (config.providers.includes("google")) {
|
|
2745
2811
|
routes.push(
|
|
2746
2812
|
["GET", "/auth/google", oauth.googleInit],
|
|
2747
|
-
|
|
2813
|
+
// ipLimit: an unauthenticated caller can otherwise force one outbound
|
|
2814
|
+
// token-exchange to Google per request (the state check doesn't gate it).
|
|
2815
|
+
["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
|
|
2748
2816
|
);
|
|
2749
2817
|
}
|
|
2750
2818
|
if (config.providers.includes("apple")) {
|
|
2751
2819
|
routes.push(
|
|
2752
2820
|
// Web redirect flow. The callback is a POST (response_mode=form_post);
|
|
2753
|
-
// its urlencoded body is parsed inside the controller.
|
|
2821
|
+
// its urlencoded body is parsed inside the controller. ipLimit for the
|
|
2822
|
+
// same reason as Google's callback (bounds outbound token exchanges).
|
|
2754
2823
|
["GET", "/auth/apple", oauth.appleInit],
|
|
2755
|
-
["POST", "/auth/apple/callback", oauth.appleCallback],
|
|
2824
|
+
["POST", "/auth/apple/callback", ipLimit("login"), oauth.appleCallback],
|
|
2756
2825
|
// Native (iOS) flow: the app posts the identityToken from the Apple
|
|
2757
2826
|
// sheet. ipLimit guards token brute-forcing; validate bounds the body.
|
|
2758
2827
|
["POST", "/auth/apple/native", ipLimit("login"), (0, import_middlewares.validate)(appleNativeSchema), oauth.appleNative]
|
|
@@ -2819,6 +2888,13 @@ function collectAuthConfigProblems(config) {
|
|
|
2819
2888
|
message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
|
|
2820
2889
|
});
|
|
2821
2890
|
}
|
|
2891
|
+
if (a.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
|
|
2892
|
+
problems.push({
|
|
2893
|
+
module: MODULE,
|
|
2894
|
+
severity: "error",
|
|
2895
|
+
message: "apple.nativeClientIds must be exact bundle ids \u2014 empty or wildcard entries would accept identity tokens minted for other apps"
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2822
2898
|
}
|
|
2823
2899
|
if (config.mfa && !config.mfaSecretKey) {
|
|
2824
2900
|
problems.push({
|