@mostajs/auth 3.1.0 → 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.
@@ -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.1.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",
@@ -118,6 +118,11 @@
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
+ },
121
126
  "./lib/mfa-totp": {
122
127
  "types": "./dist/lib/mfa-totp.d.ts",
123
128
  "import": "./dist/lib/mfa-totp.js",