@maestro-js/auth 1.0.0-alpha.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,154 @@
1
+ import * as _maestro_js_crypt from '@maestro-js/crypt';
2
+ import { Iso } from 'iso-fns2';
3
+ import { Session } from '@maestro-js/session';
4
+ import { Hash } from '@maestro-js/hash';
5
+
6
+ interface RememberMeTokenType {
7
+ selector: string;
8
+ verifier: string;
9
+ }
10
+ interface RememberMeTokenRecordType<Id, DeviceContext> {
11
+ authenticatableId: Id;
12
+ deviceContext: DeviceContext;
13
+ selector: string;
14
+ verifierHash: string;
15
+ expirationDate: Iso.Instant | null;
16
+ rotatedDate: Iso.Instant | null;
17
+ }
18
+ interface RememberMeOptionsType<Id, DeviceContext> {
19
+ maxRememberMeSeconds: number;
20
+ tokenReuseGracePeriodSeconds?: number;
21
+ create(options: RememberMeTokenRecordType<Id, DeviceContext>): Promise<unknown>;
22
+ getBySelector(selector: string): Promise<RememberMeTokenRecordType<Id, DeviceContext> | null>;
23
+ rotateBySelector(selector: string, token: {
24
+ verifierHash: string;
25
+ selector: string;
26
+ }): Promise<void>;
27
+ revokeBySelector(selector: string): Promise<void>;
28
+ revokeByAuthenticatableId(id: Id): Promise<void>;
29
+ validateDeviceContext?(creatingDeviceContext: DeviceContext, currentDeviceContext: DeviceContext): Promise<boolean> | boolean;
30
+ }
31
+
32
+ declare function create<Id, Credentials, DeviceContext>(config: Auth.Internal.AuthServiceConfig<Id, Credentials, DeviceContext>): Auth.Internal.AuthService<Id, Credentials, DeviceContext>;
33
+ declare function provider<Key extends Auth.Provider.Key>(key: Key): Auth.AuthService<Key>;
34
+ /**
35
+ * Session-based authentication with pluggable credential drivers and optional remember-me tokens.
36
+ * Call methods directly on the default instance, or use `Auth.Provider.create()` for named instances.
37
+ */
38
+ declare const AuthBase: {
39
+ Provider: {
40
+ create: typeof create;
41
+ register: (name: Auth.Provider.Key, item: Auth.AnyAuthService) => void;
42
+ };
43
+ provider: typeof provider;
44
+ };
45
+ type KeysWithFallback = keyof Auth.Provider.Keys extends never ? {
46
+ default: {
47
+ Id: unknown;
48
+ Credentials: Record<string, unknown>;
49
+ DeviceContext: unknown;
50
+ };
51
+ } : Auth.Provider.Keys;
52
+ declare const Auth: Auth.AuthService<'default'> & typeof AuthBase;
53
+ /**
54
+ * Session-based authentication with pluggable credential drivers and optional remember-me tokens.
55
+ *
56
+ * Provides session-based tracking with credential change detection,
57
+ * and encrypted remember-me cookies with automatic token rotation for
58
+ * long-lived sessions. Credential verification is handled by a pluggable
59
+ * driver (e.g. `Auth.drivers.password()`).
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * // Register with a credential driver (e.g. Password.create().verifyCredentials)
64
+ * Auth.Provider.register('default', Auth.Provider.create({
65
+ * name: 'web',
66
+ * verifyCredentials: passwordService.verifyCredentials,
67
+ * getAuthenticatableById: (id) => db.findById(id),
68
+ * updateCredentialsInvalidBefore: (id, date) => db.updateCredentialsInvalidBefore(id, date),
69
+ * crypt: Crypt.provider('default'),
70
+ * hash: Hash.provider('default'),
71
+ * rememberMeOptions: { maxRememberMeSeconds: 60 * 60 * 24 * 30, ...tokenStore }
72
+ * }))
73
+ *
74
+ * // Per-request usage
75
+ * const { id, cookie } = await Auth.login({ credentials, session, deviceContext })
76
+ * ```
77
+ */
78
+ declare namespace Auth {
79
+ type AuthService<ProviderKey extends Auth.Provider.Key = 'default'> = Internal.AuthService<KeysWithFallback[ProviderKey]['Id'], KeysWithFallback[ProviderKey]['Credentials'], KeysWithFallback[ProviderKey]['DeviceContext']>;
80
+ type AnyAuthService = Internal.AuthService<any, any, any>;
81
+ type AuthServiceConfig<ProviderKey extends Auth.Provider.Key = 'default'> = Internal.AuthServiceConfig<KeysWithFallback[ProviderKey]['Id'], KeysWithFallback[ProviderKey]['Credentials'], KeysWithFallback[ProviderKey]['DeviceContext']>;
82
+ type Authenticatable<ProviderKey extends Auth.Provider.Key = 'default'> = Internal.Authenticatable<KeysWithFallback[ProviderKey]['Id']>;
83
+ type RememberMeToken = RememberMeTokenType;
84
+ type RememberMeTokenRecord<ProviderKey extends Auth.Provider.Key = 'default'> = Internal.RememberMeTokenRecord<KeysWithFallback[ProviderKey]['Id'], KeysWithFallback[ProviderKey]['DeviceContext']>;
85
+ type RememberMeOptions<ProviderKey extends Auth.Provider.Key = 'default'> = Internal.RememberMeOptions<KeysWithFallback[ProviderKey]['Id'], KeysWithFallback[ProviderKey]['DeviceContext']>;
86
+ namespace Internal {
87
+ interface Authenticatable<Id> {
88
+ id: Id;
89
+ credentialsInvalidBefore: Iso.Instant | null;
90
+ }
91
+ type AuthService<Id, Credentials, DeviceContext> = {
92
+ login(options: {
93
+ credentials: Credentials;
94
+ rememberMe?: boolean | undefined;
95
+ deviceContext: DeviceContext;
96
+ session: Session | Promise<Session>;
97
+ }): Promise<{
98
+ id: Id | null;
99
+ cookie: string | null;
100
+ }>;
101
+ loginById: (options: {
102
+ id: Id;
103
+ rememberMe?: boolean;
104
+ deviceContext: DeviceContext;
105
+ session: Promise<Session> | Session;
106
+ }) => Promise<{
107
+ id: Id | null;
108
+ cookie: string | null;
109
+ }>;
110
+ authenticate: (options: {
111
+ cookieHeader: string | null | undefined;
112
+ deviceContext: DeviceContext;
113
+ session: Promise<Session> | Session;
114
+ }) => Promise<{
115
+ id: Id | null;
116
+ cookie: string | null;
117
+ }>;
118
+ logout: (options: {
119
+ cookieHeader: string | null | undefined;
120
+ session: Promise<Session> | Session;
121
+ }) => Promise<{
122
+ cookie: string | null;
123
+ id: Id | null;
124
+ }>;
125
+ logoutOtherDevices({ deviceContext, session }: {
126
+ deviceContext: DeviceContext;
127
+ session: Session | Promise<Session>;
128
+ }): Promise<{
129
+ id: Id | null;
130
+ cookie: string | null;
131
+ }>;
132
+ };
133
+ interface AuthServiceConfig<Id, Credentials, DeviceContext> {
134
+ name: string;
135
+ getAuthenticatableById(id: Id): Promise<Authenticatable<Id> | null>;
136
+ verifyCredentials(credentials: Credentials): Promise<Internal.Authenticatable<Id> | null>;
137
+ updateCredentialsInvalidBefore(id: Id, date: Iso.Instant): Promise<void>;
138
+ /** Used only for hashing remember-me token verifiers. `Hash.drivers.sha256()` is sufficient. */
139
+ hash: Hash.HashService;
140
+ crypt: _maestro_js_crypt.Crypt.CryptService;
141
+ cookieSameSite?: 'Strict' | 'Lax';
142
+ rememberMeOptions?: RememberMeOptions<Id, DeviceContext>;
143
+ }
144
+ type RememberMeTokenRecord<Id, DeviceContext> = RememberMeTokenRecordType<Id, DeviceContext>;
145
+ type RememberMeOptions<Id, DeviceContext> = RememberMeOptionsType<Id, DeviceContext>;
146
+ }
147
+ namespace Provider {
148
+ type Key = keyof KeysWithFallback;
149
+ interface Keys {
150
+ }
151
+ }
152
+ }
153
+
154
+ export { Auth };
package/dist/index.js ADDED
@@ -0,0 +1,336 @@
1
+ // src/index.ts
2
+ import { ServiceRegistry } from "@maestro-js/service-registry";
3
+ import { instantFns as instantFns2 } from "iso-fns2";
4
+
5
+ // src/create-remember-me-provider.ts
6
+ import { instantFns } from "iso-fns2";
7
+ import crypto from "crypto";
8
+ function createRememberMeProvider({
9
+ hash,
10
+ validateDeviceContext,
11
+ tokenReuseGracePeriodSeconds = 60,
12
+ ...db
13
+ }) {
14
+ async function create2(options) {
15
+ const selector = crypto.randomBytes(16).toString("base64url");
16
+ const verifier = crypto.randomBytes(32).toString("base64url");
17
+ const verifierHash = await hash.hash(verifier);
18
+ await db.create({
19
+ authenticatableId: options.id,
20
+ deviceContext: options.deviceContext,
21
+ expirationDate: options.expirationDate,
22
+ rotatedDate: null,
23
+ selector,
24
+ verifierHash
25
+ });
26
+ return { selector, verifier };
27
+ }
28
+ async function validate(options) {
29
+ const record = await db.getBySelector(options.token.selector);
30
+ return validateToken(options, record);
31
+ }
32
+ async function validateToken({ token, deviceContext }, record) {
33
+ if (!record) {
34
+ return null;
35
+ }
36
+ if (record.selector !== token.selector) {
37
+ return null;
38
+ }
39
+ if (record.expirationDate && record.expirationDate < instantFns.now()) {
40
+ return null;
41
+ }
42
+ if (record.rotatedDate && record.rotatedDate < instantFns.subtract(instantFns.now(), { seconds: tokenReuseGracePeriodSeconds })) {
43
+ await db.revokeByAuthenticatableId(record.authenticatableId);
44
+ return null;
45
+ }
46
+ if (!await hash.verify({ plaintext: token.verifier, hash: record.verifierHash })) {
47
+ return null;
48
+ }
49
+ if (validateDeviceContext && !await validateDeviceContext(record.deviceContext, deviceContext)) {
50
+ return null;
51
+ }
52
+ return record.authenticatableId;
53
+ }
54
+ async function rotate(previousSelector) {
55
+ const selector = crypto.randomBytes(16).toString("base64url");
56
+ const verifier = crypto.randomBytes(32).toString("base64url");
57
+ const verifierHash = await hash.hash(verifier);
58
+ await db.rotateBySelector(previousSelector, {
59
+ selector,
60
+ verifierHash
61
+ });
62
+ return { selector, verifier };
63
+ }
64
+ async function revoke(token) {
65
+ const record = await db.getBySelector(token.selector);
66
+ if (!record || record.selector !== token.selector || !await hash.verify({ plaintext: token.verifier, hash: record.verifierHash })) {
67
+ throw new Error("Failed to revoke");
68
+ }
69
+ await db.revokeBySelector(token.selector);
70
+ }
71
+ async function revokeAll(id) {
72
+ await db.revokeByAuthenticatableId(id);
73
+ }
74
+ return { create: create2, validate, rotate, revoke, revokeAll };
75
+ }
76
+ var notYetImplementedRememberMe = {
77
+ create: function(options) {
78
+ throw new Error("Function not implemented.");
79
+ },
80
+ validate: function(options) {
81
+ throw new Error("Function not implemented.");
82
+ },
83
+ rotate: function(selector) {
84
+ throw new Error("Function not implemented.");
85
+ },
86
+ revoke: function(token) {
87
+ throw new Error("Function not implemented.");
88
+ },
89
+ revokeAll: function(id) {
90
+ throw new Error("Function not implemented.");
91
+ }
92
+ };
93
+
94
+ // src/index.ts
95
+ function create(config) {
96
+ if (!/^[a-zA-Z0-9_-]+$/.test(config.name)) {
97
+ throw new Error(
98
+ `Authentication provider name must only contain alphanumeric characters, hyphens, and underscores. Got: "${config.name}"`
99
+ );
100
+ }
101
+ const hashService = config.hash;
102
+ const rememberMeProvider = config.rememberMeOptions ? createRememberMeProvider({ ...config.rememberMeOptions, hash: hashService }) : notYetImplementedRememberMe;
103
+ const REMEMBER_COOKIE_NAME = `_remember_${config.name}`;
104
+ const ID_SESSION_KEY = "_auth_id";
105
+ const AUTH_PROVIDER_NAME_SESSION_KEY = "_auth_provider_name";
106
+ const SESSION_CREATED_DATE_KEY = "_auth_session_created_date";
107
+ const NO_AUTH = { id: null, cookie: null };
108
+ async function privateLogin({
109
+ authenticatable,
110
+ rememberMe,
111
+ deviceContext,
112
+ session
113
+ }) {
114
+ session.set(ID_SESSION_KEY, authenticatable.id);
115
+ session.set(AUTH_PROVIDER_NAME_SESSION_KEY, config.name);
116
+ session.set(SESSION_CREATED_DATE_KEY, instantFns2.now());
117
+ session.regenerate();
118
+ let rememberMeCookieValue = null;
119
+ if (rememberMe && (config.rememberMeOptions?.maxRememberMeSeconds ?? 0) > 0) {
120
+ const token = await rememberMeProvider.create({
121
+ id: authenticatable.id,
122
+ expirationDate: instantFns2.add(instantFns2.now(), { seconds: config.rememberMeOptions?.maxRememberMeSeconds }),
123
+ deviceContext
124
+ });
125
+ rememberMeCookieValue = config.crypt.encryptString(encodeRememberMeToken(token));
126
+ }
127
+ return { id: authenticatable.id, cookie: getCookie({ rememberMeCookieValue }) };
128
+ }
129
+ async function login({
130
+ credentials,
131
+ rememberMe = false,
132
+ deviceContext,
133
+ session: sessionPromise
134
+ }) {
135
+ const session = await sessionPromise;
136
+ const authenticatable = await config.verifyCredentials(credentials);
137
+ if (!authenticatable) return NO_AUTH;
138
+ return privateLogin({ authenticatable, rememberMe, deviceContext, session });
139
+ }
140
+ async function loginById({
141
+ id,
142
+ rememberMe = false,
143
+ deviceContext,
144
+ session: sessionPromise
145
+ }) {
146
+ const session = await sessionPromise;
147
+ const authenticatable = await config.getAuthenticatableById(id);
148
+ if (authenticatable) {
149
+ return privateLogin({ authenticatable, rememberMe, deviceContext, session });
150
+ } else {
151
+ return NO_AUTH;
152
+ }
153
+ }
154
+ async function authenticate({
155
+ cookieHeader,
156
+ deviceContext,
157
+ session: sessionPromise
158
+ }) {
159
+ const session = await sessionPromise;
160
+ const authenticatableId = session.get(ID_SESSION_KEY);
161
+ const sessionAuthName = session.get(AUTH_PROVIDER_NAME_SESSION_KEY);
162
+ const sessionCreatedDate = session.get(SESSION_CREATED_DATE_KEY);
163
+ if (authenticatableId) {
164
+ if (sessionAuthName !== config.name) {
165
+ return NO_AUTH;
166
+ }
167
+ if (!sessionCreatedDate) {
168
+ return NO_AUTH;
169
+ }
170
+ const authenticatable = await config.getAuthenticatableById(authenticatableId);
171
+ if (!authenticatable) {
172
+ return NO_AUTH;
173
+ }
174
+ if (authenticatable.credentialsInvalidBefore && sessionCreatedDate <= authenticatable.credentialsInvalidBefore) {
175
+ return NO_AUTH;
176
+ }
177
+ return { id: authenticatableId, cookie: null };
178
+ } else {
179
+ const rememberMeToken = getRememberMeFromCookies(cookieHeader);
180
+ if (!rememberMeToken) {
181
+ return NO_AUTH;
182
+ }
183
+ const id = await rememberMeProvider.validate({ token: rememberMeToken, deviceContext });
184
+ if (!id) {
185
+ return NO_AUTH;
186
+ }
187
+ const authenticatable = await config.getAuthenticatableById(id);
188
+ if (!authenticatable) {
189
+ return NO_AUTH;
190
+ }
191
+ const newRememberMeToken = await rememberMeProvider.rotate(rememberMeToken.selector);
192
+ session.set(ID_SESSION_KEY, authenticatable.id);
193
+ session.set(AUTH_PROVIDER_NAME_SESSION_KEY, config.name);
194
+ session.set(SESSION_CREATED_DATE_KEY, instantFns2.now());
195
+ session.regenerate();
196
+ return {
197
+ id: authenticatable.id,
198
+ cookie: getCookie({
199
+ rememberMeCookieValue: config.crypt.encryptString(encodeRememberMeToken(newRememberMeToken))
200
+ })
201
+ };
202
+ }
203
+ }
204
+ function getRememberMeFromCookies(cookieHeader) {
205
+ const cookies = parseCookies(cookieHeader ?? "");
206
+ const rememberMeTokenEncoded = cookies[REMEMBER_COOKIE_NAME];
207
+ if (!rememberMeTokenEncoded) {
208
+ return null;
209
+ }
210
+ try {
211
+ const rememberMeToken = decodeRememberMeToken(config.crypt.decryptString(rememberMeTokenEncoded));
212
+ return rememberMeToken;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+ async function logout({
218
+ cookieHeader,
219
+ session: sessionPromise
220
+ }) {
221
+ const session = await sessionPromise;
222
+ session.invalidate();
223
+ const rememberMeToken = getRememberMeFromCookies(cookieHeader);
224
+ if (rememberMeToken) {
225
+ try {
226
+ await rememberMeProvider.revoke(rememberMeToken);
227
+ } catch {
228
+ }
229
+ }
230
+ return { cookie: getCookie({ rememberMeCookieValue: null }), id: null };
231
+ }
232
+ async function logoutOtherDevices({
233
+ deviceContext,
234
+ session: sessionPromise
235
+ }) {
236
+ const session = await sessionPromise;
237
+ const authenticatableId = session.get(ID_SESSION_KEY);
238
+ if (!authenticatableId) return NO_AUTH;
239
+ const now = instantFns2.now();
240
+ await config.updateCredentialsInvalidBefore(authenticatableId, now);
241
+ await rememberMeProvider.revokeAll(authenticatableId);
242
+ return privateLogin({
243
+ authenticatable: { id: authenticatableId, credentialsInvalidBefore: now },
244
+ rememberMe: false,
245
+ deviceContext,
246
+ session
247
+ });
248
+ }
249
+ function getCookie({ rememberMeCookieValue }) {
250
+ if (rememberMeCookieValue) {
251
+ return buildSetCookieHeader({
252
+ name: REMEMBER_COOKIE_NAME,
253
+ value: rememberMeCookieValue,
254
+ sameSite: config.cookieSameSite ?? "Strict",
255
+ maxAge: config.rememberMeOptions?.maxRememberMeSeconds ?? 0
256
+ });
257
+ } else {
258
+ return buildSetCookieHeader({
259
+ name: REMEMBER_COOKIE_NAME,
260
+ value: "",
261
+ sameSite: config.cookieSameSite ?? "Strict",
262
+ maxAge: 0
263
+ });
264
+ }
265
+ }
266
+ return {
267
+ login,
268
+ loginById,
269
+ authenticate,
270
+ logout,
271
+ logoutOtherDevices
272
+ };
273
+ }
274
+ var registry = ServiceRegistry.createRegistry(
275
+ ServiceRegistry.proxyFunctionsForObject
276
+ );
277
+ var Provider = {
278
+ create,
279
+ register: registry.register
280
+ };
281
+ function provider(key) {
282
+ const service = registry.resolve(key);
283
+ return {
284
+ login: service.login,
285
+ loginById: service.loginById,
286
+ authenticate: service.authenticate,
287
+ logout: service.logout,
288
+ logoutOtherDevices: service.logoutOtherDevices
289
+ };
290
+ }
291
+ var AuthBase = {
292
+ Provider,
293
+ provider
294
+ };
295
+ var Auth = {
296
+ ...AuthBase,
297
+ ...provider("default")
298
+ };
299
+ function encodeRememberMeToken(token) {
300
+ return `${token.selector}|${token.verifier}`;
301
+ }
302
+ function decodeRememberMeToken(token) {
303
+ const [selector, verifier] = token.split("|");
304
+ if (!selector || !verifier) {
305
+ return null;
306
+ }
307
+ return { selector, verifier };
308
+ }
309
+ function buildSetCookieHeader(options) {
310
+ const parts = [`${options.name}=${encodeURIComponent(options.value)}`];
311
+ parts.push(`Path=/`);
312
+ if (options.maxAge !== void 0) parts.push(`Max-Age=${options.maxAge}`);
313
+ parts.push("HttpOnly");
314
+ parts.push("Secure");
315
+ parts.push(`SameSite=${options.sameSite}`);
316
+ return parts.join("; ");
317
+ }
318
+ function parseCookies(cookieHeader) {
319
+ const cookies = {};
320
+ for (const pair of cookieHeader.split(";")) {
321
+ const index = pair.indexOf("=");
322
+ if (index === -1) continue;
323
+ const name = pair.slice(0, index).trim();
324
+ const value = pair.slice(index + 1).trim();
325
+ if (name) {
326
+ try {
327
+ cookies[name] = decodeURIComponent(value);
328
+ } catch {
329
+ }
330
+ }
331
+ }
332
+ return cookies;
333
+ }
334
+ export {
335
+ Auth
336
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@maestro-js/auth",
3
+ "type": "module",
4
+ "exports": {
5
+ ".": {
6
+ "types": "./dist/index.d.ts",
7
+ "default": "./dist/index.js"
8
+ }
9
+ },
10
+ "version": "1.0.0-alpha.0",
11
+ "publishConfig": {
12
+ "access": "restricted"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "dependencies": {
18
+ "iso-fns2": "npm:iso-fns@2.0.0-alpha.26",
19
+ "@maestro-js/service-registry": "1.0.0-alpha.0",
20
+ "@maestro-js/session": "1.0.0-alpha.0",
21
+ "@maestro-js/crypt": "1.0.0-alpha.0",
22
+ "@maestro-js/hash": "1.0.0-alpha.0",
23
+ "@maestro-js/custom-errors": "1.0.0-alpha.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.19.11"
27
+ },
28
+ "license": "UNLICENSED",
29
+ "engines": {
30
+ "node": ">=22.18.0"
31
+ },
32
+ "repository": "https://github.com/Marcato-Partners/maestro-js",
33
+ "scripts": {
34
+ "build": "tsup --config ../../tsup.config.ts",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "beartest ./tests/**/*",
37
+ "format": "prettier --write src/ tests/",
38
+ "lint": "prettier --check src/ tests/"
39
+ }
40
+ }