@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 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 → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.verify` |
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` |
@@ -78,8 +78,9 @@ interface IAuthSecrets {
78
78
  mfaSecretKey?: string;
79
79
  google?: {
80
80
  clientId: string;
81
- clientSecret: string;
82
- redirectUri: string;
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 import_node_crypto8 = require("crypto");
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 appleKeyCache = null;
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 getApplePublicKey(kid);
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 completeAppleLogin = async (ctx, claims) => {
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, "APPLE_AUTH_FAILED", "No email in Apple identity token");
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, "APPLE_AUTH_FAILED", "Apple account email is not verified");
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, "APPLE_AUTH_FAILED", "Invalid email in Apple identity token");
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, "apple", claims.sub ?? "");
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: "oauth-apple",
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", "Apple login failed");
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", "Apple login failed");
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
- await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
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: "oauth-apple",
3122
+ method,
3053
3123
  outcome: "success",
3054
3124
  ...meta
3055
3125
  });
3056
3126
  return Response.json(
3057
3127
  {
3058
- reason: "APPLE_AUTH_SUCCESS",
3059
- explanation: "Apple authentication successful.",
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, import_node_crypto8.randomBytes)(16).toString("hex");
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
- const normalizedEmail = normalizeEmailSafe(payload.email);
3177
- if (!normalizedEmail) {
3178
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
3179
- }
3180
- const meta = requestMeta(ctx);
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, import_node_crypto8.randomBytes)(16).toString("hex");
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 completeAppleLogin(ctx, {
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, import_node_crypto8.createHash)("sha256").update(identityToken).digest("hex");
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 completeAppleLogin(ctx, {
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, requireEmailLogin, import_middlewares2.requireVerified, (0, import_middlewares.validate)(mfaTokenSchema), mfa.verify),
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
- if (!clientSecret || !config.google.clientId || !config.google.redirectUri) {
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, or redirectUri is missing",
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.19.2";
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) };