@fonderie/auth 7.19.2 → 7.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/brain/outcomes.md +2 -1
- package/brain/signatures.md +4 -3
- package/dist/index.cjs +201 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +198 -118
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-B-IgXFu8.d.cts → session-C1A21bvb.d.cts} +3 -2
- package/dist/{session-B-IgXFu8.d.ts → session-C1A21bvb.d.ts} +3 -2
- package/env.json +14 -7
- package/package.json +1 -1
package/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-
|
|
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-
|
|
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-
|
|
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-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
3033
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
|
|
2978
3034
|
}
|
|
2979
|
-
const upserted = await users.upsertByProvider(normalizedEmail,
|
|
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
|
|
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",
|
|
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",
|
|
3049
|
+
return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
|
|
2994
3050
|
}
|
|
2995
|
-
await announceOAuthUpsert(ctx, upserted,
|
|
2996
|
-
|
|
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
|
|
3071
|
+
method,
|
|
3002
3072
|
outcome: "success",
|
|
3003
3073
|
...meta
|
|
3004
3074
|
});
|
|
3005
3075
|
return Response.json(
|
|
3006
3076
|
{
|
|
3007
|
-
reason:
|
|
3008
|
-
explanation:
|
|
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
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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.
|
|
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) };
|