@mostajs/auth 2.5.2 → 3.0.2
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/README.md +838 -57
- package/dist/components/MfaChallenge.d.ts +17 -0
- package/dist/components/MfaChallenge.js +55 -0
- package/dist/components/MfaEnrollDialog.d.ts +18 -0
- package/dist/components/MfaEnrollDialog.js +72 -0
- package/dist/components/PasskeyLoginButton.d.ts +20 -0
- package/dist/components/PasskeyLoginButton.js +53 -0
- package/dist/components/PasskeyRegisterButton.d.ts +26 -0
- package/dist/components/PasskeyRegisterButton.js +47 -0
- package/dist/lib/account-lifecycle.d.ts +130 -0
- package/dist/lib/account-lifecycle.js +136 -0
- package/dist/lib/auth-events.d.ts +40 -0
- package/dist/lib/auth-events.js +37 -0
- package/dist/lib/auth-rate-limit.d.ts +80 -0
- package/dist/lib/auth-rate-limit.js +100 -0
- package/dist/lib/credentials-verify.d.ts +13 -0
- package/dist/lib/credentials-verify.js +14 -0
- package/dist/lib/magic-link.d.ts +88 -0
- package/dist/lib/magic-link.js +125 -0
- package/dist/lib/mfa-totp.d.ts +154 -0
- package/dist/lib/mfa-totp.js +193 -0
- package/dist/lib/oauth-linking.d.ts +69 -0
- package/dist/lib/oauth-linking.js +70 -0
- package/dist/lib/oauth-primitives.d.ts +27 -0
- package/dist/lib/oauth-primitives.js +46 -0
- package/dist/lib/oauth-providers.d.ts +92 -0
- package/dist/lib/oauth-providers.js +192 -0
- package/dist/lib/password.d.ts +18 -1
- package/dist/lib/password.js +48 -6
- package/dist/lib/refresh-tokens.d.ts +74 -0
- package/dist/lib/refresh-tokens.js +94 -0
- package/dist/lib/remote-credentials-provider.d.ts +1 -6
- package/dist/lib/remote-credentials-provider.js +14 -0
- package/dist/lib/webauthn.d.ts +159 -0
- package/dist/lib/webauthn.js +167 -0
- package/package.json +85 -4
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @mostajs/auth — OAuth2 + OIDC provider handlers (v2.7.0+)
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Core OAuth2 / OIDC handler avec PKCE (RFC 7636) + state CSRF.
|
|
5
|
+
//
|
|
6
|
+
// Scope v2.7.0 (tête haute — exactement ce qui est livré, pas de stub claims) :
|
|
7
|
+
//
|
|
8
|
+
// ✅ Generic OAuth2 + PKCE handler
|
|
9
|
+
// ✅ Provider specs concrets : 'google', 'github', 'microsoft'
|
|
10
|
+
// ✅ Provider 'generic-oidc' : compatible tout IdP OIDC (issuer URL → discovery)
|
|
11
|
+
// ❌ apple : nécessite client_secret JWT signé ES256 — porté en v2.7.x si demande
|
|
12
|
+
// ❌ slack : format de réponse OAuth atypique (slack-specific) — porté en v2.7.x
|
|
13
|
+
// ❌ discord, facebook : specs faciles, mais on les ajoute quand on a un customer
|
|
14
|
+
//
|
|
15
|
+
// Pas d'inclusion de "8 providers stubs". Trois providers réels + générique OIDC =
|
|
16
|
+
// couverture ~80% des cas. Les autres arriveront un par un quand le besoin existe.
|
|
17
|
+
// PKCE primitives extraites en v2.9.1 vers `lib/oauth-primitives.ts` pour permettre
|
|
18
|
+
// à @mostajs/auth-flow + futurs SDKs polyglottes de les consommer sans tirer les
|
|
19
|
+
// SPECS providers + fetch deps lourdes.
|
|
20
|
+
// Importées + ré-exportées ici pour rétro-compat des consumers v2.7.x / v2.8.x.
|
|
21
|
+
import { generateCodeVerifier, deriveCodeChallenge, generateState, } from './oauth-primitives.js';
|
|
22
|
+
export { generateCodeVerifier, deriveCodeChallenge, generateState };
|
|
23
|
+
const SPECS = {
|
|
24
|
+
google: {
|
|
25
|
+
id: 'google',
|
|
26
|
+
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
27
|
+
tokenEndpoint: 'https://oauth2.googleapis.com/token',
|
|
28
|
+
userInfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
|
|
29
|
+
defaultScopes: ['openid', 'email', 'profile'],
|
|
30
|
+
mapProfile: (raw) => ({
|
|
31
|
+
providerId: String(raw.sub),
|
|
32
|
+
email: raw.email,
|
|
33
|
+
name: raw.name ?? [raw.given_name, raw.family_name].filter(Boolean).join(' '),
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
github: {
|
|
37
|
+
id: 'github',
|
|
38
|
+
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
|
|
39
|
+
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
|
40
|
+
userInfoEndpoint: 'https://api.github.com/user',
|
|
41
|
+
defaultScopes: ['read:user', 'user:email'],
|
|
42
|
+
tokenAcceptHeader: 'application/json',
|
|
43
|
+
mapProfile: (raw) => ({
|
|
44
|
+
providerId: String(raw.id),
|
|
45
|
+
email: raw.email ?? undefined,
|
|
46
|
+
name: raw.name ?? raw.login,
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
microsoft: {
|
|
50
|
+
id: 'microsoft',
|
|
51
|
+
authorizationEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
52
|
+
tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
53
|
+
userInfoEndpoint: 'https://graph.microsoft.com/v1.0/me',
|
|
54
|
+
defaultScopes: ['openid', 'email', 'profile', 'User.Read'],
|
|
55
|
+
mapProfile: (raw) => ({
|
|
56
|
+
providerId: String(raw.id),
|
|
57
|
+
email: raw.mail ?? raw.userPrincipalName,
|
|
58
|
+
name: raw.displayName,
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
/** Lookup d'une spec built-in. Retourne null si l'id n'est pas connu. */
|
|
63
|
+
export function getProviderSpec(id) {
|
|
64
|
+
return SPECS[id] ?? null;
|
|
65
|
+
}
|
|
66
|
+
/** Fetch le `.well-known/openid-configuration` d'un issuer et construit une spec OIDC. */
|
|
67
|
+
export async function discoverOidcProvider(issuerUrl, fetchImpl = fetch) {
|
|
68
|
+
const url = `${issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
69
|
+
const resp = await fetchImpl(url);
|
|
70
|
+
if (!resp.ok)
|
|
71
|
+
throw new Error(`OIDC discovery failed (${resp.status}) at ${url}`);
|
|
72
|
+
const doc = await resp.json();
|
|
73
|
+
return {
|
|
74
|
+
id: 'generic-oidc',
|
|
75
|
+
authorizationEndpoint: doc.authorization_endpoint,
|
|
76
|
+
tokenEndpoint: doc.token_endpoint,
|
|
77
|
+
userInfoEndpoint: doc.userinfo_endpoint,
|
|
78
|
+
defaultScopes: ['openid', 'email', 'profile'],
|
|
79
|
+
mapProfile: (raw) => ({
|
|
80
|
+
providerId: String(raw.sub),
|
|
81
|
+
email: raw.email,
|
|
82
|
+
name: raw.name ?? raw.preferred_username,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Démarre le flow : génère state + PKCE verifier, retourne l'URL d'autorisation.
|
|
88
|
+
* Le caller doit persister `{ state, codeVerifier }` (ex: cookie httpOnly court) et
|
|
89
|
+
* comparer au callback.
|
|
90
|
+
*/
|
|
91
|
+
export function startAuthorization(config, opts) {
|
|
92
|
+
const state = opts?.state ?? generateState();
|
|
93
|
+
const codeVerifier = generateCodeVerifier();
|
|
94
|
+
const codeChallenge = deriveCodeChallenge(codeVerifier);
|
|
95
|
+
const scopes = [...config.spec.defaultScopes, ...(config.extraScopes ?? [])];
|
|
96
|
+
const params = new URLSearchParams({
|
|
97
|
+
response_type: 'code',
|
|
98
|
+
client_id: config.clientId,
|
|
99
|
+
redirect_uri: config.redirectUri,
|
|
100
|
+
scope: scopes.join(' '),
|
|
101
|
+
state,
|
|
102
|
+
code_challenge: codeChallenge,
|
|
103
|
+
code_challenge_method: 'S256',
|
|
104
|
+
});
|
|
105
|
+
if (opts?.loginHint)
|
|
106
|
+
params.set('login_hint', opts.loginHint);
|
|
107
|
+
return {
|
|
108
|
+
url: `${config.spec.authorizationEndpoint}?${params}`,
|
|
109
|
+
state,
|
|
110
|
+
codeVerifier,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Callback : échange `code` contre access_token, fetch userinfo, retourne le profil.
|
|
115
|
+
* Le caller a la responsabilité de vérifier que `state` reçu == `state` persisté
|
|
116
|
+
* (anti-CSRF).
|
|
117
|
+
*/
|
|
118
|
+
export async function exchangeCodeForUser(config, args) {
|
|
119
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
120
|
+
const tokenBody = new URLSearchParams({
|
|
121
|
+
grant_type: 'authorization_code',
|
|
122
|
+
code: args.code,
|
|
123
|
+
redirect_uri: config.redirectUri,
|
|
124
|
+
client_id: config.clientId,
|
|
125
|
+
client_secret: config.clientSecret,
|
|
126
|
+
code_verifier: args.codeVerifier,
|
|
127
|
+
});
|
|
128
|
+
const tokenHeaders = {
|
|
129
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
130
|
+
Accept: config.spec.tokenAcceptHeader ?? 'application/json',
|
|
131
|
+
};
|
|
132
|
+
const tokenResp = await fetchImpl(config.spec.tokenEndpoint, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: tokenHeaders,
|
|
135
|
+
body: tokenBody.toString(),
|
|
136
|
+
});
|
|
137
|
+
if (!tokenResp.ok) {
|
|
138
|
+
const text = await tokenResp.text().catch(() => '');
|
|
139
|
+
throw new Error(`Token exchange failed (${tokenResp.status}): ${text.slice(0, 200)}`);
|
|
140
|
+
}
|
|
141
|
+
const tokenData = await tokenResp.json();
|
|
142
|
+
if (!tokenData.access_token) {
|
|
143
|
+
throw new Error(`Token exchange returned no access_token: ${JSON.stringify(tokenData).slice(0, 200)}`);
|
|
144
|
+
}
|
|
145
|
+
// userinfo : soit endpoint dédié, soit décodage du id_token (OIDC).
|
|
146
|
+
let rawProfile;
|
|
147
|
+
if (config.spec.userInfoEndpoint) {
|
|
148
|
+
const authHeader = config.spec.userInfoAuthHeader
|
|
149
|
+
? config.spec.userInfoAuthHeader(tokenData.access_token)
|
|
150
|
+
: `Bearer ${tokenData.access_token}`;
|
|
151
|
+
const userResp = await fetchImpl(config.spec.userInfoEndpoint, {
|
|
152
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
153
|
+
});
|
|
154
|
+
if (!userResp.ok) {
|
|
155
|
+
const text = await userResp.text().catch(() => '');
|
|
156
|
+
throw new Error(`UserInfo fetch failed (${userResp.status}): ${text.slice(0, 200)}`);
|
|
157
|
+
}
|
|
158
|
+
rawProfile = await userResp.json();
|
|
159
|
+
}
|
|
160
|
+
else if (tokenData.id_token) {
|
|
161
|
+
rawProfile = decodeJwtPayloadUnverified(tokenData.id_token);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
throw new Error('No userinfo endpoint and no id_token — cannot resolve profile');
|
|
165
|
+
}
|
|
166
|
+
const mapped = config.spec.mapProfile(rawProfile);
|
|
167
|
+
return {
|
|
168
|
+
providerId: mapped.providerId,
|
|
169
|
+
email: mapped.email,
|
|
170
|
+
name: mapped.name,
|
|
171
|
+
rawProfile,
|
|
172
|
+
accessToken: tokenData.access_token,
|
|
173
|
+
refreshToken: tokenData.refresh_token,
|
|
174
|
+
idToken: tokenData.id_token,
|
|
175
|
+
expiresIn: tokenData.expires_in,
|
|
176
|
+
scope: tokenData.scope,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Décode (sans vérifier la signature) le payload d'un JWT. Sécurité : à n'utiliser
|
|
181
|
+
* QUE quand le JWT vient d'un endpoint authentifié de confiance (ex: id_token reçu
|
|
182
|
+
* directement du tokenEndpoint). Pour vérifier un JWT venant d'un client, utiliser
|
|
183
|
+
* une lib comme `jose` avec le JWKS du provider.
|
|
184
|
+
*/
|
|
185
|
+
export function decodeJwtPayloadUnverified(jwt) {
|
|
186
|
+
const parts = jwt.split('.');
|
|
187
|
+
if (parts.length !== 3)
|
|
188
|
+
throw new Error('Invalid JWT format');
|
|
189
|
+
const payload = parts[1];
|
|
190
|
+
const decoded = Buffer.from(payload, 'base64url').toString('utf8');
|
|
191
|
+
return JSON.parse(decoded);
|
|
192
|
+
}
|
package/dist/lib/password.d.ts
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
/** Hash en Argon2id. Format : `$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>` */
|
|
2
|
+
export declare function hashPassword(plain: string): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Vérifie un password en détectant l'algo du hash stocké.
|
|
5
|
+
* Retourne `{ valid, needsRehash }` — `needsRehash=true` si le hash actuel est
|
|
6
|
+
* en bcrypt legacy (l'app peut alors re-hasher en Argon2id et persister).
|
|
7
|
+
*/
|
|
8
|
+
export declare function comparePasswordWithMeta(plain: string, hashed: string): Promise<{
|
|
9
|
+
valid: boolean;
|
|
10
|
+
needsRehash: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
/** Compatibilité ascendante — équivaut à `comparePasswordWithMeta().valid`. */
|
|
2
13
|
export declare function comparePassword(plain: string, hashed: string): Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* @deprecated Test/audit uniquement. La voie de prod est `hashPassword()`.
|
|
16
|
+
* Conservé pour permettre d'auditer la migration et écrire des tests qui forcent
|
|
17
|
+
* un hash bcrypt connu.
|
|
18
|
+
*/
|
|
19
|
+
export declare function hashPasswordBcrypt(plain: string, rounds?: number): Promise<string>;
|
package/dist/lib/password.js
CHANGED
|
@@ -1,10 +1,52 @@
|
|
|
1
|
-
// @
|
|
2
|
-
// Author: Dr Hamid MADANI drmdh@msn.com
|
|
1
|
+
// @mostajs/auth — Password hashing wrapper (v2.6.0+)
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Dual-algorithm support :
|
|
5
|
+
// - hashPassword() écrit en Argon2id (recommandation OWASP 2025).
|
|
6
|
+
// - comparePassword() détecte le format (préfixe `$argon2id$` vs `$2b$`/`$2a$`/`$2y$`)
|
|
7
|
+
// et applique l'algo correspondant — permet une migration lazy : un user qui se
|
|
8
|
+
// log avec un ancien hash bcrypt voit son hash réécrit en Argon2id si l'app appelle
|
|
9
|
+
// comparePasswordWithMeta() puis hashPassword() lors d'un login OK (cf. doc 07 Lot 1).
|
|
10
|
+
// - hashPasswordBcrypt() reste exporté pour test/audit, deprecated.
|
|
11
|
+
//
|
|
12
|
+
// Paramètres Argon2id : OWASP 2025 minimum — m=64 MiB, t=3, p=4 (cf. RFC 9106).
|
|
13
|
+
import { hash as argonHash, verify as argonVerify } from '@node-rs/argon2';
|
|
3
14
|
import bcrypt from 'bcryptjs';
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
const ARGON2ID_OPTIONS = {
|
|
16
|
+
memoryCost: 65536,
|
|
17
|
+
timeCost: 3,
|
|
18
|
+
parallelism: 4,
|
|
19
|
+
};
|
|
20
|
+
const BCRYPT_DEFAULT_ROUNDS = 12;
|
|
21
|
+
/** Hash en Argon2id. Format : `$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>` */
|
|
22
|
+
export async function hashPassword(plain) {
|
|
23
|
+
return argonHash(plain, ARGON2ID_OPTIONS);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Vérifie un password en détectant l'algo du hash stocké.
|
|
27
|
+
* Retourne `{ valid, needsRehash }` — `needsRehash=true` si le hash actuel est
|
|
28
|
+
* en bcrypt legacy (l'app peut alors re-hasher en Argon2id et persister).
|
|
29
|
+
*/
|
|
30
|
+
export async function comparePasswordWithMeta(plain, hashed) {
|
|
31
|
+
if (!hashed)
|
|
32
|
+
return { valid: false, needsRehash: false };
|
|
33
|
+
if (hashed.startsWith('$argon2')) {
|
|
34
|
+
const valid = await argonVerify(hashed, plain).catch(() => false);
|
|
35
|
+
return { valid, needsRehash: false };
|
|
36
|
+
}
|
|
37
|
+
const valid = await bcrypt.compare(plain, hashed);
|
|
38
|
+
return { valid, needsRehash: valid };
|
|
7
39
|
}
|
|
40
|
+
/** Compatibilité ascendante — équivaut à `comparePasswordWithMeta().valid`. */
|
|
8
41
|
export async function comparePassword(plain, hashed) {
|
|
9
|
-
|
|
42
|
+
const { valid } = await comparePasswordWithMeta(plain, hashed);
|
|
43
|
+
return valid;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* @deprecated Test/audit uniquement. La voie de prod est `hashPassword()`.
|
|
47
|
+
* Conservé pour permettre d'auditer la migration et écrire des tests qui forcent
|
|
48
|
+
* un hash bcrypt connu.
|
|
49
|
+
*/
|
|
50
|
+
export async function hashPasswordBcrypt(plain, rounds = BCRYPT_DEFAULT_ROUNDS) {
|
|
51
|
+
return bcrypt.hash(plain, rounds);
|
|
10
52
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Format persisté en DB (le `token` plaintext n'est jamais stocké). */
|
|
2
|
+
export interface RefreshTokenRecord {
|
|
3
|
+
id: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
/** SHA-256 hex du token plaintext. Index unique — la lookup se fait dessus. */
|
|
6
|
+
tokenHash: string;
|
|
7
|
+
expiresAt: Date | string;
|
|
8
|
+
createdAt: Date | string;
|
|
9
|
+
/** ID du refresh token qui a remplacé celui-ci (set par `rotate`). */
|
|
10
|
+
replacedBy?: string | null;
|
|
11
|
+
/** Timestamp de révocation explicite (logout, password change, etc.). */
|
|
12
|
+
revokedAt?: Date | string | null;
|
|
13
|
+
ip?: string | null;
|
|
14
|
+
userAgent?: string | null;
|
|
15
|
+
}
|
|
16
|
+
/** Repository d'accès — à implémenter par le consumer (ex: via @mostajs/orm). */
|
|
17
|
+
export interface RefreshTokenRepo {
|
|
18
|
+
insert(record: Omit<RefreshTokenRecord, 'id'>): Promise<RefreshTokenRecord>;
|
|
19
|
+
findByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
|
|
20
|
+
setReplacedBy(id: string, replacedById: string): Promise<void>;
|
|
21
|
+
revokeById(id: string): Promise<void>;
|
|
22
|
+
/** Révoque tous les tokens d'un user (sur password change, logout-all, etc.). */
|
|
23
|
+
revokeAllByUser(userId: string): Promise<void>;
|
|
24
|
+
/** Cleanup périodique des tokens expirés (cron / job). */
|
|
25
|
+
deleteExpired(now: Date): Promise<number>;
|
|
26
|
+
}
|
|
27
|
+
export interface RefreshTokenConfig {
|
|
28
|
+
repo: RefreshTokenRepo;
|
|
29
|
+
/** TTL d'un refresh token (défaut 30 jours). */
|
|
30
|
+
ttlSec?: number;
|
|
31
|
+
/** Hook appelé quand une rotation détecte un replay (token déjà rotaté ré-utilisé).
|
|
32
|
+
* Use-case : signaler à l'audit log + révoquer toute la chaîne user. */
|
|
33
|
+
onReplayDetected?: (record: RefreshTokenRecord) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export interface IssuedRefreshToken {
|
|
36
|
+
/** Plaintext à retourner au client (cookie ou body). Ne sera plus jamais accessible. */
|
|
37
|
+
token: string;
|
|
38
|
+
record: RefreshTokenRecord;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Émet un nouveau refresh token et persiste son hash.
|
|
42
|
+
* Appelé après login OK, ou comme étape finale de `rotate`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function issueRefreshToken(config: RefreshTokenConfig, ctx: {
|
|
45
|
+
userId: string;
|
|
46
|
+
ip?: string;
|
|
47
|
+
userAgent?: string;
|
|
48
|
+
}): Promise<IssuedRefreshToken>;
|
|
49
|
+
export type RotateResult = {
|
|
50
|
+
ok: true;
|
|
51
|
+
issued: IssuedRefreshToken;
|
|
52
|
+
oldRecord: RefreshTokenRecord;
|
|
53
|
+
} | {
|
|
54
|
+
ok: false;
|
|
55
|
+
reason: 'not_found' | 'expired' | 'revoked' | 'replay';
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Vérifie + rotation : ancien token marqué `replacedBy`, nouveau émis.
|
|
59
|
+
* Détection de replay : si l'ancien a déjà un `replacedBy`, c'est qu'il a été
|
|
60
|
+
* utilisé une 2ᵉ fois → token volé. On déclenche `onReplayDetected` (typiquement :
|
|
61
|
+
* révocation totale de la chaîne user + alerte audit).
|
|
62
|
+
*/
|
|
63
|
+
export declare function rotateRefreshToken(config: RefreshTokenConfig, tokenPlaintext: string, ctx?: {
|
|
64
|
+
ip?: string;
|
|
65
|
+
userAgent?: string;
|
|
66
|
+
}): Promise<RotateResult>;
|
|
67
|
+
/** Révoque un refresh token (logout single-device). */
|
|
68
|
+
export declare function revokeRefreshToken(config: RefreshTokenConfig, tokenPlaintext: string): Promise<{
|
|
69
|
+
revoked: boolean;
|
|
70
|
+
}>;
|
|
71
|
+
/** Révoque tous les tokens d'un user (logout-all, password change, account compromise). */
|
|
72
|
+
export declare function revokeAllUserRefreshTokens(config: RefreshTokenConfig, userId: string): Promise<void>;
|
|
73
|
+
/** Helper pour tests / consumer qui veut vérifier un token sans rotation. */
|
|
74
|
+
export declare function _hashToken(plaintext: string): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @mostajs/auth — Refresh tokens with rotation (v2.6.0+)
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Refresh tokens rotatifs : à chaque rotation le précédent est marqué `replacedBy`,
|
|
5
|
+
// permettant la détection d'attaque "replay" (un attaquant qui ré-utilise un refresh
|
|
6
|
+
// token déjà rotaté → on révoque toute la chaîne descendante côté server).
|
|
7
|
+
//
|
|
8
|
+
// Le token plaintext n'est JAMAIS stocké — seul `tokenHash` (SHA-256) l'est.
|
|
9
|
+
// Le client garde le plaintext en cookie httpOnly+Secure ou en secure storage SDK.
|
|
10
|
+
//
|
|
11
|
+
// Storage agnostique : le consumer fournit un `RefreshTokenRepo` (DI) — typiquement
|
|
12
|
+
// adossé à `@mostajs/orm` côté Octonet, ou n'importe quel store côté app cliente.
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
const DEFAULT_TTL_SEC = 30 * 24 * 3600; // 30 jours
|
|
15
|
+
/** Token plaintext : 256 bits aléatoires en base64url. */
|
|
16
|
+
function generateToken() {
|
|
17
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
18
|
+
}
|
|
19
|
+
function hashToken(plaintext) {
|
|
20
|
+
return crypto.createHash('sha256').update(plaintext).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Émet un nouveau refresh token et persiste son hash.
|
|
24
|
+
* Appelé après login OK, ou comme étape finale de `rotate`.
|
|
25
|
+
*/
|
|
26
|
+
export async function issueRefreshToken(config, ctx) {
|
|
27
|
+
const token = generateToken();
|
|
28
|
+
const tokenHash = hashToken(token);
|
|
29
|
+
const ttl = config.ttlSec ?? DEFAULT_TTL_SEC;
|
|
30
|
+
const now = new Date();
|
|
31
|
+
const record = await config.repo.insert({
|
|
32
|
+
userId: ctx.userId,
|
|
33
|
+
tokenHash,
|
|
34
|
+
expiresAt: new Date(now.getTime() + ttl * 1000),
|
|
35
|
+
createdAt: now,
|
|
36
|
+
replacedBy: null,
|
|
37
|
+
revokedAt: null,
|
|
38
|
+
ip: ctx.ip ?? null,
|
|
39
|
+
userAgent: ctx.userAgent ?? null,
|
|
40
|
+
});
|
|
41
|
+
return { token, record };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Vérifie + rotation : ancien token marqué `replacedBy`, nouveau émis.
|
|
45
|
+
* Détection de replay : si l'ancien a déjà un `replacedBy`, c'est qu'il a été
|
|
46
|
+
* utilisé une 2ᵉ fois → token volé. On déclenche `onReplayDetected` (typiquement :
|
|
47
|
+
* révocation totale de la chaîne user + alerte audit).
|
|
48
|
+
*/
|
|
49
|
+
export async function rotateRefreshToken(config, tokenPlaintext, ctx) {
|
|
50
|
+
if (!tokenPlaintext)
|
|
51
|
+
return { ok: false, reason: 'not_found' };
|
|
52
|
+
const tokenHash = hashToken(tokenPlaintext);
|
|
53
|
+
const old = await config.repo.findByHash(tokenHash);
|
|
54
|
+
if (!old)
|
|
55
|
+
return { ok: false, reason: 'not_found' };
|
|
56
|
+
if (old.revokedAt)
|
|
57
|
+
return { ok: false, reason: 'revoked' };
|
|
58
|
+
if (new Date(old.expiresAt) < new Date())
|
|
59
|
+
return { ok: false, reason: 'expired' };
|
|
60
|
+
if (old.replacedBy) {
|
|
61
|
+
// Replay attack : token déjà rotaté ré-utilisé.
|
|
62
|
+
if (config.onReplayDetected) {
|
|
63
|
+
await config.onReplayDetected(old).catch(() => { });
|
|
64
|
+
}
|
|
65
|
+
await config.repo.revokeAllByUser(old.userId).catch(() => { });
|
|
66
|
+
return { ok: false, reason: 'replay' };
|
|
67
|
+
}
|
|
68
|
+
const issued = await issueRefreshToken(config, {
|
|
69
|
+
userId: old.userId,
|
|
70
|
+
ip: ctx?.ip,
|
|
71
|
+
userAgent: ctx?.userAgent,
|
|
72
|
+
});
|
|
73
|
+
await config.repo.setReplacedBy(old.id, issued.record.id);
|
|
74
|
+
return { ok: true, issued, oldRecord: old };
|
|
75
|
+
}
|
|
76
|
+
/** Révoque un refresh token (logout single-device). */
|
|
77
|
+
export async function revokeRefreshToken(config, tokenPlaintext) {
|
|
78
|
+
if (!tokenPlaintext)
|
|
79
|
+
return { revoked: false };
|
|
80
|
+
const tokenHash = hashToken(tokenPlaintext);
|
|
81
|
+
const r = await config.repo.findByHash(tokenHash);
|
|
82
|
+
if (!r || r.revokedAt)
|
|
83
|
+
return { revoked: false };
|
|
84
|
+
await config.repo.revokeById(r.id);
|
|
85
|
+
return { revoked: true };
|
|
86
|
+
}
|
|
87
|
+
/** Révoque tous les tokens d'un user (logout-all, password change, account compromise). */
|
|
88
|
+
export async function revokeAllUserRefreshTokens(config, userId) {
|
|
89
|
+
await config.repo.revokeAllByUser(userId);
|
|
90
|
+
}
|
|
91
|
+
/** Helper pour tests / consumer qui veut vérifier un token sans rotation. */
|
|
92
|
+
export function _hashToken(plaintext) {
|
|
93
|
+
return hashToken(plaintext);
|
|
94
|
+
}
|
|
@@ -46,10 +46,5 @@ export declare function createRemoteCredentialsProvider(config: RemoteCredential
|
|
|
46
46
|
type: string;
|
|
47
47
|
};
|
|
48
48
|
};
|
|
49
|
-
authorize(credentials: any): Promise<
|
|
50
|
-
id: any;
|
|
51
|
-
email: any;
|
|
52
|
-
name: any;
|
|
53
|
-
role: any;
|
|
54
|
-
} | null>;
|
|
49
|
+
authorize(credentials: any): Promise<any>;
|
|
55
50
|
};
|
|
@@ -116,6 +116,20 @@ export function createRemoteCredentialsProvider(config) {
|
|
|
116
116
|
email: user.email,
|
|
117
117
|
name: user.name,
|
|
118
118
|
role: user.role,
|
|
119
|
+
// v3.0.2+ : si Octonet renvoie accountId (via createCredentialsVerifyHandler
|
|
120
|
+
// configuré avec `resolveAccountId`), on le propage tel quel à NextAuth.
|
|
121
|
+
// Le consumer (app NextAuth) doit ensuite le propager dans ses callbacks
|
|
122
|
+
// jwt+session pour qu'il soit visible côté `auth(req)` :
|
|
123
|
+
//
|
|
124
|
+
// async jwt({ token, user }) {
|
|
125
|
+
// if (user?.accountId) token.accountId = (user as any).accountId
|
|
126
|
+
// return token
|
|
127
|
+
// },
|
|
128
|
+
// async session({ session, token }) {
|
|
129
|
+
// ;(session.user as any).accountId = token.accountId
|
|
130
|
+
// return session
|
|
131
|
+
// },
|
|
132
|
+
...(user.accountId ? { accountId: user.accountId } : {}),
|
|
119
133
|
};
|
|
120
134
|
},
|
|
121
135
|
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, AuthenticationResponseJSON, AuthenticatorTransportFuture } from '@simplewebauthn/types';
|
|
2
|
+
export interface WebAuthnCredentialRecord {
|
|
3
|
+
/** PK opaque côté DB. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** User propriétaire — clé d'isolation tenant. */
|
|
6
|
+
userId: string;
|
|
7
|
+
/** Credential ID retourné par WebAuthn (base64url). Index unique global recommandé. */
|
|
8
|
+
credentialId: string;
|
|
9
|
+
/** Public key COSE encodée base64url (≤ 200 bytes typiquement). */
|
|
10
|
+
publicKey: string;
|
|
11
|
+
/** Compteur anti-replay — RFC : strictement croissant côté authenticator. */
|
|
12
|
+
counter: number;
|
|
13
|
+
/**
|
|
14
|
+
* Transports déclarés par l'authenticator (`'usb'|'nfc'|'ble'|'internal'|'hybrid'|'cable'`).
|
|
15
|
+
* Sert à hint l'UI de login (touch-vs-QR).
|
|
16
|
+
*/
|
|
17
|
+
transports?: AuthenticatorTransportFuture[];
|
|
18
|
+
/** Label affiché à l'user dans les settings ("YubiKey 5C", "iCloud Passkey"). */
|
|
19
|
+
deviceName?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Type d'usage prévu. Le module l'utilise pour discriminer les listes :
|
|
22
|
+
* - `primary` : utilisable comme primary login (signInWithPasskey)
|
|
23
|
+
* - `factor` : utilisable comme 2nd factor (verifyAsFactor)
|
|
24
|
+
* - `both` : utilisable des deux manières (default user choice)
|
|
25
|
+
*/
|
|
26
|
+
usage: 'primary' | 'factor' | 'both';
|
|
27
|
+
createdAt: Date | string;
|
|
28
|
+
lastUsedAt?: Date | string | null;
|
|
29
|
+
}
|
|
30
|
+
export interface WebAuthnCredentialRepo {
|
|
31
|
+
insert(record: Omit<WebAuthnCredentialRecord, 'id'>): Promise<WebAuthnCredentialRecord>;
|
|
32
|
+
findById(id: string): Promise<WebAuthnCredentialRecord | null>;
|
|
33
|
+
findByCredentialId(credentialId: string): Promise<WebAuthnCredentialRecord | null>;
|
|
34
|
+
findByUser(userId: string): Promise<WebAuthnCredentialRecord[]>;
|
|
35
|
+
/** Met à jour counter + lastUsedAt après un verify réussi. */
|
|
36
|
+
updateAfterUse(id: string, args: {
|
|
37
|
+
counter: number;
|
|
38
|
+
lastUsedAt: Date;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
delete(id: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export interface WebAuthnChallengeStore {
|
|
43
|
+
/** Persiste un challenge avec TTL court — clé par session opaque (cookie / sid). */
|
|
44
|
+
put(key: string, challenge: string, expiresAt: Date): Promise<void>;
|
|
45
|
+
/** Lit + supprime atomiquement (replay protection). */
|
|
46
|
+
consume(key: string): Promise<{
|
|
47
|
+
challenge: string;
|
|
48
|
+
} | null>;
|
|
49
|
+
}
|
|
50
|
+
export interface WebAuthnConfig {
|
|
51
|
+
/** RP ID = eTLD+1 (ex: 'example.com'). NE PAS inclure de port ni de path. */
|
|
52
|
+
rpID: string;
|
|
53
|
+
/** Nom du service affiché à l'authenticator (ex: 'Octonet'). */
|
|
54
|
+
rpName: string;
|
|
55
|
+
/**
|
|
56
|
+
* Origines HTTPS acceptées (ex: ['https://app.example.com', 'https://auth.example.com']).
|
|
57
|
+
* Doit inclure toutes les origines depuis lesquelles le browser peut initier la cérémonie.
|
|
58
|
+
*/
|
|
59
|
+
expectedOrigins: string[];
|
|
60
|
+
/** TTL d'un challenge en secondes (default 300 = 5 min). */
|
|
61
|
+
challengeTtlSec?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Type d'attestation requis (`'none'` par défaut — passkeys grand-public ;
|
|
64
|
+
* `'direct'` pour FIDO2 entreprise / hardware key avec cert chain).
|
|
65
|
+
*/
|
|
66
|
+
attestationType?: 'none' | 'direct' | 'enterprise';
|
|
67
|
+
/** authenticatorAttachment requis (default: aucune contrainte). */
|
|
68
|
+
authenticatorAttachment?: 'platform' | 'cross-platform';
|
|
69
|
+
/** residentKey requis ('required' = passkey discoverable ; 'preferred' = OK). */
|
|
70
|
+
residentKey?: 'required' | 'preferred' | 'discouraged';
|
|
71
|
+
/** userVerification requis (default 'preferred'). */
|
|
72
|
+
userVerification?: 'required' | 'preferred' | 'discouraged';
|
|
73
|
+
}
|
|
74
|
+
export interface StartRegistrationArgs {
|
|
75
|
+
config: WebAuthnConfig;
|
|
76
|
+
challengeStore: WebAuthnChallengeStore;
|
|
77
|
+
/** Clé de session opaque (cookie sid, etc.) pour stocker le challenge transient. */
|
|
78
|
+
sessionKey: string;
|
|
79
|
+
user: {
|
|
80
|
+
id: string;
|
|
81
|
+
/** Nom utilisé par l'authenticator (typiquement email). */
|
|
82
|
+
name: string;
|
|
83
|
+
/** Display-name (ex: "Alice Dupont"). */
|
|
84
|
+
displayName: string;
|
|
85
|
+
};
|
|
86
|
+
/** Pour exclure de la sélection les credentials déjà enregistrés (anti double-enroll). */
|
|
87
|
+
existingCredentials?: WebAuthnCredentialRecord[];
|
|
88
|
+
}
|
|
89
|
+
export declare function startRegistration(args: StartRegistrationArgs): Promise<PublicKeyCredentialCreationOptionsJSON>;
|
|
90
|
+
export interface VerifyRegistrationArgs {
|
|
91
|
+
config: WebAuthnConfig;
|
|
92
|
+
challengeStore: WebAuthnChallengeStore;
|
|
93
|
+
sessionKey: string;
|
|
94
|
+
userId: string;
|
|
95
|
+
response: RegistrationResponseJSON;
|
|
96
|
+
/** Label saisi par l'user pour ce device ("YubiKey", "iPhone 15"). */
|
|
97
|
+
deviceName?: string;
|
|
98
|
+
/** Usage prévu (default: 'both'). */
|
|
99
|
+
usage?: 'primary' | 'factor' | 'both';
|
|
100
|
+
}
|
|
101
|
+
export type VerifyRegistrationResult = {
|
|
102
|
+
ok: true;
|
|
103
|
+
record: WebAuthnCredentialRecord;
|
|
104
|
+
} | {
|
|
105
|
+
ok: false;
|
|
106
|
+
reason: 'no_challenge' | 'verification_failed';
|
|
107
|
+
};
|
|
108
|
+
export declare function finishRegistration(repo: WebAuthnCredentialRepo, args: VerifyRegistrationArgs): Promise<VerifyRegistrationResult>;
|
|
109
|
+
export interface StartAuthenticationArgs {
|
|
110
|
+
config: WebAuthnConfig;
|
|
111
|
+
challengeStore: WebAuthnChallengeStore;
|
|
112
|
+
sessionKey: string;
|
|
113
|
+
/**
|
|
114
|
+
* Si on connaît l'user (login après email entré), on peut limiter aux credentials
|
|
115
|
+
* de cet user. Sinon (conditional UI / autofill), laisser undefined → discoverable.
|
|
116
|
+
*/
|
|
117
|
+
allowedCredentials?: WebAuthnCredentialRecord[];
|
|
118
|
+
}
|
|
119
|
+
export declare function startAuthentication(args: StartAuthenticationArgs): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
120
|
+
export interface VerifyAuthenticationArgs {
|
|
121
|
+
config: WebAuthnConfig;
|
|
122
|
+
challengeStore: WebAuthnChallengeStore;
|
|
123
|
+
sessionKey: string;
|
|
124
|
+
response: AuthenticationResponseJSON;
|
|
125
|
+
/** Mode attendu : refuse si le credential trouvé n'est pas compatible. */
|
|
126
|
+
expectedUsage: 'primary' | 'factor';
|
|
127
|
+
}
|
|
128
|
+
export type VerifyAuthenticationResult = {
|
|
129
|
+
ok: true;
|
|
130
|
+
userId: string;
|
|
131
|
+
credentialId: string;
|
|
132
|
+
method: 'webauthn';
|
|
133
|
+
} | {
|
|
134
|
+
ok: false;
|
|
135
|
+
reason: 'no_challenge' | 'unknown_credential' | 'wrong_usage' | 'verification_failed' | 'counter_mismatch';
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Vérifie une réponse d'authentification — utilisable en primary OU 2nd factor selon
|
|
139
|
+
* `expectedUsage`. Le caller décide ensuite quoi faire avec le `userId` retourné.
|
|
140
|
+
*/
|
|
141
|
+
export declare function finishAuthentication(repo: WebAuthnCredentialRepo, args: VerifyAuthenticationArgs): Promise<VerifyAuthenticationResult>;
|
|
142
|
+
export interface PasskeyListItem {
|
|
143
|
+
id: string;
|
|
144
|
+
deviceName?: string;
|
|
145
|
+
usage: 'primary' | 'factor' | 'both';
|
|
146
|
+
createdAt: Date | string;
|
|
147
|
+
lastUsedAt?: Date | string | null;
|
|
148
|
+
/** Nombre de transports — peut hint à l'UI le type d'authenticator. */
|
|
149
|
+
transports?: AuthenticatorTransportFuture[];
|
|
150
|
+
}
|
|
151
|
+
export declare function listPasskeys(repo: WebAuthnCredentialRepo, userId: string): Promise<PasskeyListItem[]>;
|
|
152
|
+
/** Le caller DOIT exiger une preuve fraîche d'identité avant d'appeler — on supprime point. */
|
|
153
|
+
export declare function removePasskey(repo: WebAuthnCredentialRepo, args: {
|
|
154
|
+
credentialId: string;
|
|
155
|
+
userId: string;
|
|
156
|
+
}): Promise<{
|
|
157
|
+
ok: boolean;
|
|
158
|
+
reason?: 'not_found' | 'not_owner';
|
|
159
|
+
}>;
|