@fonderie/auth 7.20.0 → 7.21.1

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([
@@ -2851,11 +2862,59 @@ function userController(store, config, bus) {
2851
2862
  }
2852
2863
 
2853
2864
  // src/controllers/oauth.controller.ts
2854
- var import_node_crypto8 = require("crypto");
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 appleKeyCache = null;
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 getApplePublicKey(kid);
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 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";
3021
3075
  const meta = requestMeta(ctx);
3022
3076
  if (!claims.email) {
3023
- 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`);
3024
3078
  }
3025
3079
  if (!claims.emailVerified) {
3026
- 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`);
3027
3081
  }
3028
3082
  const normalizedEmail = normalizeEmailSafe(claims.email);
3029
3083
  if (!normalizedEmail) {
3030
- 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`);
3031
3085
  }
3032
- const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
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: "oauth-apple",
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", "Apple login failed");
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", "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
+ );
3047
3116
  }
3048
- await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
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: "oauth-apple",
3122
+ method,
3055
3123
  outcome: "success",
3056
3124
  ...meta
3057
3125
  });
3058
3126
  return Response.json(
3059
3127
  {
3060
- reason: "APPLE_AUTH_SUCCESS",
3061
- explanation: "Apple authentication successful.",
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, import_node_crypto8.randomBytes)(16).toString("hex");
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
- const normalizedEmail = normalizeEmailSafe(payload.email);
3179
- if (!normalizedEmail) {
3180
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
3181
- }
3182
- const meta = requestMeta(ctx);
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, import_node_crypto8.randomBytes)(16).toString("hex");
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 completeAppleLogin(ctx, {
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, import_node_crypto8.createHash)("sha256").update(identityToken).digest("hex");
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 completeAppleLogin(ctx, {
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, 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),
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
- 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) {
3515
3584
  problems.push({
3516
3585
  module: MODULE,
3517
3586
  severity: "error",
3518
- 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)",
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.20.0";
3868
+ version = "7.21.1";
3791
3869
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3792
3870
  describeAdmin() {
3793
3871
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };