@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/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([
@@ -2800,11 +2811,59 @@ function userController(store, config, bus) {
2800
2811
  }
2801
2812
 
2802
2813
  // src/controllers/oauth.controller.ts
2803
- import { randomBytes as randomBytes4, createPublicKey, createHash as createHash4 } from "crypto";
2814
+ import { randomBytes as randomBytes4, createHash as createHash4 } from "crypto";
2804
2815
  import jwt2 from "jsonwebtoken";
2805
2816
  import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
2806
2817
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
2807
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
+
2808
2867
  // src/models/consumed-token.model.ts
2809
2868
  var ConsumedTokenModel = class {
2810
2869
  constructor(store) {
@@ -2844,49 +2903,14 @@ function mintAppleClientSecret(apple) {
2844
2903
  { algorithm: "ES256", keyid: apple.keyId }
2845
2904
  );
2846
2905
  }
2847
- var appleKeyCache = null;
2848
- var appleKeyInflight = null;
2849
- var appleKeyLastAttempt = 0;
2850
- var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
2851
- var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
2852
- var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
2853
- function refreshAppleKeys() {
2854
- if (appleKeyInflight) return appleKeyInflight;
2855
- appleKeyLastAttempt = Date.now();
2856
- appleKeyInflight = (async () => {
2857
- try {
2858
- const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
2859
- const data = await res.json();
2860
- if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
2861
- } catch {
2862
- } finally {
2863
- appleKeyInflight = null;
2864
- }
2865
- })();
2866
- return appleKeyInflight;
2867
- }
2868
- async function getApplePublicKey(kid) {
2869
- const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
2870
- const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2871
- let jwk = find();
2872
- if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
2873
- await refreshAppleKeys();
2874
- jwk = find();
2875
- }
2876
- if (!jwk) return null;
2877
- try {
2878
- return createPublicKey({ key: jwk, format: "jwk" });
2879
- } catch {
2880
- return null;
2881
- }
2882
- }
2906
+ var appleKeys = createJwksSource(APPLE_KEYS_URL);
2883
2907
  async function verifyAppleIdToken(idToken, opts) {
2884
2908
  if (opts.audiences.length === 0) return null;
2885
2909
  const decoded = jwt2.decode(idToken, { complete: true });
2886
2910
  if (!decoded || typeof decoded === "string") return null;
2887
2911
  const kid = decoded.header.kid;
2888
2912
  if (!kid) return null;
2889
- const key = await getApplePublicKey(kid);
2913
+ const key = await appleKeys.getKey(kid);
2890
2914
  if (!key) return null;
2891
2915
  try {
2892
2916
  const payload = jwt2.verify(idToken, key, {
@@ -2904,6 +2928,30 @@ async function verifyAppleIdToken(idToken, opts) {
2904
2928
  }
2905
2929
  }
2906
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";
2907
2955
  function oauthController(store, config, bus) {
2908
2956
  const users = new UserModel(store);
2909
2957
  const sessions = new SessionModel(store, config.location);
@@ -2966,48 +3014,68 @@ function oauthController(store, config, bus) {
2966
3014
  ));
2967
3015
  }
2968
3016
  };
2969
- 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";
2970
3024
  const meta = requestMeta(ctx);
2971
3025
  if (!claims.email) {
2972
- 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`);
2973
3027
  }
2974
3028
  if (!claims.emailVerified) {
2975
- 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`);
2976
3030
  }
2977
3031
  const normalizedEmail = normalizeEmailSafe(claims.email);
2978
3032
  if (!normalizedEmail) {
2979
- 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`);
2980
3034
  }
2981
- const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
3035
+ const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
2982
3036
  if (!upserted) {
2983
3037
  await loginEvents.recordSafe({
2984
3038
  userId: null,
2985
3039
  emailAttempted: normalizedEmail,
2986
- method: "oauth-apple",
3040
+ method,
2987
3041
  outcome: "failed",
2988
3042
  failureReason: "provider_upsert_failed",
2989
3043
  ...meta
2990
3044
  });
2991
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
3045
+ return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
2992
3046
  }
2993
3047
  const fullUser = await users.findById(upserted.id);
2994
3048
  if (!fullUser) {
2995
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
3049
+ return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
2996
3050
  }
2997
- await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2998
- 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 });
2999
3067
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
3000
3068
  await loginEvents.recordSafe({
3001
3069
  userId: upserted.id,
3002
3070
  emailAttempted: normalizedEmail,
3003
- method: "oauth-apple",
3071
+ method,
3004
3072
  outcome: "success",
3005
3073
  ...meta
3006
3074
  });
3007
3075
  return Response.json(
3008
3076
  {
3009
- reason: "APPLE_AUTH_SUCCESS",
3010
- explanation: "Apple authentication successful.",
3077
+ reason: p.success,
3078
+ explanation: `${p.label} authentication successful.`,
3011
3079
  result: {
3012
3080
  tokens: { access: accessToken, refresh: refreshToken },
3013
3081
  user: toUserDTO(fullUser)
@@ -3015,18 +3083,16 @@ function oauthController(store, config, bus) {
3015
3083
  },
3016
3084
  {
3017
3085
  status: 200,
3018
- headers: cookieHeaders([
3019
- ...tokenPairCookies(accessToken, refreshToken, config),
3020
- // One-time value — clear the (SameSite=None) state cookie.
3021
- "oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
3022
- ])
3086
+ headers: cookieHeaders([...tokenPairCookies(accessToken, refreshToken, config), ...clearCookies])
3023
3087
  }
3024
3088
  );
3025
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";
3026
3092
  return {
3027
3093
  googleInit: async (_ctx) => {
3028
3094
  const google = config.google;
3029
- if (!google) {
3095
+ if (!google || !google.clientSecret || !google.redirectUri) {
3030
3096
  return setApiResponse5(
3031
3097
  HTTP5.NOT_IMPLEMENTED,
3032
3098
  "NOT_CONFIGURED",
@@ -3061,7 +3127,7 @@ function oauthController(store, config, bus) {
3061
3127
  },
3062
3128
  googleCallback: async (ctx) => {
3063
3129
  const google = config.google;
3064
- if (!google) {
3130
+ if (!google || !google.clientSecret || !google.redirectUri) {
3065
3131
  return setApiResponse5(
3066
3132
  HTTP5.NOT_IMPLEMENTED,
3067
3133
  "NOT_CONFIGURED",
@@ -3124,57 +3190,11 @@ function oauthController(store, config, bus) {
3124
3190
  "Google account email is not verified"
3125
3191
  );
3126
3192
  }
3127
- const normalizedEmail = normalizeEmailSafe(payload.email);
3128
- if (!normalizedEmail) {
3129
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
3130
- }
3131
- const meta = requestMeta(ctx);
3132
- const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
3133
- if (!upserted) {
3134
- await loginEvents.recordSafe({
3135
- userId: null,
3136
- emailAttempted: normalizedEmail,
3137
- method: "oauth-google",
3138
- outcome: "failed",
3139
- failureReason: "provider_upsert_failed",
3140
- ...meta
3141
- });
3142
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3143
- }
3144
- const fullUser = await users.findById(upserted.id);
3145
- if (!fullUser) {
3146
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3147
- }
3148
- await announceOAuthUpsert(ctx, upserted, "google", fullUser);
3149
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
3150
- loginMethod: "google"
3151
- });
3152
- await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
3153
- await loginEvents.recordSafe({
3154
- userId: upserted.id,
3155
- emailAttempted: normalizedEmail,
3156
- method: "oauth-google",
3157
- outcome: "success",
3158
- ...meta
3159
- });
3160
- return Response.json(
3161
- {
3162
- reason: "GOOGLE_AUTH_SUCCESS",
3163
- explanation: "Google authentication successful.",
3164
- result: {
3165
- tokens: { access: accessToken, refresh: refreshToken },
3166
- user: toUserDTO(fullUser)
3167
- }
3168
- },
3169
- {
3170
- status: 200,
3171
- headers: cookieHeaders([
3172
- ...tokenPairCookies(accessToken, refreshToken, config),
3173
- // One-time value — clear it once the flow completes.
3174
- "oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
3175
- ])
3176
- }
3177
- );
3193
+ return completeOAuthLogin(ctx, "google", {
3194
+ email: payload.email,
3195
+ sub: payload.sub,
3196
+ emailVerified: payload.email_verified === true
3197
+ }, [GOOGLE_STATE_CLEAR]);
3178
3198
  },
3179
3199
  appleInit: async (_ctx) => {
3180
3200
  const apple = config.apple;
@@ -3247,11 +3267,11 @@ function oauthController(store, config, bus) {
3247
3267
  if (!claims) {
3248
3268
  return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3249
3269
  }
3250
- return completeAppleLogin(ctx, {
3270
+ return completeOAuthLogin(ctx, "apple", {
3251
3271
  email: claims.email,
3252
3272
  sub: claims.sub,
3253
3273
  emailVerified: appleEmailVerified(claims)
3254
- });
3274
+ }, [APPLE_STATE_CLEAR]);
3255
3275
  },
3256
3276
  appleNative: async (ctx) => {
3257
3277
  const apple = config.apple;
@@ -3297,10 +3317,54 @@ function oauthController(store, config, bus) {
3297
3317
  );
3298
3318
  }
3299
3319
  consumedTokens.sweepExpiredSafe();
3300
- return completeAppleLogin(ctx, {
3320
+ return completeOAuthLogin(ctx, "apple", {
3301
3321
  email: claims.email,
3302
3322
  sub: claims.sub,
3303
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)
3304
3368
  });
3305
3369
  }
3306
3370
  };
@@ -3385,7 +3449,7 @@ function buildAuthRoutes(store, config, bus, locales) {
3385
3449
  R("mfaSetup", "POST", "/auth/mfa/setup", requireAuth, requireEmailLogin, requireVerified, mfa.setup),
3386
3450
  // /auth/mfa/verify accepts both mfaPending tokens (TOTP/backup-code login)
3387
3451
  // and full tokens (setup confirmation), so requireAnyAuth is used here.
3388
- 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),
3389
3453
  R("mfaDisable", "POST", "/auth/mfa/disable", requireAuth, requireEmailLogin, requireVerified, validate(mfaTokenSchema), mfa.disable),
3390
3454
  R("mfaBackupCodes", "POST", "/auth/mfa/backup-codes", requireAuth, requireEmailLogin, requireVerified, validate(mfaTokenSchema), mfa.regenerateBackupCodes)
3391
3455
  ];
@@ -3394,7 +3458,9 @@ function buildAuthRoutes(store, config, bus, locales) {
3394
3458
  ["GET", "/auth/google", oauth.googleInit],
3395
3459
  // ipLimit: an unauthenticated caller can otherwise force one outbound
3396
3460
  // token-exchange to Google per request (the state check doesn't gate it).
3397
- ["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]
3398
3464
  );
3399
3465
  }
3400
3466
  if (config.providers.includes("apple")) {
@@ -3460,15 +3526,18 @@ function collectAuthConfigProblems(config) {
3460
3526
  }
3461
3527
  if (config.google) {
3462
3528
  const clientSecret = config.google.clientSecret ?? "";
3463
- 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) {
3464
3533
  problems.push({
3465
3534
  module: MODULE,
3466
3535
  severity: "error",
3467
- 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)",
3468
3537
  domain: "auth",
3469
3538
  reason: "GOOGLE_INCOMPLETE"
3470
3539
  });
3471
- } else if (PLACEHOLDER_SECRET.test(clientSecret)) {
3540
+ } else if (clientSecret && PLACEHOLDER_SECRET.test(clientSecret)) {
3472
3541
  problems.push({
3473
3542
  module: MODULE,
3474
3543
  severity: "error",
@@ -3477,6 +3546,15 @@ function collectAuthConfigProblems(config) {
3477
3546
  reason: "GOOGLE_SECRET_PLACEHOLDER"
3478
3547
  });
3479
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
+ }
3480
3558
  }
3481
3559
  if (config.apple) {
3482
3560
  const a = config.apple;
@@ -3736,7 +3814,7 @@ var AuthModule = class {
3736
3814
  name = "@fonderie/auth";
3737
3815
  // Baked in at build time by tsup.base, so the operator's Modules page can
3738
3816
  // say what is actually deployed rather than 'not reported'.
3739
- version = "7.20.0";
3817
+ version = "7.21.0";
3740
3818
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3741
3819
  describeAdmin() {
3742
3820
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };