@mostajs/auth 3.0.2 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/anon-token.d.ts +40 -0
- package/dist/lib/anon-token.js +69 -0
- package/dist/lib/invite-token.d.ts +54 -0
- package/dist/lib/invite-token.js +105 -0
- package/package.json +11 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Génère un nouveau token anonyme — 32 caractères hexadécimaux issus de
|
|
3
|
+
* 16 bytes de `randomBytes` (CSPRNG).
|
|
4
|
+
*/
|
|
5
|
+
export declare function generateAnonToken(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Formate un email synthétique stable pour un token + un scope.
|
|
8
|
+
* Le scope évite qu'un même token "fuite" inter-projets *(domaine
|
|
9
|
+
* différent → email différent → respondent distinct par projet)*.
|
|
10
|
+
*
|
|
11
|
+
* Le résultat est non-routable (`.anon.local`) — pas de risque
|
|
12
|
+
* d'envoi mail par accident.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* anonEmail('a1b2c3d4e5f6', 'survey-2026')
|
|
16
|
+
* → 'anon-a1b2c3d4e5f6@survey-2026.anon.local'
|
|
17
|
+
*/
|
|
18
|
+
export declare function anonEmail(token: string, scope: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Vrai si l'email donné a la forme générée par `anonEmail` — utile
|
|
21
|
+
* pour repérer les respondents anonymes dans les dashboards / exports
|
|
22
|
+
* sans avoir à stocker un flag séparé *(quoique le flag reste une
|
|
23
|
+
* bonne pratique pour la requête DB)*.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isAnonEmail(email: string | null | undefined): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Convention de nom du cookie à utiliser. Le consumer reste libre de
|
|
28
|
+
* choisir le sien, mais cette constante factorise la pratique courante.
|
|
29
|
+
*/
|
|
30
|
+
export declare const ANON_COOKIE_DEFAULT_NAME = "anon_tk";
|
|
31
|
+
/**
|
|
32
|
+
* Options de cookie recommandées (utilisées par les implémentations
|
|
33
|
+
* Next/Express). Le consumer applique celles-ci à son cookie store.
|
|
34
|
+
*/
|
|
35
|
+
export declare const ANON_COOKIE_DEFAULT_OPTIONS: {
|
|
36
|
+
httpOnly: boolean;
|
|
37
|
+
sameSite: "lax";
|
|
38
|
+
path: string;
|
|
39
|
+
maxAge: number;
|
|
40
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @mostajs/auth — Anonymous token primitives — v3.1.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Pour les questionnaires / pages publiques `visibility=public-anon` qui
|
|
5
|
+
// veulent retrouver les réponses entre sessions sans demander d'email.
|
|
6
|
+
// Le module fournit uniquement les **primitives** : génération d'un token
|
|
7
|
+
// aléatoire et formatage d'un email synthétique stable. Le **stockage du
|
|
8
|
+
// cookie** est laissé au consumer car la signature dépend du framework
|
|
9
|
+
// (Next.js `cookies()`, Express `res.cookie()`, etc.).
|
|
10
|
+
//
|
|
11
|
+
// Design clé :
|
|
12
|
+
// - 16 bytes de hasard cryptographique → 32 chars hex. Suffisant contre
|
|
13
|
+
// la collision (probabilité < 2⁻⁶⁴ pour 10⁹ tokens).
|
|
14
|
+
// - Email synthétique de la forme `anon-<token12>@<scope>.anon.local` :
|
|
15
|
+
// stable, reconnaissable comme anon dans les exports / dashboards,
|
|
16
|
+
// non-routable (TLD `.local` réservé) → impossible d'envoyer un mail
|
|
17
|
+
// dessus par accident.
|
|
18
|
+
// - Aucune dépendance externe — uniquement `node:crypto`.
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
/**
|
|
21
|
+
* Génère un nouveau token anonyme — 32 caractères hexadécimaux issus de
|
|
22
|
+
* 16 bytes de `randomBytes` (CSPRNG).
|
|
23
|
+
*/
|
|
24
|
+
export function generateAnonToken() {
|
|
25
|
+
return randomBytes(16).toString('hex');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Formate un email synthétique stable pour un token + un scope.
|
|
29
|
+
* Le scope évite qu'un même token "fuite" inter-projets *(domaine
|
|
30
|
+
* différent → email différent → respondent distinct par projet)*.
|
|
31
|
+
*
|
|
32
|
+
* Le résultat est non-routable (`.anon.local`) — pas de risque
|
|
33
|
+
* d'envoi mail par accident.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* anonEmail('a1b2c3d4e5f6', 'survey-2026')
|
|
37
|
+
* → 'anon-a1b2c3d4e5f6@survey-2026.anon.local'
|
|
38
|
+
*/
|
|
39
|
+
export function anonEmail(token, scope) {
|
|
40
|
+
const t = String(token ?? '').slice(0, 12);
|
|
41
|
+
const s = String(scope ?? 'default').toLowerCase().replace(/[^a-z0-9-]+/g, '-');
|
|
42
|
+
return `anon-${t}@${s}.anon.local`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Vrai si l'email donné a la forme générée par `anonEmail` — utile
|
|
46
|
+
* pour repérer les respondents anonymes dans les dashboards / exports
|
|
47
|
+
* sans avoir à stocker un flag séparé *(quoique le flag reste une
|
|
48
|
+
* bonne pratique pour la requête DB)*.
|
|
49
|
+
*/
|
|
50
|
+
export function isAnonEmail(email) {
|
|
51
|
+
if (!email)
|
|
52
|
+
return false;
|
|
53
|
+
return /^anon-[a-f0-9]{12}@[a-z0-9-]+\.anon\.local$/.test(email);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Convention de nom du cookie à utiliser. Le consumer reste libre de
|
|
57
|
+
* choisir le sien, mais cette constante factorise la pratique courante.
|
|
58
|
+
*/
|
|
59
|
+
export const ANON_COOKIE_DEFAULT_NAME = 'anon_tk';
|
|
60
|
+
/**
|
|
61
|
+
* Options de cookie recommandées (utilisées par les implémentations
|
|
62
|
+
* Next/Express). Le consumer applique celles-ci à son cookie store.
|
|
63
|
+
*/
|
|
64
|
+
export const ANON_COOKIE_DEFAULT_OPTIONS = {
|
|
65
|
+
httpOnly: true,
|
|
66
|
+
sameSite: 'lax',
|
|
67
|
+
path: '/',
|
|
68
|
+
maxAge: 60 * 60 * 24 * 365, // 1 an
|
|
69
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface InviteTokenPayload {
|
|
2
|
+
/** Identifiant opaque de la ressource (project id, cohort id, etc.). */
|
|
3
|
+
id: string;
|
|
4
|
+
/** Expiration epoch ms. */
|
|
5
|
+
exp: number;
|
|
6
|
+
/** Version du format (réservé pour évolutions, default 1). */
|
|
7
|
+
v?: number;
|
|
8
|
+
/** Champs métier libres — ex: kind, role, scope. */
|
|
9
|
+
meta?: Record<string, string | number | boolean>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Signe un token. Le payload est sérialisé en JSON puis encodé en
|
|
13
|
+
* base64url, signé HMAC-SHA256 avec `secret`. La signature est
|
|
14
|
+
* concaténée au payload : `body.sig`.
|
|
15
|
+
*
|
|
16
|
+
* @param secret Clé HMAC (≥ 32 bytes recommandés en prod).
|
|
17
|
+
* @param id Identifiant de la ressource encodée.
|
|
18
|
+
* @param ttlMs Durée de validité en ms (default 60 jours).
|
|
19
|
+
* @param meta Champs métier optionnels.
|
|
20
|
+
*/
|
|
21
|
+
export declare function signInviteToken(secret: string | Buffer, id: string, ttlMs?: number, meta?: Record<string, string | number | boolean>): string;
|
|
22
|
+
export type VerifyResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
payload: InviteTokenPayload;
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
reason: 'malformed' | 'bad_signature' | 'expired';
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Vérifie + décode un token. Validation en 3 passes :
|
|
31
|
+
* 1. format `<body>.<sig>`
|
|
32
|
+
* 2. signature HMAC en time-safe compare
|
|
33
|
+
* 3. payload non expiré
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifyInviteToken(secret: string | Buffer, token: string): VerifyResult;
|
|
36
|
+
/**
|
|
37
|
+
* Variante simplifiée — retourne le payload ou null si invalide.
|
|
38
|
+
* Utile pour les callers qui ne distinguent pas les raisons d'échec.
|
|
39
|
+
*/
|
|
40
|
+
export declare function decodeInviteToken(secret: string | Buffer, token: string): InviteTokenPayload | null;
|
|
41
|
+
/**
|
|
42
|
+
* Factory — retourne `{ sign, verify, decode }` pré-liés à un secret.
|
|
43
|
+
* Évite de répéter le secret à chaque appel.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const inviteTokens = createInviteTokenSigner(process.env.INVITE_SECRET!)
|
|
47
|
+
* const t = inviteTokens.sign('project-42')
|
|
48
|
+
* const p = inviteTokens.decode(t)
|
|
49
|
+
*/
|
|
50
|
+
export declare function createInviteTokenSigner(secret: string | Buffer): {
|
|
51
|
+
sign: (id: string, ttlMs?: number, meta?: Record<string, string | number | boolean>) => string;
|
|
52
|
+
verify: (token: string) => VerifyResult;
|
|
53
|
+
decode: (token: string) => InviteTokenPayload | null;
|
|
54
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @mostajs/auth — Invite token (HMAC signed) — v3.2.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Token signé HMAC pour matérialiser une invitation persistente vers une
|
|
5
|
+
// ressource (projet, cohort, contenu privé). Plus léger qu'un magic-link :
|
|
6
|
+
// - pas de nonce single-use → le même lien peut être scanné/cliqué N fois
|
|
7
|
+
// - pas de DB write → pure crypto stateless
|
|
8
|
+
// - TTL long (jours / semaines) — adapté au QR code affiché en
|
|
9
|
+
// présentation, ou au lien collé dans un mailing étalé.
|
|
10
|
+
//
|
|
11
|
+
// Compromis : un attaquant qui intercepte le lien peut s'inscrire à la
|
|
12
|
+
// place du destinataire — d'où la signature HMAC + un payload minimal
|
|
13
|
+
// (juste un id de ressource), et l'usage en complément d'un magic-link
|
|
14
|
+
// sur les actions sensibles.
|
|
15
|
+
//
|
|
16
|
+
// Format : `<base64url(json-payload)>.<base64url(hmac)>`
|
|
17
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
function b64url(buf) {
|
|
19
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
20
|
+
}
|
|
21
|
+
function fromB64url(s) {
|
|
22
|
+
const pad = (4 - (s.length % 4)) % 4;
|
|
23
|
+
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad), 'base64');
|
|
24
|
+
}
|
|
25
|
+
function asKey(secret) {
|
|
26
|
+
return Buffer.isBuffer(secret) ? secret : Buffer.from(secret, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Signe un token. Le payload est sérialisé en JSON puis encodé en
|
|
30
|
+
* base64url, signé HMAC-SHA256 avec `secret`. La signature est
|
|
31
|
+
* concaténée au payload : `body.sig`.
|
|
32
|
+
*
|
|
33
|
+
* @param secret Clé HMAC (≥ 32 bytes recommandés en prod).
|
|
34
|
+
* @param id Identifiant de la ressource encodée.
|
|
35
|
+
* @param ttlMs Durée de validité en ms (default 60 jours).
|
|
36
|
+
* @param meta Champs métier optionnels.
|
|
37
|
+
*/
|
|
38
|
+
export function signInviteToken(secret, id, ttlMs = 60 * 24 * 3600 * 1000, meta) {
|
|
39
|
+
const payload = { id, exp: Date.now() + ttlMs, v: 1 };
|
|
40
|
+
if (meta)
|
|
41
|
+
payload.meta = meta;
|
|
42
|
+
const body = b64url(Buffer.from(JSON.stringify(payload), 'utf8'));
|
|
43
|
+
const sig = createHmac('sha256', asKey(secret)).update(body).digest();
|
|
44
|
+
return `${body}.${b64url(sig)}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Vérifie + décode un token. Validation en 3 passes :
|
|
48
|
+
* 1. format `<body>.<sig>`
|
|
49
|
+
* 2. signature HMAC en time-safe compare
|
|
50
|
+
* 3. payload non expiré
|
|
51
|
+
*/
|
|
52
|
+
export function verifyInviteToken(secret, token) {
|
|
53
|
+
const parts = token.split('.');
|
|
54
|
+
if (parts.length !== 2)
|
|
55
|
+
return { ok: false, reason: 'malformed' };
|
|
56
|
+
const [body, sig] = parts;
|
|
57
|
+
const expected = createHmac('sha256', asKey(secret)).update(body).digest();
|
|
58
|
+
let got;
|
|
59
|
+
try {
|
|
60
|
+
got = fromB64url(sig);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return { ok: false, reason: 'malformed' };
|
|
64
|
+
}
|
|
65
|
+
if (got.length !== expected.length)
|
|
66
|
+
return { ok: false, reason: 'bad_signature' };
|
|
67
|
+
if (!timingSafeEqual(got, expected))
|
|
68
|
+
return { ok: false, reason: 'bad_signature' };
|
|
69
|
+
let payload;
|
|
70
|
+
try {
|
|
71
|
+
payload = JSON.parse(fromB64url(body).toString('utf8'));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { ok: false, reason: 'malformed' };
|
|
75
|
+
}
|
|
76
|
+
if (typeof payload.id !== 'string' || typeof payload.exp !== 'number')
|
|
77
|
+
return { ok: false, reason: 'malformed' };
|
|
78
|
+
if (Date.now() > payload.exp)
|
|
79
|
+
return { ok: false, reason: 'expired' };
|
|
80
|
+
return { ok: true, payload };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Variante simplifiée — retourne le payload ou null si invalide.
|
|
84
|
+
* Utile pour les callers qui ne distinguent pas les raisons d'échec.
|
|
85
|
+
*/
|
|
86
|
+
export function decodeInviteToken(secret, token) {
|
|
87
|
+
const r = verifyInviteToken(secret, token);
|
|
88
|
+
return r.ok ? r.payload : null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Factory — retourne `{ sign, verify, decode }` pré-liés à un secret.
|
|
92
|
+
* Évite de répéter le secret à chaque appel.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* const inviteTokens = createInviteTokenSigner(process.env.INVITE_SECRET!)
|
|
96
|
+
* const t = inviteTokens.sign('project-42')
|
|
97
|
+
* const p = inviteTokens.decode(t)
|
|
98
|
+
*/
|
|
99
|
+
export function createInviteTokenSigner(secret) {
|
|
100
|
+
return {
|
|
101
|
+
sign: (id, ttlMs, meta) => signInviteToken(secret, id, ttlMs, meta),
|
|
102
|
+
verify: (token) => verifyInviteToken(secret, token),
|
|
103
|
+
decode: (token) => decodeInviteToken(secret, token),
|
|
104
|
+
};
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/auth",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Authentication — complete: email/password (Argon2id) + OAuth + magic link + MFA TOTP + WebAuthn/Passkeys + RGPD lifecycle + device_flow/pkce events + accountId propagation server↔client",
|
|
5
5
|
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -113,6 +113,16 @@
|
|
|
113
113
|
"import": "./dist/lib/magic-link.js",
|
|
114
114
|
"default": "./dist/lib/magic-link.js"
|
|
115
115
|
},
|
|
116
|
+
"./lib/anon-token": {
|
|
117
|
+
"types": "./dist/lib/anon-token.d.ts",
|
|
118
|
+
"import": "./dist/lib/anon-token.js",
|
|
119
|
+
"default": "./dist/lib/anon-token.js"
|
|
120
|
+
},
|
|
121
|
+
"./lib/invite-token": {
|
|
122
|
+
"types": "./dist/lib/invite-token.d.ts",
|
|
123
|
+
"import": "./dist/lib/invite-token.js",
|
|
124
|
+
"default": "./dist/lib/invite-token.js"
|
|
125
|
+
},
|
|
116
126
|
"./lib/mfa-totp": {
|
|
117
127
|
"types": "./dist/lib/mfa-totp.d.ts",
|
|
118
128
|
"import": "./dist/lib/mfa-totp.js",
|