@xeplr/auth 1.0.0 → 1.0.1
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/bin/migrate.js +43 -24
- package/bin/server.js +12 -30
- package/index.js +135 -51
- package/lib/adminRouter.js +20 -92
- package/lib/authHelper.js +26 -3
- package/lib/authMiddleware.js +39 -13
- package/lib/authRouter.js +140 -11
- package/lib/authService.js +327 -9
- package/lib/hooks.js +50 -0
- package/lib/mtMembershipMiddleware.js +61 -0
- package/lib/seed.js +74 -0
- package/lib/sessionService.js +54 -6
- package/lib/ticketService.js +88 -0
- package/lib/tokenDecision.js +45 -0
- package/migrations/0001_extensions.sql +9 -0
- package/migrations/0002_users.sql +31 -0
- package/migrations/0003_catalog_tables.sql +82 -0
- package/migrations/0004_role_mappings.sql +78 -0
- package/migrations/0005_user_tenants_mapping.sql +39 -0
- package/migrations/0006_seed_catalog.sql +120 -0
- package/models/Api.js +3 -0
- package/models/ApisRolesMapping.js +3 -0
- package/models/Menu.js +3 -0
- package/models/MenuRolesMapping.js +3 -0
- package/models/Role.js +8 -0
- package/models/UiElement.js +3 -0
- package/models/UiElementsRolesMapping.js +3 -0
- package/models/UiPage.js +3 -0
- package/models/UiPagesRolesMapping.js +3 -0
- package/models/User.js +3 -13
- package/models/UserRolesMapping.js +3 -0
- package/models/UserTenantsMapping.js +20 -2
- package/models/index.js +0 -2
- package/package.json +26 -5
- package/migrations/0001_users.js +0 -22
- package/migrations/0002_menus.js +0 -16
- package/migrations/0003_apis.js +0 -16
- package/migrations/0004_uiPages.js +0 -16
- package/migrations/0005_uiElements.js +0 -16
- package/migrations/0006_roles.js +0 -15
- package/migrations/0007_apisRolesMapping.js +0 -16
- package/migrations/0008_uiPagesRolesMapping.js +0 -16
- package/migrations/0009_uiElementsRolesMapping.js +0 -16
- package/migrations/0010_menuRolesMapping.js +0 -16
- package/migrations/0011_userRolesMapping.js +0 -16
- package/migrations/0012_users_add_reset_token.js +0 -13
- package/migrations/0013_add_isPublic.js +0 -27
- package/migrations/0014_users_add_activation_token.js +0 -13
- package/migrations/0015_add_mt_columns.js +0 -41
- package/migrations/0016_tenants.js +0 -23
- package/migrations/0017_userTenantsMapping.js +0 -20
- package/models/Tenant.js +0 -63
- package/seeds/001_roles.js +0 -26
- package/seeds/002_apis.js +0 -70
- package/seeds/003_pages.js +0 -41
- package/seeds/004_elements.js +0 -46
- package/seeds/005_menus.js +0 -35
- package/seeds/006_default_tenant.js +0 -50
- package/seeds/zzz_admin_access.js +0 -49
package/lib/hooks.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// One generic hook dispatcher for the whole library. Every mutating operation
|
|
2
|
+
// in xeplr-auth (register, activate, role CRUD, mapping toggles, master CRUD,
|
|
3
|
+
// ...) fires through here, tagged with an identifier ('user', 'roles', 'apis',
|
|
4
|
+
// 'uiPages', 'uiElements', 'menus', 'userRolesMapping', ...) and a fixed event
|
|
5
|
+
// ('create' | 'update' | 'delete', plus 'login' — not a CRUD event, but kept
|
|
6
|
+
// on 'user' since it's a meaningful lifecycle moment too).
|
|
7
|
+
//
|
|
8
|
+
// The identifiers are internal to the library (see the fire() call sites for
|
|
9
|
+
// the authoritative list) — you don't need a pre-built namespace object to use
|
|
10
|
+
// one, just the string, documented here and at each call site:
|
|
11
|
+
// require('@xeplr/auth').hooks.on('user', 'create', function(id) { ... });
|
|
12
|
+
// require('@xeplr/auth').hooks.on('roles', 'create', function(id) { ... });
|
|
13
|
+
//
|
|
14
|
+
// One handler per (identifier, event) — registering again REPLACES the
|
|
15
|
+
// previous one, not an accumulating list. No handler registered = silent
|
|
16
|
+
// no-op. A failing handler is caught + logged, never breaks the operation
|
|
17
|
+
// that fired it.
|
|
18
|
+
//
|
|
19
|
+
// setHooksEnabled(false) is a bulk kill-switch (e.g. for tests) — defaults to
|
|
20
|
+
// enabled (AUTH_ENABLE_HOOKS=false to default it off via env instead).
|
|
21
|
+
//
|
|
22
|
+
// NOTE on process boundaries: auth normally runs as its own service (AUTH_PORT),
|
|
23
|
+
// a separate process from your app's API. A hook registered from the API
|
|
24
|
+
// process is never seen here — register()/login()/etc. run in the auth
|
|
25
|
+
// service's own process. Assign hooks in the SAME script that calls boot():
|
|
26
|
+
// write your own tiny boot file (copy bin/server.js) that does
|
|
27
|
+
// require('@xeplr/auth').hooks.on('user', 'create', fn);
|
|
28
|
+
// require('@xeplr/auth').boot();
|
|
29
|
+
// and point your start-auth script at that file instead of the packaged bin.
|
|
30
|
+
|
|
31
|
+
var _enabled = process.env.AUTH_ENABLE_HOOKS !== 'false';
|
|
32
|
+
var _handlers = {};
|
|
33
|
+
|
|
34
|
+
function key(identifier, event) { return identifier + ':' + event; }
|
|
35
|
+
|
|
36
|
+
function setHooksEnabled(enabled) { _enabled = !!enabled; }
|
|
37
|
+
|
|
38
|
+
function on(identifier, event, fn) {
|
|
39
|
+
_handlers[key(identifier, event)] = fn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fire(identifier, event, id) {
|
|
43
|
+
if (!_enabled) return;
|
|
44
|
+
var fn = _handlers[key(identifier, event)];
|
|
45
|
+
if (!fn) return;
|
|
46
|
+
try { await fn(id); }
|
|
47
|
+
catch (err) { console.error('[xeplr-auth] hook (' + identifier + '.' + event + ') failed:', err.message); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { on, fire, setHooksEnabled };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { getMtConfig } = require('@xeplr/db');
|
|
2
|
+
const UserTenantsMapping = require('../models/UserTenantsMapping');
|
|
3
|
+
|
|
4
|
+
var SLOT_KEYS = ['l1', 'l2', 'l3', 'l4'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rejects a request whose MT header values don't match a real membership
|
|
8
|
+
* grant for the caller — mtMiddleware (in @xeplr/db) trusts any header value
|
|
9
|
+
* to filter/stamp rows, this is what actually checks the caller is allowed to
|
|
10
|
+
* use it. Mount AFTER authMiddleware (needs req.user.id) and AFTER
|
|
11
|
+
* @xeplr/db's mtMiddleware() (reads the same headers).
|
|
12
|
+
*
|
|
13
|
+
* A header that's simply absent is left alone — the existing fail-closed
|
|
14
|
+
* query modifier in BaseModel already handles "missing" (empty result set).
|
|
15
|
+
* This only rejects a PRESENT but unauthorized value (403).
|
|
16
|
+
*
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {typeof UserTenantsMapping} [options.userTenantsMapping] - a bound
|
|
19
|
+
* model class, e.g. `auth.attach().model('UserTenantsMapping')` when auth
|
|
20
|
+
* runs as its own standalone service. Omit if you called auth.init() in
|
|
21
|
+
* this same process (Model.knex is already globally bound there).
|
|
22
|
+
*
|
|
23
|
+
* var { mtMembershipMiddleware } = require('@xeplr/auth');
|
|
24
|
+
* app.use(mtMiddleware()); // @xeplr/db — sets mtId context
|
|
25
|
+
* app.use(mtMembershipMiddleware()); // this — rejects forged values
|
|
26
|
+
*/
|
|
27
|
+
function mtMembershipMiddleware(options) {
|
|
28
|
+
options = options || {};
|
|
29
|
+
var Model = options.userTenantsMapping || UserTenantsMapping;
|
|
30
|
+
|
|
31
|
+
return async function(req, res, next) {
|
|
32
|
+
try {
|
|
33
|
+
var mtConfig = getMtConfig();
|
|
34
|
+
if (!mtConfig.enabled || !req.user || !req.user.id) return next();
|
|
35
|
+
|
|
36
|
+
for (var i = 0; i < SLOT_KEYS.length; i++) {
|
|
37
|
+
var key = SLOT_KEYS[i];
|
|
38
|
+
var slot = mtConfig.slots[key];
|
|
39
|
+
if (!slot) continue;
|
|
40
|
+
|
|
41
|
+
var value = req.headers[slot.header];
|
|
42
|
+
if (!value) continue;
|
|
43
|
+
|
|
44
|
+
var grant = await Model.query()
|
|
45
|
+
.where({ userId: req.user.id, level: key, value: value, isActive: true })
|
|
46
|
+
.first();
|
|
47
|
+
|
|
48
|
+
if (!grant) {
|
|
49
|
+
return res.status(403).json({ error: 'Not authorized for ' + (slot.name || key) + ' "' + value + '"' });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
next();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (req.log) req.log.error(err.message, { stack: err.stack });
|
|
56
|
+
res.status(500).json({ error: 'Something went wrong' });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = mtMembershipMiddleware;
|
package/lib/seed.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
var { generateId, hashPassword } = require('./authHelper');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ensure a Super Admin user exists and is mapped to the "Super Admin" role.
|
|
5
|
+
*
|
|
6
|
+
* Brand-neutral: the caller supplies WHO (email/password/name); this owns HOW
|
|
7
|
+
* (hashing, columns, role mapping). Insert-if-absent, so it is idempotent and
|
|
8
|
+
* safe to call from a migration on every run. Requires the base auth migrations
|
|
9
|
+
* (roles + users + userRolesMapping) to have run first.
|
|
10
|
+
*
|
|
11
|
+
* @param {import('knex').Knex} knex
|
|
12
|
+
* @param {{ email:string, password:string, name?:string }} admin
|
|
13
|
+
* @returns {Promise<{ userId:string, created:boolean, roleAssigned:boolean }>}
|
|
14
|
+
*/
|
|
15
|
+
async function seedSuperAdmin(knex, admin) {
|
|
16
|
+
if (!admin || !admin.email || !admin.password) {
|
|
17
|
+
throw new Error('seedSuperAdmin: { email, password } are required');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var role = await knex('roles').where({ name: 'Super Admin' }).first();
|
|
21
|
+
if (!role) {
|
|
22
|
+
throw new Error('seedSuperAdmin: "Super Admin" role not found — run the base auth migrations first');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var now = new Date().toISOString();
|
|
26
|
+
var normalized = admin.email.toLowerCase();
|
|
27
|
+
var existing = await knex('users').where({ normalizedEmail: normalized }).first();
|
|
28
|
+
|
|
29
|
+
var userId;
|
|
30
|
+
var created = false;
|
|
31
|
+
if (existing) {
|
|
32
|
+
userId = existing.id;
|
|
33
|
+
} else {
|
|
34
|
+
userId = generateId();
|
|
35
|
+
var creds = await hashPassword(admin.password);
|
|
36
|
+
await knex('users').insert({
|
|
37
|
+
id: userId,
|
|
38
|
+
email: admin.email,
|
|
39
|
+
normalizedEmail: normalized,
|
|
40
|
+
name: admin.name || admin.email,
|
|
41
|
+
pwd: creds.hash,
|
|
42
|
+
pwdSalt: creds.salt,
|
|
43
|
+
isActive: true,
|
|
44
|
+
isActivated: true,
|
|
45
|
+
activatedOn: now,
|
|
46
|
+
activatedBy: userId,
|
|
47
|
+
recordCreatedDate: now,
|
|
48
|
+
recordModifiedDate: now,
|
|
49
|
+
recordCreatedBy: userId,
|
|
50
|
+
recordModifiedBy: userId
|
|
51
|
+
});
|
|
52
|
+
created = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
var mapped = await knex('userRolesMapping').where({ userId: userId, roleId: role.id }).first();
|
|
56
|
+
var roleAssigned = false;
|
|
57
|
+
if (!mapped) {
|
|
58
|
+
await knex('userRolesMapping').insert({
|
|
59
|
+
id: generateId(),
|
|
60
|
+
userId: userId,
|
|
61
|
+
roleId: role.id,
|
|
62
|
+
isActive: true,
|
|
63
|
+
recordCreatedDate: now,
|
|
64
|
+
recordModifiedDate: now,
|
|
65
|
+
recordCreatedBy: userId,
|
|
66
|
+
recordModifiedBy: userId
|
|
67
|
+
});
|
|
68
|
+
roleAssigned = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { userId: userId, created: created, roleAssigned: roleAssigned };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { seedSuperAdmin };
|
package/lib/sessionService.js
CHANGED
|
@@ -17,24 +17,24 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const { cache } = require('@xeplr/utils');
|
|
20
|
-
const { generateRefreshToken, generateAccessToken } = require('./authHelper');
|
|
20
|
+
const { generateRefreshToken, generateAccessToken, getToleranceSeconds } = require('./authHelper');
|
|
21
21
|
|
|
22
22
|
const SESSION_PREFIX = 'session:';
|
|
23
23
|
const ACCESS_PREFIX = 'access:';
|
|
24
24
|
const USER_PREFIX = 'sessions:user:';
|
|
25
25
|
|
|
26
26
|
function getRefreshTTL() {
|
|
27
|
-
const days = parseInt(process.env.
|
|
27
|
+
const days = parseInt(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS || '7');
|
|
28
28
|
return days * 24 * 60 * 60;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function getAccessTTL() {
|
|
32
|
-
const minutes = parseInt(process.env.
|
|
32
|
+
const minutes = parseInt(process.env.AUTH_ACCESS_TOKEN_TTL_MINUTES || '15');
|
|
33
33
|
return minutes * 60;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function getMaxSessions() {
|
|
37
|
-
return parseInt(process.env.
|
|
37
|
+
return parseInt(process.env.AUTH_MAX_SESSIONS_PER_USER || '5');
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -58,11 +58,13 @@ async function createSession(user) {
|
|
|
58
58
|
createdAt: now
|
|
59
59
|
}, refreshTTL);
|
|
60
60
|
|
|
61
|
-
// Store access token whitelist entry (for middleware check)
|
|
61
|
+
// Store access token whitelist entry (for middleware check). The entry lives
|
|
62
|
+
// accessTTL + tolerance so it's still present during the grace window — that's
|
|
63
|
+
// what lets an expired-but-tolerated token find its live session and slide.
|
|
62
64
|
await cache.set(ACCESS_PREFIX + accessToken, {
|
|
63
65
|
userId: user.id,
|
|
64
66
|
refreshToken
|
|
65
|
-
}, accessTTL);
|
|
67
|
+
}, accessTTL + getToleranceSeconds());
|
|
66
68
|
|
|
67
69
|
// Track session in user's sorted set
|
|
68
70
|
const client = cache.getClient();
|
|
@@ -126,6 +128,51 @@ async function validateAccessToken(accessToken) {
|
|
|
126
128
|
return await cache.get(ACCESS_PREFIX + accessToken);
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Slide an access token — the sliding refresh. Given an expired-but-tolerated
|
|
133
|
+
* access token (already signature-verified + session confirmed live by the
|
|
134
|
+
* middleware), mint a fresh access token for the SAME session and return it,
|
|
135
|
+
* so the middleware can hand it back on the response.
|
|
136
|
+
*
|
|
137
|
+
* Session-scoped IDEMPOTENT: if the session already slid to a newer token
|
|
138
|
+
* (a concurrent request beat us to it), hand that SAME token back instead of
|
|
139
|
+
* minting another — so 100 parallel requests yield ONE new token, not 100.
|
|
140
|
+
*
|
|
141
|
+
* The OLD token is deliberately left whitelisted (it expires on its own TTL),
|
|
142
|
+
* so other in-flight requests still carrying it keep passing during the window.
|
|
143
|
+
*
|
|
144
|
+
* NOTE: on a single node the check-then-set is race-free. Under HA/cluster,
|
|
145
|
+
* wrap the mint in a short Redis lock (or a Lua script) for atomicity.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} oldToken the expired-but-tolerated access token
|
|
148
|
+
* @param {object} claims its verified JWT claims { id, email, name, roles }
|
|
149
|
+
* @returns {Promise<string|null>} the fresh access token, or null if no session
|
|
150
|
+
*/
|
|
151
|
+
async function slideAccessToken(oldToken, claims) {
|
|
152
|
+
const wl = await cache.get(ACCESS_PREFIX + oldToken);
|
|
153
|
+
if (!wl || !wl.refreshToken) return null;
|
|
154
|
+
|
|
155
|
+
const session = await cache.get(SESSION_PREFIX + wl.refreshToken);
|
|
156
|
+
if (!session) return null;
|
|
157
|
+
|
|
158
|
+
// Already slid by a concurrent request → return the current token (idempotent).
|
|
159
|
+
if (session.accessToken && session.accessToken !== oldToken) {
|
|
160
|
+
return session.accessToken;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Mint from the token's own claims — no DB hit. (Roles refresh fully on the
|
|
164
|
+
// next /refresh or re-login; sliding just extends the active session.)
|
|
165
|
+
const newToken = generateAccessToken({
|
|
166
|
+
id: claims.id, email: claims.email, name: claims.name, roles: claims.roles || []
|
|
167
|
+
});
|
|
168
|
+
const whitelistTTL = getAccessTTL() + getToleranceSeconds();
|
|
169
|
+
|
|
170
|
+
await cache.set(ACCESS_PREFIX + newToken, { userId: wl.userId, refreshToken: wl.refreshToken }, whitelistTTL);
|
|
171
|
+
await cache.set(SESSION_PREFIX + wl.refreshToken, Object.assign({}, session, { accessToken: newToken }), getRefreshTTL());
|
|
172
|
+
|
|
173
|
+
return newToken;
|
|
174
|
+
}
|
|
175
|
+
|
|
129
176
|
/**
|
|
130
177
|
* Rotate a session — validate old refresh token, create new pair.
|
|
131
178
|
* Returns { accessToken, refreshToken, userId } or null.
|
|
@@ -204,6 +251,7 @@ module.exports = {
|
|
|
204
251
|
createSession,
|
|
205
252
|
getSession,
|
|
206
253
|
validateAccessToken,
|
|
254
|
+
slideAccessToken,
|
|
207
255
|
rotateSession,
|
|
208
256
|
destroySession,
|
|
209
257
|
destroyAllUserSessions
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ticketService — one-time-use tickets for out-of-band auth channels
|
|
3
|
+
* (SSE, WebSocket) where the browser can't attach a normal Authorization
|
|
4
|
+
* header.
|
|
5
|
+
*
|
|
6
|
+
* FLOW
|
|
7
|
+
* 1. Client makes an AUTHENTICATED POST to /auth/api/sse-ticket
|
|
8
|
+
* 2. issueTicket({ userId, scopes }) mints a random 24-byte token,
|
|
9
|
+
* stores it in Redis with a 30s TTL, returns { ticket, expiresIn }
|
|
10
|
+
* 3. Client opens EventSource('/events?ticket=<tk>&topics=...')
|
|
11
|
+
* 4. SSE handler calls consumeTicket(tk) which atomically GETDEL's
|
|
12
|
+
* the token. Returns { userId, scopes } on success, null on
|
|
13
|
+
* missing / expired / already-consumed.
|
|
14
|
+
* 5. SSE handler enforces the requested topics are within `scopes`.
|
|
15
|
+
*
|
|
16
|
+
* SECURITY PROPERTIES
|
|
17
|
+
* · Single-use — Redis GETDEL is atomic; two concurrent connects
|
|
18
|
+
* with the same URL cannot both succeed
|
|
19
|
+
* · Short-lived — default 30s; only needs to survive the roundtrip
|
|
20
|
+
* from /sse-ticket → EventSource open
|
|
21
|
+
* · Bearer-free — no long-lived token ever appears in a query string
|
|
22
|
+
* · Scope-bound — the ticket carries the exact set of subscribe
|
|
23
|
+
* scopes the user was authorized for at mint time;
|
|
24
|
+
* the SSE handler enforces this per-topic
|
|
25
|
+
*
|
|
26
|
+
* STORAGE
|
|
27
|
+
* @xeplr/utils cache is Redis-backed via ioredis. We use client.getdel()
|
|
28
|
+
* directly (bypassing cache.set's error swallowing) so a Redis outage
|
|
29
|
+
* surfaces immediately at ticket mint time — the caller learns "auth
|
|
30
|
+
* backend unavailable" instead of "your ticket silently didn't stick
|
|
31
|
+
* and the connection is now inexplicably 401".
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const crypto = require('crypto');
|
|
35
|
+
const { cache } = require('@xeplr/utils');
|
|
36
|
+
|
|
37
|
+
const TICKET_PREFIX = 'ticket:sse:';
|
|
38
|
+
const DEFAULT_TTL = 30; // seconds
|
|
39
|
+
|
|
40
|
+
function newTicketId() {
|
|
41
|
+
return crypto.randomBytes(24).toString('base64url');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Issue a one-time ticket bound to a user + scope set.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} payload — { userId, scopes: string[], ... }
|
|
48
|
+
* @param {object} [opts]
|
|
49
|
+
* @param {number} [opts.ttl] — seconds (default 30)
|
|
50
|
+
* @returns {Promise<{ ticket: string, expiresIn: number }>}
|
|
51
|
+
* @throws if Redis is unreachable (fail loud — this
|
|
52
|
+
* is an auth surface, silent failure is a bug)
|
|
53
|
+
*/
|
|
54
|
+
async function issueTicket(payload, opts) {
|
|
55
|
+
if (!payload || !payload.userId) {
|
|
56
|
+
throw new Error('ticketService.issueTicket: payload.userId is required');
|
|
57
|
+
}
|
|
58
|
+
var ttl = (opts && opts.ttl) || DEFAULT_TTL;
|
|
59
|
+
var ticket = newTicketId();
|
|
60
|
+
|
|
61
|
+
var client = cache.getClient();
|
|
62
|
+
await client.set(TICKET_PREFIX + ticket, JSON.stringify(payload), 'EX', ttl);
|
|
63
|
+
return { ticket: ticket, expiresIn: ttl };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Atomically consume a ticket. Returns the payload on success, null if
|
|
68
|
+
* the ticket doesn't exist / expired / was already consumed.
|
|
69
|
+
*
|
|
70
|
+
* Guarantees single-use even under concurrent lookups (Redis GETDEL is
|
|
71
|
+
* a single atomic op).
|
|
72
|
+
*
|
|
73
|
+
* @param {string} ticket
|
|
74
|
+
* @returns {Promise<object|null>}
|
|
75
|
+
*/
|
|
76
|
+
async function consumeTicket(ticket) {
|
|
77
|
+
if (!ticket || typeof ticket !== 'string') return null;
|
|
78
|
+
var client = cache.getClient();
|
|
79
|
+
var raw = await client.getdel(TICKET_PREFIX + ticket);
|
|
80
|
+
if (!raw) return null;
|
|
81
|
+
try { return JSON.parse(raw); }
|
|
82
|
+
catch (_) { return null; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
issueTicket: issueTicket,
|
|
87
|
+
consumeTicket: consumeTicket
|
|
88
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure token decision — NO I/O, NO side effects. This is the heart of the
|
|
2
|
+
// tolerance-refresh scheme, deliberately isolated so its edges are covered by
|
|
3
|
+
// tests instead of discovered in production. The middleware does the I/O
|
|
4
|
+
// (verify signature, look up the session) and hands the facts to decide().
|
|
5
|
+
//
|
|
6
|
+
// decide({ exp, now, toleranceSeconds, sessionLive, signatureValid })
|
|
7
|
+
// → { action, reason }
|
|
8
|
+
//
|
|
9
|
+
// action:
|
|
10
|
+
// 'ok' → token is valid; serve normally
|
|
11
|
+
// 'slide' → token expired but within tolerance; serve AND issue a fresh
|
|
12
|
+
// token (attach it to the response) — the sliding refresh
|
|
13
|
+
// 'reject' → do not serve (401)
|
|
14
|
+
//
|
|
15
|
+
// inputs (all plain values — that's the point):
|
|
16
|
+
// exp token expiry, UNIX seconds (JWT `exp`)
|
|
17
|
+
// now current time, UNIX seconds
|
|
18
|
+
// toleranceSeconds grace past expiry during which an expired token is still
|
|
19
|
+
// honored + refreshed. 0 = strict (reject on expiry).
|
|
20
|
+
// sessionLive is the session still valid server-side (NOT logged
|
|
21
|
+
// out / revoked)? A revoke is NEVER graced.
|
|
22
|
+
// signatureValid did the JWT signature verify?
|
|
23
|
+
//
|
|
24
|
+
// Order matters: signature → revocation → expiry. A bad signature or a revoked
|
|
25
|
+
// session is rejected regardless of the clock; only a validly-signed, live
|
|
26
|
+
// session gets the tolerance grace.
|
|
27
|
+
|
|
28
|
+
function decide(input) {
|
|
29
|
+
input = input || {};
|
|
30
|
+
var exp = input.exp;
|
|
31
|
+
var now = input.now;
|
|
32
|
+
var tolerance = input.toleranceSeconds || 0;
|
|
33
|
+
|
|
34
|
+
if (!input.signatureValid) return { action: 'reject', reason: 'bad-signature' };
|
|
35
|
+
if (!input.sessionLive) return { action: 'reject', reason: 'revoked' }; // never grace a revoke
|
|
36
|
+
if (typeof exp !== 'number' || typeof now !== 'number') {
|
|
37
|
+
return { action: 'reject', reason: 'no-expiry' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (now <= exp) return { action: 'ok', reason: 'valid' };
|
|
41
|
+
if (now <= exp + tolerance) return { action: 'slide', reason: 'within-tolerance' };
|
|
42
|
+
return { action: 'reject', reason: 'expired' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { decide };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- 0001_extensions.sql
|
|
2
|
+
-- pgcrypto gives us gen_random_bytes() for id generation (see 0006's
|
|
3
|
+
-- encode(gen_random_bytes(12),'hex') — matches the app's own generateId()
|
|
4
|
+
-- shape) and crypt()/gen_salt('bf') — real bcrypt-compatible hashes, usable
|
|
5
|
+
-- from plain SQL for any app-level super-admin bootstrap migration (see
|
|
6
|
+
-- lib/seed.js's seedSuperAdmin, or roll your own like this). Node's `bcrypt`
|
|
7
|
+
-- package can compare() against these hashes directly (same algorithm family).
|
|
8
|
+
|
|
9
|
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- 0002_users.sql
|
|
2
|
+
|
|
3
|
+
CREATE TABLE "users" (
|
|
4
|
+
"id" varchar(25) PRIMARY KEY,
|
|
5
|
+
"email" varchar(255) NOT NULL,
|
|
6
|
+
"normalizedEmail" varchar(255),
|
|
7
|
+
"phoneNumber" varchar(255),
|
|
8
|
+
"phoneVerified" boolean DEFAULT false,
|
|
9
|
+
"phoneVerifiedOn" timestamp,
|
|
10
|
+
"name" varchar(255),
|
|
11
|
+
"pwd" varchar(255),
|
|
12
|
+
"pwdSalt" varchar(100),
|
|
13
|
+
"profilePicUrl" varchar(500),
|
|
14
|
+
"isActive" boolean DEFAULT false,
|
|
15
|
+
"isActivated" boolean DEFAULT false,
|
|
16
|
+
"activatedOn" timestamp,
|
|
17
|
+
"activatedBy" varchar(25),
|
|
18
|
+
"resetToken" varchar(255),
|
|
19
|
+
"resetTokenExpiry" timestamp,
|
|
20
|
+
"activationToken" varchar(255),
|
|
21
|
+
"mtId1" varchar(25),
|
|
22
|
+
"mtId2" varchar(25),
|
|
23
|
+
"mtId3" varchar(25),
|
|
24
|
+
"mtId4" varchar(25),
|
|
25
|
+
"recordCreatedDate" timestamp,
|
|
26
|
+
"recordModifiedDate" timestamp,
|
|
27
|
+
"recordCreatedBy" varchar(25),
|
|
28
|
+
"recordModifiedBy" varchar(25)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX "users_normalizedEmail_index" ON "users" ("normalizedEmail");
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
-- 0003_catalog_tables.sql
|
|
2
|
+
-- roles + the four access-catalog tables (apis/uiPages/uiElements/menus),
|
|
3
|
+
-- each independently role-mappable and each with an isPublic escape hatch
|
|
4
|
+
-- (accessService.getPublicItems() unions isPublic:true rows into every
|
|
5
|
+
-- authenticated user's access regardless of role).
|
|
6
|
+
|
|
7
|
+
CREATE TABLE "roles" (
|
|
8
|
+
"id" varchar(25) PRIMARY KEY,
|
|
9
|
+
"name" varchar(255),
|
|
10
|
+
"isActive" boolean DEFAULT false,
|
|
11
|
+
"mtId1" varchar(25),
|
|
12
|
+
"mtId2" varchar(25),
|
|
13
|
+
"mtId3" varchar(25),
|
|
14
|
+
"mtId4" varchar(25),
|
|
15
|
+
"recordCreatedDate" timestamp,
|
|
16
|
+
"recordModifiedDate" timestamp,
|
|
17
|
+
"recordCreatedBy" varchar(25),
|
|
18
|
+
"recordModifiedBy" varchar(25)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE "apis" (
|
|
22
|
+
"id" varchar(25) PRIMARY KEY,
|
|
23
|
+
"name" varchar(255),
|
|
24
|
+
"apiGroup" varchar(255),
|
|
25
|
+
"isPublic" boolean DEFAULT false,
|
|
26
|
+
"isActive" boolean DEFAULT false,
|
|
27
|
+
"mtId1" varchar(25),
|
|
28
|
+
"mtId2" varchar(25),
|
|
29
|
+
"mtId3" varchar(25),
|
|
30
|
+
"mtId4" varchar(25),
|
|
31
|
+
"recordCreatedDate" timestamp,
|
|
32
|
+
"recordModifiedDate" timestamp,
|
|
33
|
+
"recordCreatedBy" varchar(25),
|
|
34
|
+
"recordModifiedBy" varchar(25)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE "uiPages" (
|
|
38
|
+
"id" varchar(25) PRIMARY KEY,
|
|
39
|
+
"name" varchar(255),
|
|
40
|
+
"uiPagesGroup" varchar(255),
|
|
41
|
+
"isPublic" boolean DEFAULT false,
|
|
42
|
+
"isActive" boolean DEFAULT false,
|
|
43
|
+
"mtId1" varchar(25),
|
|
44
|
+
"mtId2" varchar(25),
|
|
45
|
+
"mtId3" varchar(25),
|
|
46
|
+
"mtId4" varchar(25),
|
|
47
|
+
"recordCreatedDate" timestamp,
|
|
48
|
+
"recordModifiedDate" timestamp,
|
|
49
|
+
"recordCreatedBy" varchar(25),
|
|
50
|
+
"recordModifiedBy" varchar(25)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE "uiElements" (
|
|
54
|
+
"id" varchar(25) PRIMARY KEY,
|
|
55
|
+
"name" varchar(255),
|
|
56
|
+
"uiElementsGroup" varchar(255),
|
|
57
|
+
"isActive" boolean DEFAULT false,
|
|
58
|
+
"mtId1" varchar(25),
|
|
59
|
+
"mtId2" varchar(25),
|
|
60
|
+
"mtId3" varchar(25),
|
|
61
|
+
"mtId4" varchar(25),
|
|
62
|
+
"recordCreatedDate" timestamp,
|
|
63
|
+
"recordModifiedDate" timestamp,
|
|
64
|
+
"recordCreatedBy" varchar(25),
|
|
65
|
+
"recordModifiedBy" varchar(25)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE TABLE "menus" (
|
|
69
|
+
"id" varchar(25) PRIMARY KEY,
|
|
70
|
+
"name" varchar(255),
|
|
71
|
+
"menuGroup" varchar(255),
|
|
72
|
+
"isPublic" boolean DEFAULT false,
|
|
73
|
+
"isActive" boolean DEFAULT false,
|
|
74
|
+
"mtId1" varchar(25),
|
|
75
|
+
"mtId2" varchar(25),
|
|
76
|
+
"mtId3" varchar(25),
|
|
77
|
+
"mtId4" varchar(25),
|
|
78
|
+
"recordCreatedDate" timestamp,
|
|
79
|
+
"recordModifiedDate" timestamp,
|
|
80
|
+
"recordCreatedBy" varchar(25),
|
|
81
|
+
"recordModifiedBy" varchar(25)
|
|
82
|
+
);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- 0004_role_mappings.sql
|
|
2
|
+
-- Join tables: which roles can see which api/page/element/menu, and which
|
|
3
|
+
-- users hold which roles.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE "apisRolesMapping" (
|
|
6
|
+
"id" varchar(25) PRIMARY KEY,
|
|
7
|
+
"roleId" varchar(25) REFERENCES "roles"("id"),
|
|
8
|
+
"apiId" varchar(25) REFERENCES "apis"("id"),
|
|
9
|
+
"isActive" boolean DEFAULT false,
|
|
10
|
+
"mtId1" varchar(25),
|
|
11
|
+
"mtId2" varchar(25),
|
|
12
|
+
"mtId3" varchar(25),
|
|
13
|
+
"mtId4" varchar(25),
|
|
14
|
+
"recordCreatedDate" timestamp,
|
|
15
|
+
"recordModifiedDate" timestamp,
|
|
16
|
+
"recordCreatedBy" varchar(25),
|
|
17
|
+
"recordModifiedBy" varchar(25)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE "uiPagesRolesMapping" (
|
|
21
|
+
"id" varchar(25) PRIMARY KEY,
|
|
22
|
+
"roleId" varchar(25) REFERENCES "roles"("id"),
|
|
23
|
+
"uiPageId" varchar(25) REFERENCES "uiPages"("id"),
|
|
24
|
+
"isActive" boolean DEFAULT false,
|
|
25
|
+
"mtId1" varchar(25),
|
|
26
|
+
"mtId2" varchar(25),
|
|
27
|
+
"mtId3" varchar(25),
|
|
28
|
+
"mtId4" varchar(25),
|
|
29
|
+
"recordCreatedDate" timestamp,
|
|
30
|
+
"recordModifiedDate" timestamp,
|
|
31
|
+
"recordCreatedBy" varchar(25),
|
|
32
|
+
"recordModifiedBy" varchar(25)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE "uiElementsRolesMapping" (
|
|
36
|
+
"id" varchar(25) PRIMARY KEY,
|
|
37
|
+
"roleId" varchar(25) REFERENCES "roles"("id"),
|
|
38
|
+
"uiElementId" varchar(25) REFERENCES "uiElements"("id"),
|
|
39
|
+
"isActive" boolean DEFAULT false,
|
|
40
|
+
"mtId1" varchar(25),
|
|
41
|
+
"mtId2" varchar(25),
|
|
42
|
+
"mtId3" varchar(25),
|
|
43
|
+
"mtId4" varchar(25),
|
|
44
|
+
"recordCreatedDate" timestamp,
|
|
45
|
+
"recordModifiedDate" timestamp,
|
|
46
|
+
"recordCreatedBy" varchar(25),
|
|
47
|
+
"recordModifiedBy" varchar(25)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE "menuRolesMapping" (
|
|
51
|
+
"id" varchar(25) PRIMARY KEY,
|
|
52
|
+
"menuId" varchar(25) REFERENCES "menus"("id"),
|
|
53
|
+
"roleId" varchar(25) REFERENCES "roles"("id"),
|
|
54
|
+
"isActive" boolean DEFAULT false,
|
|
55
|
+
"mtId1" varchar(25),
|
|
56
|
+
"mtId2" varchar(25),
|
|
57
|
+
"mtId3" varchar(25),
|
|
58
|
+
"mtId4" varchar(25),
|
|
59
|
+
"recordCreatedDate" timestamp,
|
|
60
|
+
"recordModifiedDate" timestamp,
|
|
61
|
+
"recordCreatedBy" varchar(25),
|
|
62
|
+
"recordModifiedBy" varchar(25)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE "userRolesMapping" (
|
|
66
|
+
"id" varchar(25) PRIMARY KEY,
|
|
67
|
+
"userId" varchar(25) REFERENCES "users"("id"),
|
|
68
|
+
"roleId" varchar(25) REFERENCES "roles"("id"),
|
|
69
|
+
"isActive" boolean DEFAULT false,
|
|
70
|
+
"mtId1" varchar(25),
|
|
71
|
+
"mtId2" varchar(25),
|
|
72
|
+
"mtId3" varchar(25),
|
|
73
|
+
"mtId4" varchar(25),
|
|
74
|
+
"recordCreatedDate" timestamp,
|
|
75
|
+
"recordModifiedDate" timestamp,
|
|
76
|
+
"recordCreatedBy" varchar(25),
|
|
77
|
+
"recordModifiedBy" varchar(25)
|
|
78
|
+
);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- 0005_user_tenants_mapping.sql
|
|
2
|
+
-- Row-per-level membership registry. `level` is the generic MT slot (l1-l4,
|
|
3
|
+
-- matching mtId1-4) — not the app-defined name (companyId/workspaceId/...);
|
|
4
|
+
-- that name<->slot mapping lives only in the app's own registerMTs() config
|
|
5
|
+
-- (see @xeplr/db's BaseModel.registerMTs). `value` is the app's own id at
|
|
6
|
+
-- that level — auth doesn't own or validate a tenant tree, it just records
|
|
7
|
+
-- "this user is a member at this level with this value".
|
|
8
|
+
--
|
|
9
|
+
-- This table is deliberately NOT multiTenant-filtered by BaseModel (see
|
|
10
|
+
-- UserTenantsMapping.js's `multiTenant: false`) — it must be queryable before
|
|
11
|
+
-- any active scope exists (chicken-and-egg: you need this table to find out
|
|
12
|
+
-- which company to activate in the first place).
|
|
13
|
+
|
|
14
|
+
CREATE TABLE "userTenantsMapping" (
|
|
15
|
+
"id" varchar(25) PRIMARY KEY,
|
|
16
|
+
"userId" varchar(25) NOT NULL REFERENCES "users"("id"),
|
|
17
|
+
"level" varchar(2) NOT NULL CHECK ("level" IN ('l1', 'l2', 'l3', 'l4')),
|
|
18
|
+
"value" varchar(255) NOT NULL,
|
|
19
|
+
"roleId" varchar(25) REFERENCES "roles"("id"),
|
|
20
|
+
"isActive" boolean DEFAULT true,
|
|
21
|
+
"mtId1" varchar(25),
|
|
22
|
+
"mtId2" varchar(25),
|
|
23
|
+
"mtId3" varchar(25),
|
|
24
|
+
"mtId4" varchar(25),
|
|
25
|
+
"recordCreatedDate" timestamp,
|
|
26
|
+
"recordModifiedDate" timestamp,
|
|
27
|
+
"recordCreatedBy" varchar(25),
|
|
28
|
+
"recordModifiedBy" varchar(25),
|
|
29
|
+
UNIQUE ("userId", "level", "value")
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
-- Enforcement lookup (mtMembershipMiddleware): "does this user have a grant
|
|
33
|
+
-- at level X with value Y?"
|
|
34
|
+
CREATE INDEX "userTenantsMapping_userId_level_value_index"
|
|
35
|
+
ON "userTenantsMapping" ("userId", "level", "value");
|
|
36
|
+
|
|
37
|
+
-- "Who has access to this company/workspace?" lookups.
|
|
38
|
+
CREATE INDEX "userTenantsMapping_level_value_index"
|
|
39
|
+
ON "userTenantsMapping" ("level", "value");
|