@mostajs/auth 2.5.1 → 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.
Files changed (36) hide show
  1. package/README.md +838 -57
  2. package/dist/components/MfaChallenge.d.ts +17 -0
  3. package/dist/components/MfaChallenge.js +55 -0
  4. package/dist/components/MfaEnrollDialog.d.ts +18 -0
  5. package/dist/components/MfaEnrollDialog.js +72 -0
  6. package/dist/components/PasskeyLoginButton.d.ts +20 -0
  7. package/dist/components/PasskeyLoginButton.js +53 -0
  8. package/dist/components/PasskeyRegisterButton.d.ts +26 -0
  9. package/dist/components/PasskeyRegisterButton.js +47 -0
  10. package/dist/lib/account-lifecycle.d.ts +130 -0
  11. package/dist/lib/account-lifecycle.js +136 -0
  12. package/dist/lib/auth-events.d.ts +40 -0
  13. package/dist/lib/auth-events.js +37 -0
  14. package/dist/lib/auth-rate-limit.d.ts +80 -0
  15. package/dist/lib/auth-rate-limit.js +100 -0
  16. package/dist/lib/credentials-verify.d.ts +13 -0
  17. package/dist/lib/credentials-verify.js +14 -0
  18. package/dist/lib/magic-link.d.ts +88 -0
  19. package/dist/lib/magic-link.js +125 -0
  20. package/dist/lib/mfa-totp.d.ts +154 -0
  21. package/dist/lib/mfa-totp.js +193 -0
  22. package/dist/lib/oauth-linking.d.ts +69 -0
  23. package/dist/lib/oauth-linking.js +70 -0
  24. package/dist/lib/oauth-primitives.d.ts +27 -0
  25. package/dist/lib/oauth-primitives.js +46 -0
  26. package/dist/lib/oauth-providers.d.ts +92 -0
  27. package/dist/lib/oauth-providers.js +192 -0
  28. package/dist/lib/password.d.ts +18 -1
  29. package/dist/lib/password.js +48 -6
  30. package/dist/lib/refresh-tokens.d.ts +74 -0
  31. package/dist/lib/refresh-tokens.js +94 -0
  32. package/dist/lib/remote-credentials-provider.d.ts +1 -6
  33. package/dist/lib/remote-credentials-provider.js +14 -0
  34. package/dist/lib/webauthn.d.ts +159 -0
  35. package/dist/lib/webauthn.js +167 -0
  36. package/package.json +95 -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
+ }
@@ -1,2 +1,19 @@
1
- export declare function hashPassword(plain: string, rounds?: number): Promise<string>;
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>;
@@ -1,10 +1,52 @@
1
- // @mosta/auth — Password hashing wrapper
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 DEFAULT_ROUNDS = 12;
5
- export async function hashPassword(plain, rounds = DEFAULT_ROUNDS) {
6
- return bcrypt.hash(plain, rounds);
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
- return bcrypt.compare(plain, hashed);
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
+ }>;