@fonderie/auth 7.20.0 → 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 +199 -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 +196 -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([
|
|
@@ -2851,11 +2862,59 @@ function userController(store, config, bus) {
|
|
|
2851
2862
|
}
|
|
2852
2863
|
|
|
2853
2864
|
// src/controllers/oauth.controller.ts
|
|
2854
|
-
var
|
|
2865
|
+
var import_node_crypto9 = require("crypto");
|
|
2855
2866
|
var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
|
|
2856
2867
|
var import_core9 = require("@fonderie/core");
|
|
2857
2868
|
var import_events4 = require("@fonderie/events");
|
|
2858
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
|
+
|
|
2859
2918
|
// src/models/consumed-token.model.ts
|
|
2860
2919
|
var ConsumedTokenModel = class {
|
|
2861
2920
|
constructor(store) {
|
|
@@ -2895,49 +2954,14 @@ function mintAppleClientSecret(apple) {
|
|
|
2895
2954
|
{ algorithm: "ES256", keyid: apple.keyId }
|
|
2896
2955
|
);
|
|
2897
2956
|
}
|
|
2898
|
-
var
|
|
2899
|
-
var appleKeyInflight = null;
|
|
2900
|
-
var appleKeyLastAttempt = 0;
|
|
2901
|
-
var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
|
|
2902
|
-
var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
|
|
2903
|
-
var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
|
|
2904
|
-
function refreshAppleKeys() {
|
|
2905
|
-
if (appleKeyInflight) return appleKeyInflight;
|
|
2906
|
-
appleKeyLastAttempt = Date.now();
|
|
2907
|
-
appleKeyInflight = (async () => {
|
|
2908
|
-
try {
|
|
2909
|
-
const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
|
|
2910
|
-
const data = await res.json();
|
|
2911
|
-
if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
|
|
2912
|
-
} catch {
|
|
2913
|
-
} finally {
|
|
2914
|
-
appleKeyInflight = null;
|
|
2915
|
-
}
|
|
2916
|
-
})();
|
|
2917
|
-
return appleKeyInflight;
|
|
2918
|
-
}
|
|
2919
|
-
async function getApplePublicKey(kid) {
|
|
2920
|
-
const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
|
|
2921
|
-
const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
|
|
2922
|
-
let jwk = find();
|
|
2923
|
-
if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
|
|
2924
|
-
await refreshAppleKeys();
|
|
2925
|
-
jwk = find();
|
|
2926
|
-
}
|
|
2927
|
-
if (!jwk) return null;
|
|
2928
|
-
try {
|
|
2929
|
-
return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
|
|
2930
|
-
} catch {
|
|
2931
|
-
return null;
|
|
2932
|
-
}
|
|
2933
|
-
}
|
|
2957
|
+
var appleKeys = createJwksSource(APPLE_KEYS_URL);
|
|
2934
2958
|
async function verifyAppleIdToken(idToken, opts) {
|
|
2935
2959
|
if (opts.audiences.length === 0) return null;
|
|
2936
2960
|
const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
|
|
2937
2961
|
if (!decoded || typeof decoded === "string") return null;
|
|
2938
2962
|
const kid = decoded.header.kid;
|
|
2939
2963
|
if (!kid) return null;
|
|
2940
|
-
const key = await
|
|
2964
|
+
const key = await appleKeys.getKey(kid);
|
|
2941
2965
|
if (!key) return null;
|
|
2942
2966
|
try {
|
|
2943
2967
|
const payload = import_jsonwebtoken2.default.verify(idToken, key, {
|
|
@@ -2955,6 +2979,30 @@ async function verifyAppleIdToken(idToken, opts) {
|
|
|
2955
2979
|
}
|
|
2956
2980
|
}
|
|
2957
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";
|
|
2958
3006
|
function oauthController(store, config, bus) {
|
|
2959
3007
|
const users = new UserModel(store);
|
|
2960
3008
|
const sessions = new SessionModel(store, config.location);
|
|
@@ -3017,48 +3065,68 @@ function oauthController(store, config, bus) {
|
|
|
3017
3065
|
));
|
|
3018
3066
|
}
|
|
3019
3067
|
};
|
|
3020
|
-
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";
|
|
3021
3075
|
const meta = requestMeta(ctx);
|
|
3022
3076
|
if (!claims.email) {
|
|
3023
|
-
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`);
|
|
3024
3078
|
}
|
|
3025
3079
|
if (!claims.emailVerified) {
|
|
3026
|
-
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`);
|
|
3027
3081
|
}
|
|
3028
3082
|
const normalizedEmail = normalizeEmailSafe(claims.email);
|
|
3029
3083
|
if (!normalizedEmail) {
|
|
3030
|
-
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`);
|
|
3031
3085
|
}
|
|
3032
|
-
const upserted = await users.upsertByProvider(normalizedEmail,
|
|
3086
|
+
const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
|
|
3033
3087
|
if (!upserted) {
|
|
3034
3088
|
await loginEvents.recordSafe({
|
|
3035
3089
|
userId: null,
|
|
3036
3090
|
emailAttempted: normalizedEmail,
|
|
3037
|
-
method
|
|
3091
|
+
method,
|
|
3038
3092
|
outcome: "failed",
|
|
3039
3093
|
failureReason: "provider_upsert_failed",
|
|
3040
3094
|
...meta
|
|
3041
3095
|
});
|
|
3042
|
-
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`);
|
|
3043
3097
|
}
|
|
3044
3098
|
const fullUser = await users.findById(upserted.id);
|
|
3045
3099
|
if (!fullUser) {
|
|
3046
|
-
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
|
+
);
|
|
3047
3116
|
}
|
|
3048
|
-
|
|
3049
|
-
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 });
|
|
3050
3118
|
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
3051
3119
|
await loginEvents.recordSafe({
|
|
3052
3120
|
userId: upserted.id,
|
|
3053
3121
|
emailAttempted: normalizedEmail,
|
|
3054
|
-
method
|
|
3122
|
+
method,
|
|
3055
3123
|
outcome: "success",
|
|
3056
3124
|
...meta
|
|
3057
3125
|
});
|
|
3058
3126
|
return Response.json(
|
|
3059
3127
|
{
|
|
3060
|
-
reason:
|
|
3061
|
-
explanation:
|
|
3128
|
+
reason: p.success,
|
|
3129
|
+
explanation: `${p.label} authentication successful.`,
|
|
3062
3130
|
result: {
|
|
3063
3131
|
tokens: { access: accessToken, refresh: refreshToken },
|
|
3064
3132
|
user: toUserDTO(fullUser)
|
|
@@ -3066,25 +3134,23 @@ function oauthController(store, config, bus) {
|
|
|
3066
3134
|
},
|
|
3067
3135
|
{
|
|
3068
3136
|
status: 200,
|
|
3069
|
-
headers: cookieHeaders([
|
|
3070
|
-
...tokenPairCookies(accessToken, refreshToken, config),
|
|
3071
|
-
// One-time value — clear the (SameSite=None) state cookie.
|
|
3072
|
-
"oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
|
|
3073
|
-
])
|
|
3137
|
+
headers: cookieHeaders([...tokenPairCookies(accessToken, refreshToken, config), ...clearCookies])
|
|
3074
3138
|
}
|
|
3075
3139
|
);
|
|
3076
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";
|
|
3077
3143
|
return {
|
|
3078
3144
|
googleInit: async (_ctx) => {
|
|
3079
3145
|
const google = config.google;
|
|
3080
|
-
if (!google) {
|
|
3146
|
+
if (!google || !google.clientSecret || !google.redirectUri) {
|
|
3081
3147
|
return (0, import_core9.setApiResponse)(
|
|
3082
3148
|
import_core9.HTTP.NOT_IMPLEMENTED,
|
|
3083
3149
|
"NOT_CONFIGURED",
|
|
3084
3150
|
"Google OAuth not configured"
|
|
3085
3151
|
);
|
|
3086
3152
|
}
|
|
3087
|
-
const state = (0,
|
|
3153
|
+
const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
|
|
3088
3154
|
const params = new URLSearchParams({
|
|
3089
3155
|
client_id: google.clientId,
|
|
3090
3156
|
redirect_uri: google.redirectUri,
|
|
@@ -3112,7 +3178,7 @@ function oauthController(store, config, bus) {
|
|
|
3112
3178
|
},
|
|
3113
3179
|
googleCallback: async (ctx) => {
|
|
3114
3180
|
const google = config.google;
|
|
3115
|
-
if (!google) {
|
|
3181
|
+
if (!google || !google.clientSecret || !google.redirectUri) {
|
|
3116
3182
|
return (0, import_core9.setApiResponse)(
|
|
3117
3183
|
import_core9.HTTP.NOT_IMPLEMENTED,
|
|
3118
3184
|
"NOT_CONFIGURED",
|
|
@@ -3175,64 +3241,18 @@ function oauthController(store, config, bus) {
|
|
|
3175
3241
|
"Google account email is not verified"
|
|
3176
3242
|
);
|
|
3177
3243
|
}
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
|
|
3184
|
-
if (!upserted) {
|
|
3185
|
-
await loginEvents.recordSafe({
|
|
3186
|
-
userId: null,
|
|
3187
|
-
emailAttempted: normalizedEmail,
|
|
3188
|
-
method: "oauth-google",
|
|
3189
|
-
outcome: "failed",
|
|
3190
|
-
failureReason: "provider_upsert_failed",
|
|
3191
|
-
...meta
|
|
3192
|
-
});
|
|
3193
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
3194
|
-
}
|
|
3195
|
-
const fullUser = await users.findById(upserted.id);
|
|
3196
|
-
if (!fullUser) {
|
|
3197
|
-
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
3198
|
-
}
|
|
3199
|
-
await announceOAuthUpsert(ctx, upserted, "google", fullUser);
|
|
3200
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
3201
|
-
loginMethod: "google"
|
|
3202
|
-
});
|
|
3203
|
-
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
3204
|
-
await loginEvents.recordSafe({
|
|
3205
|
-
userId: upserted.id,
|
|
3206
|
-
emailAttempted: normalizedEmail,
|
|
3207
|
-
method: "oauth-google",
|
|
3208
|
-
outcome: "success",
|
|
3209
|
-
...meta
|
|
3210
|
-
});
|
|
3211
|
-
return Response.json(
|
|
3212
|
-
{
|
|
3213
|
-
reason: "GOOGLE_AUTH_SUCCESS",
|
|
3214
|
-
explanation: "Google authentication successful.",
|
|
3215
|
-
result: {
|
|
3216
|
-
tokens: { access: accessToken, refresh: refreshToken },
|
|
3217
|
-
user: toUserDTO(fullUser)
|
|
3218
|
-
}
|
|
3219
|
-
},
|
|
3220
|
-
{
|
|
3221
|
-
status: 200,
|
|
3222
|
-
headers: cookieHeaders([
|
|
3223
|
-
...tokenPairCookies(accessToken, refreshToken, config),
|
|
3224
|
-
// One-time value — clear it once the flow completes.
|
|
3225
|
-
"oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
|
|
3226
|
-
])
|
|
3227
|
-
}
|
|
3228
|
-
);
|
|
3244
|
+
return completeOAuthLogin(ctx, "google", {
|
|
3245
|
+
email: payload.email,
|
|
3246
|
+
sub: payload.sub,
|
|
3247
|
+
emailVerified: payload.email_verified === true
|
|
3248
|
+
}, [GOOGLE_STATE_CLEAR]);
|
|
3229
3249
|
},
|
|
3230
3250
|
appleInit: async (_ctx) => {
|
|
3231
3251
|
const apple = config.apple;
|
|
3232
3252
|
if (!apple) {
|
|
3233
3253
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
3234
3254
|
}
|
|
3235
|
-
const state = (0,
|
|
3255
|
+
const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
|
|
3236
3256
|
const params = new URLSearchParams({
|
|
3237
3257
|
client_id: apple.clientId,
|
|
3238
3258
|
redirect_uri: apple.redirectUri,
|
|
@@ -3298,11 +3318,11 @@ function oauthController(store, config, bus) {
|
|
|
3298
3318
|
if (!claims) {
|
|
3299
3319
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
3300
3320
|
}
|
|
3301
|
-
return
|
|
3321
|
+
return completeOAuthLogin(ctx, "apple", {
|
|
3302
3322
|
email: claims.email,
|
|
3303
3323
|
sub: claims.sub,
|
|
3304
3324
|
emailVerified: appleEmailVerified(claims)
|
|
3305
|
-
});
|
|
3325
|
+
}, [APPLE_STATE_CLEAR]);
|
|
3306
3326
|
},
|
|
3307
3327
|
appleNative: async (ctx) => {
|
|
3308
3328
|
const apple = config.apple;
|
|
@@ -3328,7 +3348,7 @@ function oauthController(store, config, bus) {
|
|
|
3328
3348
|
});
|
|
3329
3349
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
3330
3350
|
}
|
|
3331
|
-
const tokenHash = (0,
|
|
3351
|
+
const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(identityToken).digest("hex");
|
|
3332
3352
|
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
3333
3353
|
const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
|
|
3334
3354
|
if (!firstUse) {
|
|
@@ -3348,10 +3368,54 @@ function oauthController(store, config, bus) {
|
|
|
3348
3368
|
);
|
|
3349
3369
|
}
|
|
3350
3370
|
consumedTokens.sweepExpiredSafe();
|
|
3351
|
-
return
|
|
3371
|
+
return completeOAuthLogin(ctx, "apple", {
|
|
3352
3372
|
email: claims.email,
|
|
3353
3373
|
sub: claims.sub,
|
|
3354
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)
|
|
3355
3419
|
});
|
|
3356
3420
|
}
|
|
3357
3421
|
};
|
|
@@ -3436,7 +3500,7 @@ function buildAuthRoutes(store, config, bus, locales) {
|
|
|
3436
3500
|
R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
|
|
3437
3501
|
// /auth/mfa/verify accepts both mfaPending tokens (TOTP/backup-code login)
|
|
3438
3502
|
// and full tokens (setup confirmation), so requireAnyAuth is used here.
|
|
3439
|
-
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),
|
|
3440
3504
|
R("mfaDisable", "POST", "/auth/mfa/disable", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, (0, import_middlewares.validate)(mfaTokenSchema), mfa.disable),
|
|
3441
3505
|
R("mfaBackupCodes", "POST", "/auth/mfa/backup-codes", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, (0, import_middlewares.validate)(mfaTokenSchema), mfa.regenerateBackupCodes)
|
|
3442
3506
|
];
|
|
@@ -3445,7 +3509,9 @@ function buildAuthRoutes(store, config, bus, locales) {
|
|
|
3445
3509
|
["GET", "/auth/google", oauth.googleInit],
|
|
3446
3510
|
// ipLimit: an unauthenticated caller can otherwise force one outbound
|
|
3447
3511
|
// token-exchange to Google per request (the state check doesn't gate it).
|
|
3448
|
-
["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]
|
|
3449
3515
|
);
|
|
3450
3516
|
}
|
|
3451
3517
|
if (config.providers.includes("apple")) {
|
|
@@ -3511,15 +3577,18 @@ function collectAuthConfigProblems(config) {
|
|
|
3511
3577
|
}
|
|
3512
3578
|
if (config.google) {
|
|
3513
3579
|
const clientSecret = config.google.clientSecret ?? "";
|
|
3514
|
-
|
|
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) {
|
|
3515
3584
|
problems.push({
|
|
3516
3585
|
module: MODULE,
|
|
3517
3586
|
severity: "error",
|
|
3518
|
-
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)",
|
|
3519
3588
|
domain: "auth",
|
|
3520
3589
|
reason: "GOOGLE_INCOMPLETE"
|
|
3521
3590
|
});
|
|
3522
|
-
} else if (import_core11.PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
3591
|
+
} else if (clientSecret && import_core11.PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
3523
3592
|
problems.push({
|
|
3524
3593
|
module: MODULE,
|
|
3525
3594
|
severity: "error",
|
|
@@ -3528,6 +3597,15 @@ function collectAuthConfigProblems(config) {
|
|
|
3528
3597
|
reason: "GOOGLE_SECRET_PLACEHOLDER"
|
|
3529
3598
|
});
|
|
3530
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
|
+
}
|
|
3531
3609
|
}
|
|
3532
3610
|
if (config.apple) {
|
|
3533
3611
|
const a = config.apple;
|
|
@@ -3787,7 +3865,7 @@ var AuthModule = class {
|
|
|
3787
3865
|
name = "@fonderie/auth";
|
|
3788
3866
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3789
3867
|
// say what is actually deployed rather than 'not reported'.
|
|
3790
|
-
version = "7.
|
|
3868
|
+
version = "7.21.0";
|
|
3791
3869
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3792
3870
|
describeAdmin() {
|
|
3793
3871
|
return { routes: describeAuthAdminRoutes(this.store, this.bus) };
|