@fonderie/auth 7.19.2 → 7.21.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 +2 -1
- package/brain/signatures.md +4 -3
- package/dist/index.cjs +201 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +198 -118
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-B-IgXFu8.d.cts → session-C1A21bvb.d.cts} +3 -2
- package/dist/{session-B-IgXFu8.d.ts → session-C1A21bvb.d.ts} +3 -2
- package/env.json +14 -7
- package/package.json +1 -1
package/brain/outcomes.md
CHANGED
|
@@ -156,13 +156,14 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
|
|
|
156
156
|
| POST | `/auth/email/reset` | `ipLimit('reset') → validate(resetPasswordSchema) → auth.resetPassword` |
|
|
157
157
|
| GET | `/auth/google` | `oauth.googleInit` |
|
|
158
158
|
| GET | `/auth/google/callback` | `ipLimit('login') → oauth.googleCallback` |
|
|
159
|
+
| POST | `/auth/google/native` | `ipLimit('login') → validate(googleNativeSchema) → oauth.googleNative` |
|
|
159
160
|
| POST | `/auth/login` | `ipLimit('login') → validate(loginSchema) → acctLimit('login') → auth.login` |
|
|
160
161
|
| GET | `/auth/login-history` | `requireAuth → user.loginHistory` |
|
|
161
162
|
| POST | `/auth/logout` | `requireAuth → validate(refreshSchema) → auth.logout` |
|
|
162
163
|
| POST | `/auth/mfa/backup-codes` | `requireAuth → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.regenerateBackupCodes` |
|
|
163
164
|
| POST | `/auth/mfa/disable` | `requireAuth → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.disable` |
|
|
164
165
|
| POST | `/auth/mfa/setup` | `requireAuth → requireEmailLogin → requireVerified → mfa.setup` |
|
|
165
|
-
| POST | `/auth/mfa/verify` | `ipLimit('mfaVerify') → requireAnyAuth →
|
|
166
|
+
| POST | `/auth/mfa/verify` | `ipLimit('mfaVerify') → requireAnyAuth → requireEmailLoginUnlessSigningIn → requireVerified → validate(mfaTokenSchema) → mfa.verify` |
|
|
166
167
|
| DELETE | `/auth/oauth/:provider` | `requireAuth → user.unlinkOauth` |
|
|
167
168
|
| GET | `/auth/providers` | `async () => setApiResponse(HTTP.OK, 'AUTH_PROVIDERS', 'Sign-in methods available here', { providers: [...config.providers], })` |
|
|
168
169
|
| POST | `/auth/refresh` | `validate(refreshSchema) → auth.refresh` |
|
package/brain/signatures.md
CHANGED
|
@@ -78,8 +78,9 @@ interface IAuthSecrets {
|
|
|
78
78
|
mfaSecretKey?: string;
|
|
79
79
|
google?: {
|
|
80
80
|
clientId: string;
|
|
81
|
-
clientSecret
|
|
82
|
-
redirectUri
|
|
81
|
+
clientSecret?: string;
|
|
82
|
+
redirectUri?: string;
|
|
83
|
+
nativeClientIds?: string[];
|
|
83
84
|
};
|
|
84
85
|
apple?: {
|
|
85
86
|
clientId: string;
|
|
@@ -198,7 +199,7 @@ type LocationResolver = (req: ILocationRequest) => IRequestLocation | null | und
|
|
|
198
199
|
|
|
199
200
|
function validate(schema: IRequestSchema): Middleware
|
|
200
201
|
|
|
201
|
-
namespace schemas — exports: ChangePasswordInput, LoginInput, RegisterInput, ResetPasswordInput, appleNativeSchema, changePasswordSchema, forgotPasswordSchema, loginSchema, mfaTokenSchema, refreshSchema, registerSchema, resetPasswordSchema, updateEmailSchema, updatePhoneSchema, updatePreferencesSchema, updateProfileSchema, verifySchema
|
|
202
|
+
namespace schemas — exports: ChangePasswordInput, LoginInput, RegisterInput, ResetPasswordInput, appleNativeSchema, changePasswordSchema, forgotPasswordSchema, googleNativeSchema, loginSchema, mfaTokenSchema, refreshSchema, registerSchema, resetPasswordSchema, updateEmailSchema, updatePhoneSchema, updatePreferencesSchema, updateProfileSchema, verifySchema
|
|
202
203
|
|
|
203
204
|
type RegisterInput = z.infer<typeof registerSchema>;
|
|
204
205
|
|
package/dist/index.cjs
CHANGED
|
@@ -115,6 +115,10 @@ var requireEmailLogin = async (ctx, next) => {
|
|
|
115
115
|
}
|
|
116
116
|
return next();
|
|
117
117
|
};
|
|
118
|
+
var requireEmailLoginUnlessSigningIn = async (ctx, next) => {
|
|
119
|
+
if (ctx.user.mfaPending) return next();
|
|
120
|
+
return requireEmailLogin(ctx, next);
|
|
121
|
+
};
|
|
118
122
|
|
|
119
123
|
// src/middlewares/validate.ts
|
|
120
124
|
var import_middlewares = require("@fonderie/core/middlewares");
|
|
@@ -183,6 +187,7 @@ __export(schemas_exports, {
|
|
|
183
187
|
appleNativeSchema: () => appleNativeSchema,
|
|
184
188
|
changePasswordSchema: () => changePasswordSchema,
|
|
185
189
|
forgotPasswordSchema: () => forgotPasswordSchema,
|
|
190
|
+
googleNativeSchema: () => googleNativeSchema,
|
|
186
191
|
loginSchema: () => loginSchema,
|
|
187
192
|
mfaTokenSchema: () => mfaTokenSchema,
|
|
188
193
|
refreshSchema: () => refreshSchema,
|
|
@@ -220,6 +225,12 @@ var appleNativeSchema = import_zod.z.object({
|
|
|
220
225
|
// it against the token's `nonce` claim to defend against replay.
|
|
221
226
|
nonce: import_zod.z.string().max(256).optional()
|
|
222
227
|
});
|
|
228
|
+
var googleNativeSchema = import_zod.z.object({
|
|
229
|
+
idToken: import_zod.z.string().min(1).max(8192),
|
|
230
|
+
// Optional nonce the app passed to the Google SDK; checked against the
|
|
231
|
+
// token's `nonce` claim (binds the token to this request).
|
|
232
|
+
nonce: import_zod.z.string().max(256).optional()
|
|
233
|
+
});
|
|
223
234
|
var refreshSchema = import_zod.z.object({ refreshToken: import_zod.z.string().min(1).optional() });
|
|
224
235
|
var forgotPasswordSchema = import_zod.z.object({ email });
|
|
225
236
|
var resetPasswordSchema = import_zod.z.union([
|
|
@@ -2795,12 +2806,14 @@ function userController(store, config, bus) {
|
|
|
2795
2806
|
deleteMe: async (ctx) => {
|
|
2796
2807
|
const userId = ctx.user.id;
|
|
2797
2808
|
await users.softDelete(userId);
|
|
2809
|
+
await sessions.deleteByUser(userId);
|
|
2798
2810
|
const reqId = ctx.meta["requestId"];
|
|
2799
2811
|
await (0, import_core8.background)(bus?.emit(
|
|
2800
2812
|
EVENT_KEYS.userDeleted,
|
|
2801
2813
|
{ userId },
|
|
2802
2814
|
reqId !== void 0 ? { requestId: reqId } : void 0
|
|
2803
2815
|
));
|
|
2816
|
+
await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
|
|
2804
2817
|
return Response.json(
|
|
2805
2818
|
{ reason: "ACCOUNT_DELETED", explanation: "Account successfully deleted." },
|
|
2806
2819
|
{
|
|
@@ -2849,11 +2862,59 @@ function userController(store, config, bus) {
|
|
|
2849
2862
|
}
|
|
2850
2863
|
|
|
2851
2864
|
// src/controllers/oauth.controller.ts
|
|
2852
|
-
var
|
|
2865
|
+
var import_node_crypto9 = require("crypto");
|
|
2853
2866
|
var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
|
|
2854
2867
|
var import_core9 = require("@fonderie/core");
|
|
2855
2868
|
var import_events4 = require("@fonderie/events");
|
|
2856
2869
|
|
|
2870
|
+
// src/services/jwks.ts
|
|
2871
|
+
var import_node_crypto8 = require("crypto");
|
|
2872
|
+
var TTL_MS = 60 * 60 * 1e3;
|
|
2873
|
+
var MIN_REFETCH_MS = 60 * 1e3;
|
|
2874
|
+
var FETCH_TIMEOUT_MS = 5e3;
|
|
2875
|
+
function createJwksSource(url) {
|
|
2876
|
+
let cache = null;
|
|
2877
|
+
let inflight = null;
|
|
2878
|
+
let lastAttempt = 0;
|
|
2879
|
+
const refresh = () => {
|
|
2880
|
+
if (inflight) return inflight;
|
|
2881
|
+
lastAttempt = Date.now();
|
|
2882
|
+
inflight = (async () => {
|
|
2883
|
+
try {
|
|
2884
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
2885
|
+
const data = await res.json();
|
|
2886
|
+
if (Array.isArray(data.keys)) cache = { keys: data.keys, fetchedAt: Date.now() };
|
|
2887
|
+
} catch {
|
|
2888
|
+
} finally {
|
|
2889
|
+
inflight = null;
|
|
2890
|
+
}
|
|
2891
|
+
})();
|
|
2892
|
+
return inflight;
|
|
2893
|
+
};
|
|
2894
|
+
return {
|
|
2895
|
+
async getKey(kid) {
|
|
2896
|
+
const find = () => cache?.keys.find((k) => k.kid === kid) ?? null;
|
|
2897
|
+
const fresh = () => !!cache && Date.now() - cache.fetchedAt < TTL_MS;
|
|
2898
|
+
let jwk = find();
|
|
2899
|
+
if ((!jwk || !fresh()) && Date.now() - lastAttempt >= MIN_REFETCH_MS) {
|
|
2900
|
+
await refresh();
|
|
2901
|
+
jwk = find();
|
|
2902
|
+
}
|
|
2903
|
+
if (!jwk) return null;
|
|
2904
|
+
try {
|
|
2905
|
+
return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
|
|
2906
|
+
} catch {
|
|
2907
|
+
return null;
|
|
2908
|
+
}
|
|
2909
|
+
},
|
|
2910
|
+
reset() {
|
|
2911
|
+
cache = null;
|
|
2912
|
+
inflight = null;
|
|
2913
|
+
lastAttempt = 0;
|
|
2914
|
+
}
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2857
2918
|
// src/models/consumed-token.model.ts
|
|
2858
2919
|
var ConsumedTokenModel = class {
|
|
2859
2920
|
constructor(store) {
|
|
@@ -2893,49 +2954,14 @@ function mintAppleClientSecret(apple) {
|
|
|
2893
2954
|
{ algorithm: "ES256", keyid: apple.keyId }
|
|
2894
2955
|
);
|
|
2895
2956
|
}
|
|
2896
|
-
var
|
|
2897
|
-
var appleKeyInflight = null;
|
|
2898
|
-
var appleKeyLastAttempt = 0;
|
|
2899
|
-
var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
|
|
2900
|
-
var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
|
|
2901
|
-
var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
|
|
2902
|
-
function refreshAppleKeys() {
|
|
2903
|
-
if (appleKeyInflight) return appleKeyInflight;
|
|
2904
|
-
appleKeyLastAttempt = Date.now();
|
|
2905
|
-
appleKeyInflight = (async () => {
|
|
2906
|
-
try {
|
|
2907
|
-
const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
|
|
2908
|
-
const data = await res.json();
|
|
2909
|
-
if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
|
|
2910
|
-
} catch {
|
|
2911
|
-
} finally {
|
|
2912
|
-
appleKeyInflight = null;
|
|
2913
|
-
}
|
|
2914
|
-
})();
|
|
2915
|
-
return appleKeyInflight;
|
|
2916
|
-
}
|
|
2917
|
-
async function getApplePublicKey(kid) {
|
|
2918
|
-
const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
|
|
2919
|
-
const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
|
|
2920
|
-
let jwk = find();
|
|
2921
|
-
if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
|
|
2922
|
-
await refreshAppleKeys();
|
|
2923
|
-
jwk = find();
|
|
2924
|
-
}
|
|
2925
|
-
if (!jwk) return null;
|
|
2926
|
-
try {
|
|
2927
|
-
return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
|
|
2928
|
-
} catch {
|
|
2929
|
-
return null;
|
|
2930
|
-
}
|
|
2931
|
-
}
|
|
2957
|
+
var appleKeys = createJwksSource(APPLE_KEYS_URL);
|
|
2932
2958
|
async function verifyAppleIdToken(idToken, opts) {
|
|
2933
2959
|
if (opts.audiences.length === 0) return null;
|
|
2934
2960
|
const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
|
|
2935
2961
|
if (!decoded || typeof decoded === "string") return null;
|
|
2936
2962
|
const kid = decoded.header.kid;
|
|
2937
2963
|
if (!kid) return null;
|
|
2938
|
-
const key = await
|
|
2964
|
+
const key = await appleKeys.getKey(kid);
|
|
2939
2965
|
if (!key) return null;
|
|
2940
2966
|
try {
|
|
2941
2967
|
const payload = import_jsonwebtoken2.default.verify(idToken, key, {
|
|
@@ -2953,6 +2979,30 @@ async function verifyAppleIdToken(idToken, opts) {
|
|
|
2953
2979
|
}
|
|
2954
2980
|
}
|
|
2955
2981
|
var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
|
|
2982
|
+
var GOOGLE_KEYS_URL = "https://www.googleapis.com/oauth2/v3/certs";
|
|
2983
|
+
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
2984
|
+
var googleKeys = createJwksSource(GOOGLE_KEYS_URL);
|
|
2985
|
+
async function verifyGoogleIdToken(idToken, opts) {
|
|
2986
|
+
if (opts.audiences.length === 0) return null;
|
|
2987
|
+
const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
|
|
2988
|
+
if (!decoded || typeof decoded === "string") return null;
|
|
2989
|
+
const kid = decoded.header.kid;
|
|
2990
|
+
if (!kid) return null;
|
|
2991
|
+
const key = await googleKeys.getKey(kid);
|
|
2992
|
+
if (!key) return null;
|
|
2993
|
+
try {
|
|
2994
|
+
const payload = import_jsonwebtoken2.default.verify(idToken, key, {
|
|
2995
|
+
algorithms: ["RS256"],
|
|
2996
|
+
issuer: [...GOOGLE_ISSUERS],
|
|
2997
|
+
audience: opts.audiences
|
|
2998
|
+
});
|
|
2999
|
+
if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) return null;
|
|
3000
|
+
return payload;
|
|
3001
|
+
} catch {
|
|
3002
|
+
return null;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
var googleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
|
|
2956
3006
|
function oauthController(store, config, bus) {
|
|
2957
3007
|
const users = new UserModel(store);
|
|
2958
3008
|
const sessions = new SessionModel(store, config.location);
|
|
@@ -3015,48 +3065,68 @@ function oauthController(store, config, bus) {
|
|
|
3015
3065
|
));
|
|
3016
3066
|
}
|
|
3017
3067
|
};
|
|
3018
|
-
const
|
|
3068
|
+
const PROVIDER = {
|
|
3069
|
+
apple: { label: "Apple", failed: "APPLE_AUTH_FAILED", success: "APPLE_AUTH_SUCCESS" },
|
|
3070
|
+
google: { label: "Google", failed: "GOOGLE_AUTH_FAILED", success: "GOOGLE_AUTH_SUCCESS" }
|
|
3071
|
+
};
|
|
3072
|
+
const completeOAuthLogin = async (ctx, provider, claims, clearCookies = []) => {
|
|
3073
|
+
const p = PROVIDER[provider];
|
|
3074
|
+
const method = provider === "apple" ? "oauth-apple" : "oauth-google";
|
|
3019
3075
|
const meta = requestMeta(ctx);
|
|
3020
3076
|
if (!claims.email) {
|
|
3021
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST,
|
|
3077
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
|
|
3022
3078
|
}
|
|
3023
3079
|
if (!claims.emailVerified) {
|
|
3024
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST,
|
|
3080
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
|
|
3025
3081
|
}
|
|
3026
3082
|
const normalizedEmail = normalizeEmailSafe(claims.email);
|
|
3027
3083
|
if (!normalizedEmail) {
|
|
3028
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST,
|
|
3084
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
|
|
3029
3085
|
}
|
|
3030
|
-
const upserted = await users.upsertByProvider(normalizedEmail,
|
|
3086
|
+
const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
|
|
3031
3087
|
if (!upserted) {
|
|
3032
3088
|
await loginEvents.recordSafe({
|
|
3033
3089
|
userId: null,
|
|
3034
3090
|
emailAttempted: normalizedEmail,
|
|
3035
|
-
method
|
|
3091
|
+
method,
|
|
3036
3092
|
outcome: "failed",
|
|
3037
3093
|
failureReason: "provider_upsert_failed",
|
|
3038
3094
|
...meta
|
|
3039
3095
|
});
|
|
3040
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR",
|
|
3096
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
|
|
3041
3097
|
}
|
|
3042
3098
|
const fullUser = await users.findById(upserted.id);
|
|
3043
3099
|
if (!fullUser) {
|
|
3044
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR",
|
|
3100
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
|
|
3101
|
+
}
|
|
3102
|
+
await announceOAuthUpsert(ctx, upserted, provider, fullUser);
|
|
3103
|
+
if (fullUser.suspended) {
|
|
3104
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
|
|
3105
|
+
}
|
|
3106
|
+
if (fullUser.mfaEnabled) {
|
|
3107
|
+
const mfaToken = issueMfaPendingToken(upserted.id, config, provider);
|
|
3108
|
+
return Response.json(
|
|
3109
|
+
{
|
|
3110
|
+
reason: "MFA_REQUIRED",
|
|
3111
|
+
explanation: "Multi-factor authentication required",
|
|
3112
|
+
result: { mfaToken }
|
|
3113
|
+
},
|
|
3114
|
+
{ status: 200, headers: cookieHeaders(clearCookies) }
|
|
3115
|
+
);
|
|
3045
3116
|
}
|
|
3046
|
-
|
|
3047
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: "apple" });
|
|
3117
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: provider });
|
|
3048
3118
|
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
3049
3119
|
await loginEvents.recordSafe({
|
|
3050
3120
|
userId: upserted.id,
|
|
3051
3121
|
emailAttempted: normalizedEmail,
|
|
3052
|
-
method
|
|
3122
|
+
method,
|
|
3053
3123
|
outcome: "success",
|
|
3054
3124
|
...meta
|
|
3055
3125
|
});
|
|
3056
3126
|
return Response.json(
|
|
3057
3127
|
{
|
|
3058
|
-
reason:
|
|
3059
|
-
explanation:
|
|
3128
|
+
reason: p.success,
|
|
3129
|
+
explanation: `${p.label} authentication successful.`,
|
|
3060
3130
|
result: {
|
|
3061
3131
|
tokens: { access: accessToken, refresh: refreshToken },
|
|
3062
3132
|
user: toUserDTO(fullUser)
|
|
@@ -3064,25 +3134,23 @@ function oauthController(store, config, bus) {
|
|
|
3064
3134
|
},
|
|
3065
3135
|
{
|
|
3066
3136
|
status: 200,
|
|
3067
|
-
headers: cookieHeaders([
|
|
3068
|
-
...tokenPairCookies(accessToken, refreshToken, config),
|
|
3069
|
-
// One-time value — clear the (SameSite=None) state cookie.
|
|
3070
|
-
"oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
|
|
3071
|
-
])
|
|
3137
|
+
headers: cookieHeaders([...tokenPairCookies(accessToken, refreshToken, config), ...clearCookies])
|
|
3072
3138
|
}
|
|
3073
3139
|
);
|
|
3074
3140
|
};
|
|
3141
|
+
const APPLE_STATE_CLEAR = "oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0";
|
|
3142
|
+
const GOOGLE_STATE_CLEAR = "oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0";
|
|
3075
3143
|
return {
|
|
3076
3144
|
googleInit: async (_ctx) => {
|
|
3077
3145
|
const google = config.google;
|
|
3078
|
-
if (!google) {
|
|
3146
|
+
if (!google || !google.clientSecret || !google.redirectUri) {
|
|
3079
3147
|
return (0, import_core9.setApiResponse)(
|
|
3080
3148
|
import_core9.HTTP.NOT_IMPLEMENTED,
|
|
3081
3149
|
"NOT_CONFIGURED",
|
|
3082
3150
|
"Google OAuth not configured"
|
|
3083
3151
|
);
|
|
3084
3152
|
}
|
|
3085
|
-
const state = (0,
|
|
3153
|
+
const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
|
|
3086
3154
|
const params = new URLSearchParams({
|
|
3087
3155
|
client_id: google.clientId,
|
|
3088
3156
|
redirect_uri: google.redirectUri,
|
|
@@ -3110,7 +3178,7 @@ function oauthController(store, config, bus) {
|
|
|
3110
3178
|
},
|
|
3111
3179
|
googleCallback: async (ctx) => {
|
|
3112
3180
|
const google = config.google;
|
|
3113
|
-
if (!google) {
|
|
3181
|
+
if (!google || !google.clientSecret || !google.redirectUri) {
|
|
3114
3182
|
return (0, import_core9.setApiResponse)(
|
|
3115
3183
|
import_core9.HTTP.NOT_IMPLEMENTED,
|
|
3116
3184
|
"NOT_CONFIGURED",
|
|
@@ -3173,64 +3241,18 @@ function oauthController(store, config, bus) {
|
|
|
3173
3241
|
"Google account email is not verified"
|
|
3174
3242
|
);
|
|
3175
3243
|
}
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
|
|
3182
|
-
if (!upserted) {
|
|
3183
|
-
await loginEvents.recordSafe({
|
|
3184
|
-
userId: null,
|
|
3185
|
-
emailAttempted: normalizedEmail,
|
|
3186
|
-
method: "oauth-google",
|
|
3187
|
-
outcome: "failed",
|
|
3188
|
-
failureReason: "provider_upsert_failed",
|
|
3189
|
-
...meta
|
|
3190
|
-
});
|
|
3191
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
3192
|
-
}
|
|
3193
|
-
const fullUser = await users.findById(upserted.id);
|
|
3194
|
-
if (!fullUser) {
|
|
3195
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
3196
|
-
}
|
|
3197
|
-
await announceOAuthUpsert(ctx, upserted, "google", fullUser);
|
|
3198
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
3199
|
-
loginMethod: "google"
|
|
3200
|
-
});
|
|
3201
|
-
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
3202
|
-
await loginEvents.recordSafe({
|
|
3203
|
-
userId: upserted.id,
|
|
3204
|
-
emailAttempted: normalizedEmail,
|
|
3205
|
-
method: "oauth-google",
|
|
3206
|
-
outcome: "success",
|
|
3207
|
-
...meta
|
|
3208
|
-
});
|
|
3209
|
-
return Response.json(
|
|
3210
|
-
{
|
|
3211
|
-
reason: "GOOGLE_AUTH_SUCCESS",
|
|
3212
|
-
explanation: "Google authentication successful.",
|
|
3213
|
-
result: {
|
|
3214
|
-
tokens: { access: accessToken, refresh: refreshToken },
|
|
3215
|
-
user: toUserDTO(fullUser)
|
|
3216
|
-
}
|
|
3217
|
-
},
|
|
3218
|
-
{
|
|
3219
|
-
status: 200,
|
|
3220
|
-
headers: cookieHeaders([
|
|
3221
|
-
...tokenPairCookies(accessToken, refreshToken, config),
|
|
3222
|
-
// One-time value — clear it once the flow completes.
|
|
3223
|
-
"oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
|
|
3224
|
-
])
|
|
3225
|
-
}
|
|
3226
|
-
);
|
|
3244
|
+
return completeOAuthLogin(ctx, "google", {
|
|
3245
|
+
email: payload.email,
|
|
3246
|
+
sub: payload.sub,
|
|
3247
|
+
emailVerified: payload.email_verified === true
|
|
3248
|
+
}, [GOOGLE_STATE_CLEAR]);
|
|
3227
3249
|
},
|
|
3228
3250
|
appleInit: async (_ctx) => {
|
|
3229
3251
|
const apple = config.apple;
|
|
3230
3252
|
if (!apple) {
|
|
3231
3253
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
3232
3254
|
}
|
|
3233
|
-
const state = (0,
|
|
3255
|
+
const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
|
|
3234
3256
|
const params = new URLSearchParams({
|
|
3235
3257
|
client_id: apple.clientId,
|
|
3236
3258
|
redirect_uri: apple.redirectUri,
|
|
@@ -3296,11 +3318,11 @@ function oauthController(store, config, bus) {
|
|
|
3296
3318
|
if (!claims) {
|
|
3297
3319
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
3298
3320
|
}
|
|
3299
|
-
return
|
|
3321
|
+
return completeOAuthLogin(ctx, "apple", {
|
|
3300
3322
|
email: claims.email,
|
|
3301
3323
|
sub: claims.sub,
|
|
3302
3324
|
emailVerified: appleEmailVerified(claims)
|
|
3303
|
-
});
|
|
3325
|
+
}, [APPLE_STATE_CLEAR]);
|
|
3304
3326
|
},
|
|
3305
3327
|
appleNative: async (ctx) => {
|
|
3306
3328
|
const apple = config.apple;
|
|
@@ -3326,7 +3348,7 @@ function oauthController(store, config, bus) {
|
|
|
3326
3348
|
});
|
|
3327
3349
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
3328
3350
|
}
|
|
3329
|
-
const tokenHash = (0,
|
|
3351
|
+
const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(identityToken).digest("hex");
|
|
3330
3352
|
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
3331
3353
|
const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
|
|
3332
3354
|
if (!firstUse) {
|
|
@@ -3346,10 +3368,54 @@ function oauthController(store, config, bus) {
|
|
|
3346
3368
|
);
|
|
3347
3369
|
}
|
|
3348
3370
|
consumedTokens.sweepExpiredSafe();
|
|
3349
|
-
return
|
|
3371
|
+
return completeOAuthLogin(ctx, "apple", {
|
|
3350
3372
|
email: claims.email,
|
|
3351
3373
|
sub: claims.sub,
|
|
3352
3374
|
emailVerified: appleEmailVerified(claims)
|
|
3375
|
+
}, [APPLE_STATE_CLEAR]);
|
|
3376
|
+
},
|
|
3377
|
+
// Sign in with Google from a native app: the Google SDK's ID token,
|
|
3378
|
+
// verified here (signature, issuer, audience, expiry), redeemed once.
|
|
3379
|
+
googleNative: async (ctx) => {
|
|
3380
|
+
const audiences = config.google?.nativeClientIds ?? [];
|
|
3381
|
+
if (!config.google || audiences.length === 0) {
|
|
3382
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
|
|
3383
|
+
}
|
|
3384
|
+
const body = ctx.meta["body"];
|
|
3385
|
+
const idToken = body?.idToken;
|
|
3386
|
+
if (!idToken) {
|
|
3387
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
|
|
3388
|
+
}
|
|
3389
|
+
const claims = await verifyGoogleIdToken(idToken, { audiences, nonce: body?.nonce });
|
|
3390
|
+
if (!claims) {
|
|
3391
|
+
await loginEvents.recordSafe({
|
|
3392
|
+
userId: null,
|
|
3393
|
+
emailAttempted: null,
|
|
3394
|
+
method: "oauth-google",
|
|
3395
|
+
outcome: "failed",
|
|
3396
|
+
failureReason: "invalid_identity_token",
|
|
3397
|
+
...requestMeta(ctx)
|
|
3398
|
+
});
|
|
3399
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
|
|
3400
|
+
}
|
|
3401
|
+
const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(idToken).digest("hex");
|
|
3402
|
+
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
3403
|
+
if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
|
|
3404
|
+
await loginEvents.recordSafe({
|
|
3405
|
+
userId: null,
|
|
3406
|
+
emailAttempted: claims.email ?? null,
|
|
3407
|
+
method: "oauth-google",
|
|
3408
|
+
outcome: "failed",
|
|
3409
|
+
failureReason: "token_replayed",
|
|
3410
|
+
...requestMeta(ctx)
|
|
3411
|
+
});
|
|
3412
|
+
return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
|
|
3413
|
+
}
|
|
3414
|
+
consumedTokens.sweepExpiredSafe();
|
|
3415
|
+
return completeOAuthLogin(ctx, "google", {
|
|
3416
|
+
email: claims.email,
|
|
3417
|
+
sub: claims.sub,
|
|
3418
|
+
emailVerified: googleEmailVerified(claims)
|
|
3353
3419
|
});
|
|
3354
3420
|
}
|
|
3355
3421
|
};
|
|
@@ -3434,7 +3500,7 @@ function buildAuthRoutes(store, config, bus, locales) {
|
|
|
3434
3500
|
R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
|
|
3435
3501
|
// /auth/mfa/verify accepts both mfaPending tokens (TOTP/backup-code login)
|
|
3436
3502
|
// and full tokens (setup confirmation), so requireAnyAuth is used here.
|
|
3437
|
-
R("mfaVerify", "POST", "/auth/mfa/verify", ipLimit("mfaVerify"), import_middlewares2.requireAnyAuth,
|
|
3503
|
+
R("mfaVerify", "POST", "/auth/mfa/verify", ipLimit("mfaVerify"), import_middlewares2.requireAnyAuth, requireEmailLoginUnlessSigningIn, import_middlewares2.requireVerified, (0, import_middlewares.validate)(mfaTokenSchema), mfa.verify),
|
|
3438
3504
|
R("mfaDisable", "POST", "/auth/mfa/disable", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, (0, import_middlewares.validate)(mfaTokenSchema), mfa.disable),
|
|
3439
3505
|
R("mfaBackupCodes", "POST", "/auth/mfa/backup-codes", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, (0, import_middlewares.validate)(mfaTokenSchema), mfa.regenerateBackupCodes)
|
|
3440
3506
|
];
|
|
@@ -3443,7 +3509,9 @@ function buildAuthRoutes(store, config, bus, locales) {
|
|
|
3443
3509
|
["GET", "/auth/google", oauth.googleInit],
|
|
3444
3510
|
// ipLimit: an unauthenticated caller can otherwise force one outbound
|
|
3445
3511
|
// token-exchange to Google per request (the state check doesn't gate it).
|
|
3446
|
-
["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
|
|
3512
|
+
["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback],
|
|
3513
|
+
// Native (Android/iOS) flow: the app posts the Google SDK's ID token.
|
|
3514
|
+
["POST", "/auth/google/native", ipLimit("login"), (0, import_middlewares.validate)(googleNativeSchema), oauth.googleNative]
|
|
3447
3515
|
);
|
|
3448
3516
|
}
|
|
3449
3517
|
if (config.providers.includes("apple")) {
|
|
@@ -3509,15 +3577,18 @@ function collectAuthConfigProblems(config) {
|
|
|
3509
3577
|
}
|
|
3510
3578
|
if (config.google) {
|
|
3511
3579
|
const clientSecret = config.google.clientSecret ?? "";
|
|
3512
|
-
|
|
3580
|
+
const web = Boolean(clientSecret || config.google.redirectUri);
|
|
3581
|
+
const webComplete = Boolean(clientSecret && config.google.redirectUri);
|
|
3582
|
+
const native = (config.google.nativeClientIds ?? []).filter(Boolean).length > 0;
|
|
3583
|
+
if (!config.google.clientId || web && !webComplete || !webComplete && !native) {
|
|
3513
3584
|
problems.push({
|
|
3514
3585
|
module: MODULE,
|
|
3515
3586
|
severity: "error",
|
|
3516
|
-
message: "google OAuth is configured but clientId, clientSecret
|
|
3587
|
+
message: "google OAuth is configured but incomplete: set clientId, and clientSecret + redirectUri (web sign-in) and/or nativeClientIds (app sign-in)",
|
|
3517
3588
|
domain: "auth",
|
|
3518
3589
|
reason: "GOOGLE_INCOMPLETE"
|
|
3519
3590
|
});
|
|
3520
|
-
} else if (import_core11.PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
3591
|
+
} else if (clientSecret && import_core11.PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
3521
3592
|
problems.push({
|
|
3522
3593
|
module: MODULE,
|
|
3523
3594
|
severity: "error",
|
|
@@ -3526,6 +3597,15 @@ function collectAuthConfigProblems(config) {
|
|
|
3526
3597
|
reason: "GOOGLE_SECRET_PLACEHOLDER"
|
|
3527
3598
|
});
|
|
3528
3599
|
}
|
|
3600
|
+
if (config.google.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
|
|
3601
|
+
problems.push({
|
|
3602
|
+
module: MODULE,
|
|
3603
|
+
severity: "error",
|
|
3604
|
+
message: "google.nativeClientIds must be exact client ids \u2014 empty or wildcard entries would accept ID tokens minted for other apps",
|
|
3605
|
+
domain: "auth",
|
|
3606
|
+
reason: "GOOGLE_NATIVE_IDS_INVALID"
|
|
3607
|
+
});
|
|
3608
|
+
}
|
|
3529
3609
|
}
|
|
3530
3610
|
if (config.apple) {
|
|
3531
3611
|
const a = config.apple;
|
|
@@ -3785,7 +3865,7 @@ var AuthModule = class {
|
|
|
3785
3865
|
name = "@fonderie/auth";
|
|
3786
3866
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3787
3867
|
// say what is actually deployed rather than 'not reported'.
|
|
3788
|
-
version = "7.
|
|
3868
|
+
version = "7.21.0";
|
|
3789
3869
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3790
3870
|
describeAdmin() {
|
|
3791
3871
|
return { routes: describeAuthAdminRoutes(this.store, this.bus) };
|