@mostajs/auth 3.1.0 → 3.3.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.
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ /** Repo minimum requis — typiquement `UserRepository` de @mostajs/rbac. */
2
+ export interface PasswordlessUserRepo<U = any> {
3
+ findByEmail(email: string): Promise<U | null>;
4
+ create(data: any): Promise<U | void>;
5
+ }
6
+ export interface FindOrCreatePasswordlessOptions {
7
+ /** Prénom — défaut : local-part de l'email. */
8
+ firstName?: string;
9
+ /** Nom — défaut : '·' (placeholder visible mais non vide). */
10
+ lastName?: string;
11
+ /** Locale — défaut : 'fr'. */
12
+ locale?: string;
13
+ /** Téléphone optionnel. */
14
+ phone?: string;
15
+ /** verified par défaut true (l'utilisateur a prouvé l'email via le canal magic-link). */
16
+ verified?: boolean;
17
+ }
18
+ /**
19
+ * Lookup-or-create idempotent pour un flux passwordless.
20
+ *
21
+ * Comportement :
22
+ * - normalise l'email (lowercase + trim)
23
+ * - valide format (regex minimaliste, retourne null si invalide)
24
+ * - retourne le user existant
25
+ * - sinon crée avec `password = await hashPassword(randomBytes(24).toString('hex'))`
26
+ * → hash Argon2id valide d'un password aléatoire **inconnu de tous**
27
+ * → `comparePassword(any, this.password)` retournera toujours `false`
28
+ * → impossible de login par password sur ce user (exactement le but)
29
+ * - firstName / lastName placeholder pour respecter required:true du schema rbac
30
+ *
31
+ * Si l'app permet plus tard l'auth password, le user passe par
32
+ * password-reset pour se définir un password réel.
33
+ *
34
+ * @example
35
+ * import { findOrCreatePasswordless } from '@mostajs/auth/lib/passwordless'
36
+ * import { UserRepository } from '@mostajs/rbac/server'
37
+ *
38
+ * const userRepo = new UserRepository(dialect)
39
+ * const user = await findOrCreatePasswordless(userRepo, 'alice@example.com')
40
+ */
41
+ export declare function findOrCreatePasswordless<U = any>(userRepo: PasswordlessUserRepo<U>, email: string, opts?: FindOrCreatePasswordlessOptions): Promise<U | null>;
@@ -0,0 +1,62 @@
1
+ // @mostajs/auth — Passwordless user provisioning — v3.3.0+
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Helper pour les flux **magic-link / OAuth / passkey-only** où
5
+ // l'utilisateur n'a jamais à saisir de password. Le User en DB est
6
+ // créé avec un password aléatoire HASHÉ Argon2id que **personne ne
7
+ // connaît** — `comparePassword` retournera toujours `false` pour ce
8
+ // user, garantissant l'impossibilité d'un login par mot de passe (ce
9
+ // qui est exactement le comportement voulu).
10
+ //
11
+ // Le helper prend le `userRepo` en paramètre (DI) — il n'impose pas
12
+ // `@mostajs/rbac` comme dependency, mais accepte n'importe quel repo
13
+ // qui implémente `findByEmail` + `create` avec la signature attendue.
14
+ import { randomBytes } from 'node:crypto';
15
+ import { hashPassword } from './password.js';
16
+ const EMAIL_REGEX = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
17
+ /**
18
+ * Lookup-or-create idempotent pour un flux passwordless.
19
+ *
20
+ * Comportement :
21
+ * - normalise l'email (lowercase + trim)
22
+ * - valide format (regex minimaliste, retourne null si invalide)
23
+ * - retourne le user existant
24
+ * - sinon crée avec `password = await hashPassword(randomBytes(24).toString('hex'))`
25
+ * → hash Argon2id valide d'un password aléatoire **inconnu de tous**
26
+ * → `comparePassword(any, this.password)` retournera toujours `false`
27
+ * → impossible de login par password sur ce user (exactement le but)
28
+ * - firstName / lastName placeholder pour respecter required:true du schema rbac
29
+ *
30
+ * Si l'app permet plus tard l'auth password, le user passe par
31
+ * password-reset pour se définir un password réel.
32
+ *
33
+ * @example
34
+ * import { findOrCreatePasswordless } from '@mostajs/auth/lib/passwordless'
35
+ * import { UserRepository } from '@mostajs/rbac/server'
36
+ *
37
+ * const userRepo = new UserRepository(dialect)
38
+ * const user = await findOrCreatePasswordless(userRepo, 'alice@example.com')
39
+ */
40
+ export async function findOrCreatePasswordless(userRepo, email, opts) {
41
+ const e = String(email ?? '').toLowerCase().trim();
42
+ if (!e || !EMAIL_REGEX.test(e))
43
+ return null;
44
+ const existing = await userRepo.findByEmail(e);
45
+ if (existing)
46
+ return existing;
47
+ const localPart = e.split('@')[0] || 'user';
48
+ // Hash Argon2id d'un random — personne ne connaît le plain
49
+ const randomPlain = randomBytes(24).toString('hex');
50
+ const hashedPassword = await hashPassword(randomPlain);
51
+ await userRepo.create({
52
+ email: e,
53
+ password: hashedPassword,
54
+ firstName: opts?.firstName ?? localPart,
55
+ lastName: opts?.lastName ?? '·',
56
+ phone: opts?.phone,
57
+ locale: opts?.locale ?? 'fr',
58
+ status: 'active',
59
+ verified: opts?.verified ?? true,
60
+ });
61
+ return userRepo.findByEmail(e);
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/auth",
3
- "version": "3.1.0",
3
+ "version": "3.3.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",
@@ -118,6 +118,16 @@
118
118
  "import": "./dist/lib/anon-token.js",
119
119
  "default": "./dist/lib/anon-token.js"
120
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
+ },
126
+ "./lib/passwordless": {
127
+ "types": "./dist/lib/passwordless.d.ts",
128
+ "import": "./dist/lib/passwordless.js",
129
+ "default": "./dist/lib/passwordless.js"
130
+ },
121
131
  "./lib/mfa-totp": {
122
132
  "types": "./dist/lib/mfa-totp.d.ts",
123
133
  "import": "./dist/lib/mfa-totp.js",