@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/dist/index.d.cts CHANGED
@@ -3,8 +3,8 @@ export { IMfaChallenge, ISession } from './types.cjs';
3
3
  import { IFonderieModule, IAdminDescription, IEventCatalogEntry, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
4
4
  import { IStoreAdapter } from '@fonderie/store';
5
5
  import { EventBus } from '@fonderie/events';
6
- import { I as IAuthConfig, a as IRequestLocation } from './session-B-IgXFu8.cjs';
7
- export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, C as CLIENT_KINDS, d as ClientKind, e as IAuthRateLimitConfig, f as IAuthRuntimeConfig, g as IAuthSecrets, h as IDataExportContributor, i as ILocationRequest, j as ISessionPolicy, L as LocationResolver, M as MESSAGE_KEYS, S as SESSION_POLICY_PRESETS, k as buildAuthAccountLimiter, l as buildAuthIpLimiter, m as clientKindOf, n as configForClient, r as readAuthRuntimeConfig, o as resolveLocation, s as sanitizeLocation, p as sessionPolicyConfigKeys, w as withSession } from './session-B-IgXFu8.cjs';
6
+ import { I as IAuthConfig, a as IRequestLocation } from './session-C1A21bvb.cjs';
7
+ export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, C as CLIENT_KINDS, d as ClientKind, e as IAuthRateLimitConfig, f as IAuthRuntimeConfig, g as IAuthSecrets, h as IDataExportContributor, i as ILocationRequest, j as ISessionPolicy, L as LocationResolver, M as MESSAGE_KEYS, S as SESSION_POLICY_PRESETS, k as buildAuthAccountLimiter, l as buildAuthIpLimiter, m as clientKindOf, n as configForClient, r as readAuthRuntimeConfig, o as resolveLocation, s as sanitizeLocation, p as sessionPolicyConfigKeys, w as withSession } from './session-C1A21bvb.cjs';
8
8
  import { IUserDTO } from './dtos/user.cjs';
9
9
  export { toUserDTO } from './dtos/user.cjs';
10
10
  export { requireAuth, validate } from '@fonderie/core/middlewares';
@@ -75,6 +75,10 @@ declare const appleNativeSchema: z.ZodObject<{
75
75
  identityToken: z.ZodString;
76
76
  nonce: z.ZodOptional<z.ZodString>;
77
77
  }, z.core.$strip>;
78
+ declare const googleNativeSchema: z.ZodObject<{
79
+ idToken: z.ZodString;
80
+ nonce: z.ZodOptional<z.ZodString>;
81
+ }, z.core.$strip>;
78
82
  declare const refreshSchema: z.ZodObject<{
79
83
  refreshToken: z.ZodOptional<z.ZodString>;
80
84
  }, z.core.$strip>;
@@ -134,6 +138,7 @@ type schemas_ResetPasswordInput = ResetPasswordInput;
134
138
  declare const schemas_appleNativeSchema: typeof appleNativeSchema;
135
139
  declare const schemas_changePasswordSchema: typeof changePasswordSchema;
136
140
  declare const schemas_forgotPasswordSchema: typeof forgotPasswordSchema;
141
+ declare const schemas_googleNativeSchema: typeof googleNativeSchema;
137
142
  declare const schemas_loginSchema: typeof loginSchema;
138
143
  declare const schemas_mfaTokenSchema: typeof mfaTokenSchema;
139
144
  declare const schemas_refreshSchema: typeof refreshSchema;
@@ -145,7 +150,7 @@ declare const schemas_updatePreferencesSchema: typeof updatePreferencesSchema;
145
150
  declare const schemas_updateProfileSchema: typeof updateProfileSchema;
146
151
  declare const schemas_verifySchema: typeof verifySchema;
147
152
  declare namespace schemas {
148
- export { type schemas_ChangePasswordInput as ChangePasswordInput, type schemas_LoginInput as LoginInput, type schemas_RegisterInput as RegisterInput, type schemas_ResetPasswordInput as ResetPasswordInput, schemas_appleNativeSchema as appleNativeSchema, schemas_changePasswordSchema as changePasswordSchema, schemas_forgotPasswordSchema as forgotPasswordSchema, schemas_loginSchema as loginSchema, schemas_mfaTokenSchema as mfaTokenSchema, schemas_refreshSchema as refreshSchema, schemas_registerSchema as registerSchema, schemas_resetPasswordSchema as resetPasswordSchema, schemas_updateEmailSchema as updateEmailSchema, schemas_updatePhoneSchema as updatePhoneSchema, schemas_updatePreferencesSchema as updatePreferencesSchema, schemas_updateProfileSchema as updateProfileSchema, schemas_verifySchema as verifySchema };
153
+ export { type schemas_ChangePasswordInput as ChangePasswordInput, type schemas_LoginInput as LoginInput, type schemas_RegisterInput as RegisterInput, type schemas_ResetPasswordInput as ResetPasswordInput, schemas_appleNativeSchema as appleNativeSchema, schemas_changePasswordSchema as changePasswordSchema, schemas_forgotPasswordSchema as forgotPasswordSchema, schemas_googleNativeSchema as googleNativeSchema, schemas_loginSchema as loginSchema, schemas_mfaTokenSchema as mfaTokenSchema, schemas_refreshSchema as refreshSchema, schemas_registerSchema as registerSchema, schemas_resetPasswordSchema as resetPasswordSchema, schemas_updateEmailSchema as updateEmailSchema, schemas_updatePhoneSchema as updatePhoneSchema, schemas_updatePreferencesSchema as updatePreferencesSchema, schemas_updateProfileSchema as updateProfileSchema, schemas_verifySchema as verifySchema };
149
154
  }
150
155
 
151
156
  declare function normalizeEmail(email: string): string;
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export { IMfaChallenge, ISession } from './types.js';
3
3
  import { IFonderieModule, IAdminDescription, IEventCatalogEntry, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
4
4
  import { IStoreAdapter } from '@fonderie/store';
5
5
  import { EventBus } from '@fonderie/events';
6
- import { I as IAuthConfig, a as IRequestLocation } from './session-B-IgXFu8.js';
7
- export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, C as CLIENT_KINDS, d as ClientKind, e as IAuthRateLimitConfig, f as IAuthRuntimeConfig, g as IAuthSecrets, h as IDataExportContributor, i as ILocationRequest, j as ISessionPolicy, L as LocationResolver, M as MESSAGE_KEYS, S as SESSION_POLICY_PRESETS, k as buildAuthAccountLimiter, l as buildAuthIpLimiter, m as clientKindOf, n as configForClient, r as readAuthRuntimeConfig, o as resolveLocation, s as sanitizeLocation, p as sessionPolicyConfigKeys, w as withSession } from './session-B-IgXFu8.js';
6
+ import { I as IAuthConfig, a as IRequestLocation } from './session-C1A21bvb.js';
7
+ export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, C as CLIENT_KINDS, d as ClientKind, e as IAuthRateLimitConfig, f as IAuthRuntimeConfig, g as IAuthSecrets, h as IDataExportContributor, i as ILocationRequest, j as ISessionPolicy, L as LocationResolver, M as MESSAGE_KEYS, S as SESSION_POLICY_PRESETS, k as buildAuthAccountLimiter, l as buildAuthIpLimiter, m as clientKindOf, n as configForClient, r as readAuthRuntimeConfig, o as resolveLocation, s as sanitizeLocation, p as sessionPolicyConfigKeys, w as withSession } from './session-C1A21bvb.js';
8
8
  import { IUserDTO } from './dtos/user.js';
9
9
  export { toUserDTO } from './dtos/user.js';
10
10
  export { requireAuth, validate } from '@fonderie/core/middlewares';
@@ -75,6 +75,10 @@ declare const appleNativeSchema: z.ZodObject<{
75
75
  identityToken: z.ZodString;
76
76
  nonce: z.ZodOptional<z.ZodString>;
77
77
  }, z.core.$strip>;
78
+ declare const googleNativeSchema: z.ZodObject<{
79
+ idToken: z.ZodString;
80
+ nonce: z.ZodOptional<z.ZodString>;
81
+ }, z.core.$strip>;
78
82
  declare const refreshSchema: z.ZodObject<{
79
83
  refreshToken: z.ZodOptional<z.ZodString>;
80
84
  }, z.core.$strip>;
@@ -134,6 +138,7 @@ type schemas_ResetPasswordInput = ResetPasswordInput;
134
138
  declare const schemas_appleNativeSchema: typeof appleNativeSchema;
135
139
  declare const schemas_changePasswordSchema: typeof changePasswordSchema;
136
140
  declare const schemas_forgotPasswordSchema: typeof forgotPasswordSchema;
141
+ declare const schemas_googleNativeSchema: typeof googleNativeSchema;
137
142
  declare const schemas_loginSchema: typeof loginSchema;
138
143
  declare const schemas_mfaTokenSchema: typeof mfaTokenSchema;
139
144
  declare const schemas_refreshSchema: typeof refreshSchema;
@@ -145,7 +150,7 @@ declare const schemas_updatePreferencesSchema: typeof updatePreferencesSchema;
145
150
  declare const schemas_updateProfileSchema: typeof updateProfileSchema;
146
151
  declare const schemas_verifySchema: typeof verifySchema;
147
152
  declare namespace schemas {
148
- export { type schemas_ChangePasswordInput as ChangePasswordInput, type schemas_LoginInput as LoginInput, type schemas_RegisterInput as RegisterInput, type schemas_ResetPasswordInput as ResetPasswordInput, schemas_appleNativeSchema as appleNativeSchema, schemas_changePasswordSchema as changePasswordSchema, schemas_forgotPasswordSchema as forgotPasswordSchema, schemas_loginSchema as loginSchema, schemas_mfaTokenSchema as mfaTokenSchema, schemas_refreshSchema as refreshSchema, schemas_registerSchema as registerSchema, schemas_resetPasswordSchema as resetPasswordSchema, schemas_updateEmailSchema as updateEmailSchema, schemas_updatePhoneSchema as updatePhoneSchema, schemas_updatePreferencesSchema as updatePreferencesSchema, schemas_updateProfileSchema as updateProfileSchema, schemas_verifySchema as verifySchema };
153
+ export { type schemas_ChangePasswordInput as ChangePasswordInput, type schemas_LoginInput as LoginInput, type schemas_RegisterInput as RegisterInput, type schemas_ResetPasswordInput as ResetPasswordInput, schemas_appleNativeSchema as appleNativeSchema, schemas_changePasswordSchema as changePasswordSchema, schemas_forgotPasswordSchema as forgotPasswordSchema, schemas_googleNativeSchema as googleNativeSchema, schemas_loginSchema as loginSchema, schemas_mfaTokenSchema as mfaTokenSchema, schemas_refreshSchema as refreshSchema, schemas_registerSchema as registerSchema, schemas_resetPasswordSchema as resetPasswordSchema, schemas_updateEmailSchema as updateEmailSchema, schemas_updatePhoneSchema as updatePhoneSchema, schemas_updatePreferencesSchema as updatePreferencesSchema, schemas_updateProfileSchema as updateProfileSchema, schemas_verifySchema as verifySchema };
149
154
  }
150
155
 
151
156
  declare function normalizeEmail(email: string): string;
package/dist/index.js CHANGED
@@ -59,6 +59,10 @@ var requireEmailLogin = async (ctx, next) => {
59
59
  }
60
60
  return next();
61
61
  };
62
+ var requireEmailLoginUnlessSigningIn = async (ctx, next) => {
63
+ if (ctx.user.mfaPending) return next();
64
+ return requireEmailLogin(ctx, next);
65
+ };
62
66
 
63
67
  // src/middlewares/validate.ts
64
68
  import { validate } from "@fonderie/core/middlewares";
@@ -132,6 +136,7 @@ __export(schemas_exports, {
132
136
  appleNativeSchema: () => appleNativeSchema,
133
137
  changePasswordSchema: () => changePasswordSchema,
134
138
  forgotPasswordSchema: () => forgotPasswordSchema,
139
+ googleNativeSchema: () => googleNativeSchema,
135
140
  loginSchema: () => loginSchema,
136
141
  mfaTokenSchema: () => mfaTokenSchema,
137
142
  refreshSchema: () => refreshSchema,
@@ -169,6 +174,12 @@ var appleNativeSchema = z.object({
169
174
  // it against the token's `nonce` claim to defend against replay.
170
175
  nonce: z.string().max(256).optional()
171
176
  });
177
+ var googleNativeSchema = z.object({
178
+ idToken: z.string().min(1).max(8192),
179
+ // Optional nonce the app passed to the Google SDK; checked against the
180
+ // token's `nonce` claim (binds the token to this request).
181
+ nonce: z.string().max(256).optional()
182
+ });
172
183
  var refreshSchema = z.object({ refreshToken: z.string().min(1).optional() });
173
184
  var forgotPasswordSchema = z.object({ email });
174
185
  var resetPasswordSchema = z.union([
@@ -2744,12 +2755,14 @@ function userController(store, config, bus) {
2744
2755
  deleteMe: async (ctx) => {
2745
2756
  const userId = ctx.user.id;
2746
2757
  await users.softDelete(userId);
2758
+ await sessions.deleteByUser(userId);
2747
2759
  const reqId = ctx.meta["requestId"];
2748
2760
  await background4(bus?.emit(
2749
2761
  EVENT_KEYS.userDeleted,
2750
2762
  { userId },
2751
2763
  reqId !== void 0 ? { requestId: reqId } : void 0
2752
2764
  ));
2765
+ await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2753
2766
  return Response.json(
2754
2767
  { reason: "ACCOUNT_DELETED", explanation: "Account successfully deleted." },
2755
2768
  {
@@ -2798,11 +2811,59 @@ function userController(store, config, bus) {
2798
2811
  }
2799
2812
 
2800
2813
  // src/controllers/oauth.controller.ts
2801
- import { randomBytes as randomBytes4, createPublicKey, createHash as createHash4 } from "crypto";
2814
+ import { randomBytes as randomBytes4, createHash as createHash4 } from "crypto";
2802
2815
  import jwt2 from "jsonwebtoken";
2803
2816
  import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
2804
2817
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
2805
2818
 
2819
+ // src/services/jwks.ts
2820
+ import { createPublicKey } from "crypto";
2821
+ var TTL_MS = 60 * 60 * 1e3;
2822
+ var MIN_REFETCH_MS = 60 * 1e3;
2823
+ var FETCH_TIMEOUT_MS = 5e3;
2824
+ function createJwksSource(url) {
2825
+ let cache = null;
2826
+ let inflight = null;
2827
+ let lastAttempt = 0;
2828
+ const refresh = () => {
2829
+ if (inflight) return inflight;
2830
+ lastAttempt = Date.now();
2831
+ inflight = (async () => {
2832
+ try {
2833
+ const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
2834
+ const data = await res.json();
2835
+ if (Array.isArray(data.keys)) cache = { keys: data.keys, fetchedAt: Date.now() };
2836
+ } catch {
2837
+ } finally {
2838
+ inflight = null;
2839
+ }
2840
+ })();
2841
+ return inflight;
2842
+ };
2843
+ return {
2844
+ async getKey(kid) {
2845
+ const find = () => cache?.keys.find((k) => k.kid === kid) ?? null;
2846
+ const fresh = () => !!cache && Date.now() - cache.fetchedAt < TTL_MS;
2847
+ let jwk = find();
2848
+ if ((!jwk || !fresh()) && Date.now() - lastAttempt >= MIN_REFETCH_MS) {
2849
+ await refresh();
2850
+ jwk = find();
2851
+ }
2852
+ if (!jwk) return null;
2853
+ try {
2854
+ return createPublicKey({ key: jwk, format: "jwk" });
2855
+ } catch {
2856
+ return null;
2857
+ }
2858
+ },
2859
+ reset() {
2860
+ cache = null;
2861
+ inflight = null;
2862
+ lastAttempt = 0;
2863
+ }
2864
+ };
2865
+ }
2866
+
2806
2867
  // src/models/consumed-token.model.ts
2807
2868
  var ConsumedTokenModel = class {
2808
2869
  constructor(store) {
@@ -2842,49 +2903,14 @@ function mintAppleClientSecret(apple) {
2842
2903
  { algorithm: "ES256", keyid: apple.keyId }
2843
2904
  );
2844
2905
  }
2845
- var appleKeyCache = null;
2846
- var appleKeyInflight = null;
2847
- var appleKeyLastAttempt = 0;
2848
- var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
2849
- var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
2850
- var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
2851
- function refreshAppleKeys() {
2852
- if (appleKeyInflight) return appleKeyInflight;
2853
- appleKeyLastAttempt = Date.now();
2854
- appleKeyInflight = (async () => {
2855
- try {
2856
- const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
2857
- const data = await res.json();
2858
- if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
2859
- } catch {
2860
- } finally {
2861
- appleKeyInflight = null;
2862
- }
2863
- })();
2864
- return appleKeyInflight;
2865
- }
2866
- async function getApplePublicKey(kid) {
2867
- const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
2868
- const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2869
- let jwk = find();
2870
- if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
2871
- await refreshAppleKeys();
2872
- jwk = find();
2873
- }
2874
- if (!jwk) return null;
2875
- try {
2876
- return createPublicKey({ key: jwk, format: "jwk" });
2877
- } catch {
2878
- return null;
2879
- }
2880
- }
2906
+ var appleKeys = createJwksSource(APPLE_KEYS_URL);
2881
2907
  async function verifyAppleIdToken(idToken, opts) {
2882
2908
  if (opts.audiences.length === 0) return null;
2883
2909
  const decoded = jwt2.decode(idToken, { complete: true });
2884
2910
  if (!decoded || typeof decoded === "string") return null;
2885
2911
  const kid = decoded.header.kid;
2886
2912
  if (!kid) return null;
2887
- const key = await getApplePublicKey(kid);
2913
+ const key = await appleKeys.getKey(kid);
2888
2914
  if (!key) return null;
2889
2915
  try {
2890
2916
  const payload = jwt2.verify(idToken, key, {
@@ -2902,6 +2928,30 @@ async function verifyAppleIdToken(idToken, opts) {
2902
2928
  }
2903
2929
  }
2904
2930
  var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2931
+ var GOOGLE_KEYS_URL = "https://www.googleapis.com/oauth2/v3/certs";
2932
+ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
2933
+ var googleKeys = createJwksSource(GOOGLE_KEYS_URL);
2934
+ async function verifyGoogleIdToken(idToken, opts) {
2935
+ if (opts.audiences.length === 0) return null;
2936
+ const decoded = jwt2.decode(idToken, { complete: true });
2937
+ if (!decoded || typeof decoded === "string") return null;
2938
+ const kid = decoded.header.kid;
2939
+ if (!kid) return null;
2940
+ const key = await googleKeys.getKey(kid);
2941
+ if (!key) return null;
2942
+ try {
2943
+ const payload = jwt2.verify(idToken, key, {
2944
+ algorithms: ["RS256"],
2945
+ issuer: [...GOOGLE_ISSUERS],
2946
+ audience: opts.audiences
2947
+ });
2948
+ if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) return null;
2949
+ return payload;
2950
+ } catch {
2951
+ return null;
2952
+ }
2953
+ }
2954
+ var googleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2905
2955
  function oauthController(store, config, bus) {
2906
2956
  const users = new UserModel(store);
2907
2957
  const sessions = new SessionModel(store, config.location);
@@ -2964,48 +3014,68 @@ function oauthController(store, config, bus) {
2964
3014
  ));
2965
3015
  }
2966
3016
  };
2967
- const completeAppleLogin = async (ctx, claims) => {
3017
+ const PROVIDER = {
3018
+ apple: { label: "Apple", failed: "APPLE_AUTH_FAILED", success: "APPLE_AUTH_SUCCESS" },
3019
+ google: { label: "Google", failed: "GOOGLE_AUTH_FAILED", success: "GOOGLE_AUTH_SUCCESS" }
3020
+ };
3021
+ const completeOAuthLogin = async (ctx, provider, claims, clearCookies = []) => {
3022
+ const p = PROVIDER[provider];
3023
+ const method = provider === "apple" ? "oauth-apple" : "oauth-google";
2968
3024
  const meta = requestMeta(ctx);
2969
3025
  if (!claims.email) {
2970
- return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "No email in Apple identity token");
3026
+ return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
2971
3027
  }
2972
3028
  if (!claims.emailVerified) {
2973
- return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Apple account email is not verified");
3029
+ return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
2974
3030
  }
2975
3031
  const normalizedEmail = normalizeEmailSafe(claims.email);
2976
3032
  if (!normalizedEmail) {
2977
- return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid email in Apple identity token");
3033
+ return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
2978
3034
  }
2979
- const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
3035
+ const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
2980
3036
  if (!upserted) {
2981
3037
  await loginEvents.recordSafe({
2982
3038
  userId: null,
2983
3039
  emailAttempted: normalizedEmail,
2984
- method: "oauth-apple",
3040
+ method,
2985
3041
  outcome: "failed",
2986
3042
  failureReason: "provider_upsert_failed",
2987
3043
  ...meta
2988
3044
  });
2989
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
3045
+ return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
2990
3046
  }
2991
3047
  const fullUser = await users.findById(upserted.id);
2992
3048
  if (!fullUser) {
2993
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
3049
+ return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
2994
3050
  }
2995
- await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2996
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: "apple" });
3051
+ await announceOAuthUpsert(ctx, upserted, provider, fullUser);
3052
+ if (fullUser.suspended) {
3053
+ return setApiResponse5(HTTP5.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3054
+ }
3055
+ if (fullUser.mfaEnabled) {
3056
+ const mfaToken = issueMfaPendingToken(upserted.id, config, provider);
3057
+ return Response.json(
3058
+ {
3059
+ reason: "MFA_REQUIRED",
3060
+ explanation: "Multi-factor authentication required",
3061
+ result: { mfaToken }
3062
+ },
3063
+ { status: 200, headers: cookieHeaders(clearCookies) }
3064
+ );
3065
+ }
3066
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: provider });
2997
3067
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2998
3068
  await loginEvents.recordSafe({
2999
3069
  userId: upserted.id,
3000
3070
  emailAttempted: normalizedEmail,
3001
- method: "oauth-apple",
3071
+ method,
3002
3072
  outcome: "success",
3003
3073
  ...meta
3004
3074
  });
3005
3075
  return Response.json(
3006
3076
  {
3007
- reason: "APPLE_AUTH_SUCCESS",
3008
- explanation: "Apple authentication successful.",
3077
+ reason: p.success,
3078
+ explanation: `${p.label} authentication successful.`,
3009
3079
  result: {
3010
3080
  tokens: { access: accessToken, refresh: refreshToken },
3011
3081
  user: toUserDTO(fullUser)
@@ -3013,18 +3083,16 @@ function oauthController(store, config, bus) {
3013
3083
  },
3014
3084
  {
3015
3085
  status: 200,
3016
- headers: cookieHeaders([
3017
- ...tokenPairCookies(accessToken, refreshToken, config),
3018
- // One-time value — clear the (SameSite=None) state cookie.
3019
- "oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
3020
- ])
3086
+ headers: cookieHeaders([...tokenPairCookies(accessToken, refreshToken, config), ...clearCookies])
3021
3087
  }
3022
3088
  );
3023
3089
  };
3090
+ const APPLE_STATE_CLEAR = "oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0";
3091
+ const GOOGLE_STATE_CLEAR = "oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0";
3024
3092
  return {
3025
3093
  googleInit: async (_ctx) => {
3026
3094
  const google = config.google;
3027
- if (!google) {
3095
+ if (!google || !google.clientSecret || !google.redirectUri) {
3028
3096
  return setApiResponse5(
3029
3097
  HTTP5.NOT_IMPLEMENTED,
3030
3098
  "NOT_CONFIGURED",
@@ -3059,7 +3127,7 @@ function oauthController(store, config, bus) {
3059
3127
  },
3060
3128
  googleCallback: async (ctx) => {
3061
3129
  const google = config.google;
3062
- if (!google) {
3130
+ if (!google || !google.clientSecret || !google.redirectUri) {
3063
3131
  return setApiResponse5(
3064
3132
  HTTP5.NOT_IMPLEMENTED,
3065
3133
  "NOT_CONFIGURED",
@@ -3122,57 +3190,11 @@ function oauthController(store, config, bus) {
3122
3190
  "Google account email is not verified"
3123
3191
  );
3124
3192
  }
3125
- const normalizedEmail = normalizeEmailSafe(payload.email);
3126
- if (!normalizedEmail) {
3127
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
3128
- }
3129
- const meta = requestMeta(ctx);
3130
- const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
3131
- if (!upserted) {
3132
- await loginEvents.recordSafe({
3133
- userId: null,
3134
- emailAttempted: normalizedEmail,
3135
- method: "oauth-google",
3136
- outcome: "failed",
3137
- failureReason: "provider_upsert_failed",
3138
- ...meta
3139
- });
3140
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3141
- }
3142
- const fullUser = await users.findById(upserted.id);
3143
- if (!fullUser) {
3144
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3145
- }
3146
- await announceOAuthUpsert(ctx, upserted, "google", fullUser);
3147
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
3148
- loginMethod: "google"
3149
- });
3150
- await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
3151
- await loginEvents.recordSafe({
3152
- userId: upserted.id,
3153
- emailAttempted: normalizedEmail,
3154
- method: "oauth-google",
3155
- outcome: "success",
3156
- ...meta
3157
- });
3158
- return Response.json(
3159
- {
3160
- reason: "GOOGLE_AUTH_SUCCESS",
3161
- explanation: "Google authentication successful.",
3162
- result: {
3163
- tokens: { access: accessToken, refresh: refreshToken },
3164
- user: toUserDTO(fullUser)
3165
- }
3166
- },
3167
- {
3168
- status: 200,
3169
- headers: cookieHeaders([
3170
- ...tokenPairCookies(accessToken, refreshToken, config),
3171
- // One-time value — clear it once the flow completes.
3172
- "oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
3173
- ])
3174
- }
3175
- );
3193
+ return completeOAuthLogin(ctx, "google", {
3194
+ email: payload.email,
3195
+ sub: payload.sub,
3196
+ emailVerified: payload.email_verified === true
3197
+ }, [GOOGLE_STATE_CLEAR]);
3176
3198
  },
3177
3199
  appleInit: async (_ctx) => {
3178
3200
  const apple = config.apple;
@@ -3245,11 +3267,11 @@ function oauthController(store, config, bus) {
3245
3267
  if (!claims) {
3246
3268
  return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3247
3269
  }
3248
- return completeAppleLogin(ctx, {
3270
+ return completeOAuthLogin(ctx, "apple", {
3249
3271
  email: claims.email,
3250
3272
  sub: claims.sub,
3251
3273
  emailVerified: appleEmailVerified(claims)
3252
- });
3274
+ }, [APPLE_STATE_CLEAR]);
3253
3275
  },
3254
3276
  appleNative: async (ctx) => {
3255
3277
  const apple = config.apple;
@@ -3295,10 +3317,54 @@ function oauthController(store, config, bus) {
3295
3317
  );
3296
3318
  }
3297
3319
  consumedTokens.sweepExpiredSafe();
3298
- return completeAppleLogin(ctx, {
3320
+ return completeOAuthLogin(ctx, "apple", {
3299
3321
  email: claims.email,
3300
3322
  sub: claims.sub,
3301
3323
  emailVerified: appleEmailVerified(claims)
3324
+ }, [APPLE_STATE_CLEAR]);
3325
+ },
3326
+ // Sign in with Google from a native app: the Google SDK's ID token,
3327
+ // verified here (signature, issuer, audience, expiry), redeemed once.
3328
+ googleNative: async (ctx) => {
3329
+ const audiences = config.google?.nativeClientIds ?? [];
3330
+ if (!config.google || audiences.length === 0) {
3331
+ return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3332
+ }
3333
+ const body = ctx.meta["body"];
3334
+ const idToken = body?.idToken;
3335
+ if (!idToken) {
3336
+ return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3337
+ }
3338
+ const claims = await verifyGoogleIdToken(idToken, { audiences, nonce: body?.nonce });
3339
+ if (!claims) {
3340
+ await loginEvents.recordSafe({
3341
+ userId: null,
3342
+ emailAttempted: null,
3343
+ method: "oauth-google",
3344
+ outcome: "failed",
3345
+ failureReason: "invalid_identity_token",
3346
+ ...requestMeta(ctx)
3347
+ });
3348
+ return setApiResponse5(HTTP5.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3349
+ }
3350
+ const tokenHash = createHash4("sha256").update(idToken).digest("hex");
3351
+ const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3352
+ if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
3353
+ await loginEvents.recordSafe({
3354
+ userId: null,
3355
+ emailAttempted: claims.email ?? null,
3356
+ method: "oauth-google",
3357
+ outcome: "failed",
3358
+ failureReason: "token_replayed",
3359
+ ...requestMeta(ctx)
3360
+ });
3361
+ return setApiResponse5(HTTP5.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3362
+ }
3363
+ consumedTokens.sweepExpiredSafe();
3364
+ return completeOAuthLogin(ctx, "google", {
3365
+ email: claims.email,
3366
+ sub: claims.sub,
3367
+ emailVerified: googleEmailVerified(claims)
3302
3368
  });
3303
3369
  }
3304
3370
  };
@@ -3383,7 +3449,7 @@ function buildAuthRoutes(store, config, bus, locales) {
3383
3449
  R("mfaSetup", "POST", "/auth/mfa/setup", requireAuth, requireEmailLogin, requireVerified, mfa.setup),
3384
3450
  // /auth/mfa/verify accepts both mfaPending tokens (TOTP/backup-code login)
3385
3451
  // and full tokens (setup confirmation), so requireAnyAuth is used here.
3386
- R("mfaVerify", "POST", "/auth/mfa/verify", ipLimit("mfaVerify"), requireAnyAuth, requireEmailLogin, requireVerified, validate(mfaTokenSchema), mfa.verify),
3452
+ R("mfaVerify", "POST", "/auth/mfa/verify", ipLimit("mfaVerify"), requireAnyAuth, requireEmailLoginUnlessSigningIn, requireVerified, validate(mfaTokenSchema), mfa.verify),
3387
3453
  R("mfaDisable", "POST", "/auth/mfa/disable", requireAuth, requireEmailLogin, requireVerified, validate(mfaTokenSchema), mfa.disable),
3388
3454
  R("mfaBackupCodes", "POST", "/auth/mfa/backup-codes", requireAuth, requireEmailLogin, requireVerified, validate(mfaTokenSchema), mfa.regenerateBackupCodes)
3389
3455
  ];
@@ -3392,7 +3458,9 @@ function buildAuthRoutes(store, config, bus, locales) {
3392
3458
  ["GET", "/auth/google", oauth.googleInit],
3393
3459
  // ipLimit: an unauthenticated caller can otherwise force one outbound
3394
3460
  // token-exchange to Google per request (the state check doesn't gate it).
3395
- ["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
3461
+ ["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback],
3462
+ // Native (Android/iOS) flow: the app posts the Google SDK's ID token.
3463
+ ["POST", "/auth/google/native", ipLimit("login"), validate(googleNativeSchema), oauth.googleNative]
3396
3464
  );
3397
3465
  }
3398
3466
  if (config.providers.includes("apple")) {
@@ -3458,15 +3526,18 @@ function collectAuthConfigProblems(config) {
3458
3526
  }
3459
3527
  if (config.google) {
3460
3528
  const clientSecret = config.google.clientSecret ?? "";
3461
- if (!clientSecret || !config.google.clientId || !config.google.redirectUri) {
3529
+ const web = Boolean(clientSecret || config.google.redirectUri);
3530
+ const webComplete = Boolean(clientSecret && config.google.redirectUri);
3531
+ const native = (config.google.nativeClientIds ?? []).filter(Boolean).length > 0;
3532
+ if (!config.google.clientId || web && !webComplete || !webComplete && !native) {
3462
3533
  problems.push({
3463
3534
  module: MODULE,
3464
3535
  severity: "error",
3465
- message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing",
3536
+ message: "google OAuth is configured but incomplete: set clientId, and clientSecret + redirectUri (web sign-in) and/or nativeClientIds (app sign-in)",
3466
3537
  domain: "auth",
3467
3538
  reason: "GOOGLE_INCOMPLETE"
3468
3539
  });
3469
- } else if (PLACEHOLDER_SECRET.test(clientSecret)) {
3540
+ } else if (clientSecret && PLACEHOLDER_SECRET.test(clientSecret)) {
3470
3541
  problems.push({
3471
3542
  module: MODULE,
3472
3543
  severity: "error",
@@ -3475,6 +3546,15 @@ function collectAuthConfigProblems(config) {
3475
3546
  reason: "GOOGLE_SECRET_PLACEHOLDER"
3476
3547
  });
3477
3548
  }
3549
+ if (config.google.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
3550
+ problems.push({
3551
+ module: MODULE,
3552
+ severity: "error",
3553
+ message: "google.nativeClientIds must be exact client ids \u2014 empty or wildcard entries would accept ID tokens minted for other apps",
3554
+ domain: "auth",
3555
+ reason: "GOOGLE_NATIVE_IDS_INVALID"
3556
+ });
3557
+ }
3478
3558
  }
3479
3559
  if (config.apple) {
3480
3560
  const a = config.apple;
@@ -3734,7 +3814,7 @@ var AuthModule = class {
3734
3814
  name = "@fonderie/auth";
3735
3815
  // Baked in at build time by tsup.base, so the operator's Modules page can
3736
3816
  // say what is actually deployed rather than 'not reported'.
3737
- version = "7.19.2";
3817
+ version = "7.21.0";
3738
3818
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3739
3819
  describeAdmin() {
3740
3820
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };