@manablox/auth 0.1.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/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@manablox/auth",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./src/index.ts"
9
+ }
10
+ },
11
+ "main": "./src/index.ts",
12
+ "types": "./src/index.ts",
13
+ "dependencies": {
14
+ "@manablox/core": "0.1.0",
15
+ "@manablox/db": "0.1.0",
16
+ "better-auth": "^1.7.2",
17
+ "drizzle-orm": "^0.45.2",
18
+ "@node-rs/argon2": "^2.2.0"
19
+ },
20
+ "devDependencies": {
21
+ "@manablox/config-typescript": "0.0.0",
22
+ "@types/node": "^26.4.1",
23
+ "typescript": "^7.0.2",
24
+ "vitest": "^5.0.0"
25
+ },
26
+ "scripts": {
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "vitest run"
29
+ }
30
+ }
package/src/api-key.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { ManabloxError } from '@manablox/core';
3
+ import { type Database, type Repositories, schema } from '@manablox/db';
4
+ import { and, eq, sql } from 'drizzle-orm';
5
+ import type { Principal, SpaceRole } from './rbac.js';
6
+
7
+ export interface IssuedApiKey {
8
+ id: string;
9
+ name: string;
10
+ /** The full secret, shown once at creation and never recoverable afterwards. */
11
+ key: string;
12
+ prefix: string;
13
+ }
14
+
15
+ export interface IssueApiKeyOptions {
16
+ expiresAt?: Date | undefined;
17
+ /** Spaces the key may act in. `null`/omitted issues an unrestricted key. */
18
+ spaceIds?: string[] | null | undefined;
19
+ }
20
+
21
+ const PREFIX = 'mbx';
22
+
23
+ /**
24
+ * Long-lived credentials for headless consumers.
25
+ *
26
+ * Keys are stored as a SHA-256 digest, never in plaintext, and are looked up by an
27
+ * indexed non-secret prefix so verification is one indexed read plus one constant-time
28
+ * comparison — not a scan-and-compare over every row.
29
+ */
30
+ export class ApiKeyService {
31
+ constructor(
32
+ private readonly db: Database,
33
+ private readonly repos: Repositories,
34
+ ) {}
35
+
36
+ async issue(
37
+ userId: string,
38
+ name: string,
39
+ options: IssueApiKeyOptions = {},
40
+ ): Promise<IssuedApiKey> {
41
+ const secret = randomBytes(32).toString('base64url');
42
+ const prefix = randomBytes(6).toString('hex');
43
+ const key = `${PREFIX}_${prefix}_${secret}`;
44
+
45
+ const [row] = await this.db
46
+ .insert(schema.apikeys)
47
+ .values({
48
+ userId,
49
+ name,
50
+ prefix,
51
+ start: key.slice(0, 12),
52
+ key: digest(secret),
53
+ expiresAt: options.expiresAt ?? null,
54
+ spaceIds: options.spaceIds?.length ? options.spaceIds : null,
55
+ })
56
+ .returning();
57
+
58
+ if (!row) throw new ManabloxError('apiKey.create.failed');
59
+ return { id: row.id, name, key, prefix };
60
+ }
61
+
62
+ /**
63
+ * Deletes the row rather than clearing `enabled`: a revoked key is never listed again
64
+ * or re-enabled, so a disabled row is only a secret digest left lying around.
65
+ */
66
+ async revoke(id: string): Promise<void> {
67
+ await this.db.delete(schema.apikeys).where(eq(schema.apikeys.id, id));
68
+ }
69
+
70
+ async list(userId: string) {
71
+ return this.db
72
+ .select({
73
+ id: schema.apikeys.id,
74
+ name: schema.apikeys.name,
75
+ start: schema.apikeys.start,
76
+ enabled: schema.apikeys.enabled,
77
+ expiresAt: schema.apikeys.expiresAt,
78
+ lastRequest: schema.apikeys.lastRequest,
79
+ spaceIds: schema.apikeys.spaceIds,
80
+ createdAt: schema.apikeys.createdAt,
81
+ })
82
+ .from(schema.apikeys)
83
+ .where(eq(schema.apikeys.userId, userId));
84
+ }
85
+
86
+ async resolve(presented: string): Promise<Principal | null> {
87
+ const parts = presented.split('_');
88
+ if (parts.length !== 3 || parts[0] !== PREFIX) return null;
89
+ const [, prefix, secret] = parts as [string, string, string];
90
+
91
+ const rows = await this.db
92
+ .select()
93
+ .from(schema.apikeys)
94
+ .where(and(eq(schema.apikeys.prefix, prefix), eq(schema.apikeys.enabled, true)))
95
+ .limit(1);
96
+
97
+ const row = rows[0];
98
+ if (!row) return null;
99
+ if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
100
+
101
+ const expected = Buffer.from(row.key, 'hex');
102
+ const actual = Buffer.from(digest(secret), 'hex');
103
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
104
+
105
+ // Best-effort touch; a failure here must never fail the request.
106
+ void this.db
107
+ .update(schema.apikeys)
108
+ .set({ lastRequest: new Date() })
109
+ .where(eq(schema.apikeys.id, row.id))
110
+ .catch(() => undefined);
111
+
112
+ const user = await this.repos.users.findById(row.userId);
113
+ if (!user || user.banned) return null;
114
+
115
+ const memberships = await this.repos.users.memberships(user.id);
116
+ const allowed = row.spaceIds?.length ? new Set(row.spaceIds) : null;
117
+ const spaces: Record<string, SpaceRole> = {};
118
+ for (const membership of memberships) {
119
+ if (allowed && !allowed.has(membership.spaceId)) continue;
120
+ spaces[membership.spaceId] = membership.role;
121
+ }
122
+
123
+ return {
124
+ userId: user.id,
125
+ email: user.email,
126
+ role: user.role,
127
+ spaces,
128
+ viaApiKey: true,
129
+ allowedSpaceIds: allowed ? [...allowed] : null,
130
+ };
131
+ }
132
+
133
+ /** Removes expired keys; scheduled by the jobs package. */
134
+ async pruneExpired(): Promise<number> {
135
+ const deleted = await this.db
136
+ .delete(schema.apikeys)
137
+ .where(sql`${schema.apikeys.expiresAt} is not null and ${schema.apikeys.expiresAt} < now()`)
138
+ .returning({ id: schema.apikeys.id });
139
+ return deleted.length;
140
+ }
141
+ }
142
+
143
+ const digest = (secret: string): string => createHash('sha256').update(secret).digest('hex');
package/src/index.ts ADDED
@@ -0,0 +1,155 @@
1
+ import type { AuthConfig, Manablox } from '@manablox/core';
2
+ import type { Database, Repositories } from '@manablox/db';
3
+ import { schema } from '@manablox/db';
4
+ import { betterAuth } from 'better-auth';
5
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
6
+ import { bearer } from 'better-auth/plugins';
7
+ import type { ApiKeyService } from './api-key.js';
8
+ import type { Principal } from './rbac.js';
9
+
10
+ export * from './api-key.js';
11
+ export * from './rbac.js';
12
+
13
+ export type ManabloxAuth = ReturnType<typeof createAuth>;
14
+
15
+ /** better-auth, wired to the Drizzle schema. Sessions are rows, so concurrent devices
16
+ * each hold their own. */
17
+ export interface AuthCallbacks {
18
+ /** Runs after a user row is created, inside better-auth's own transaction path. */
19
+ onUserCreated?: (userId: string) => Promise<void>;
20
+ }
21
+
22
+ export function createAuth(config: AuthConfig, db: Database, callbacks: AuthCallbacks = {}) {
23
+ return betterAuth({
24
+ secret: config.secret,
25
+ ...(config.baseUrl ? { baseURL: config.baseUrl } : {}),
26
+ trustedOrigins: config.trustedOrigins ?? [],
27
+
28
+ database: drizzleAdapter(db, {
29
+ provider: 'pg',
30
+ schema: {
31
+ user: schema.users,
32
+ session: schema.sessions,
33
+ account: schema.accounts,
34
+ verification: schema.verifications,
35
+ apikey: schema.apikeys,
36
+ },
37
+ }),
38
+
39
+ emailAndPassword: {
40
+ enabled: config.emailAndPassword ?? true,
41
+ minPasswordLength: 12,
42
+ // Argon2id: memory-hard, and the current OWASP recommendation over bcrypt, which
43
+ // also silently truncates passwords at 72 bytes.
44
+ password: {
45
+ hash: async (password) => {
46
+ const { hash } = await import('@node-rs/argon2');
47
+ return hash(password, { memoryCost: 19456, timeCost: 2, parallelism: 1 });
48
+ },
49
+ verify: async ({ hash: stored, password }) => {
50
+ const { verify } = await import('@node-rs/argon2');
51
+ return verify(stored, password);
52
+ },
53
+ },
54
+ },
55
+
56
+ session: {
57
+ expiresIn: config.sessionMaxAge ?? 60 * 60 * 24 * 7,
58
+ updateAge: 60 * 60 * 24,
59
+ cookieCache: { enabled: true, maxAge: 60 * 5 },
60
+ },
61
+
62
+ // `bearer` lets a non-browser client present the session token as an Authorization
63
+ // header instead of a cookie. Long-lived machine credentials are handled separately
64
+ // by `ApiKeyService` below — better-auth 1.7 ships no api-key plugin.
65
+ plugins: [bearer()],
66
+
67
+ databaseHooks: {
68
+ user: {
69
+ create: {
70
+ after: async (user) => {
71
+ await callbacks.onUserCreated?.(user.id);
72
+ },
73
+ },
74
+ },
75
+ },
76
+
77
+ advanced: { database: { generateId: () => crypto.randomUUID() } },
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Resolves a request's session into a `Principal`, including its space memberships.
83
+ * Returns `null` for anonymous requests rather than throwing — route guards decide.
84
+ */
85
+ export async function resolvePrincipal(
86
+ auth: ManabloxAuth,
87
+ repos: Repositories,
88
+ headers: Headers,
89
+ apiKeys?: ApiKeyService,
90
+ ): Promise<Principal | null> {
91
+ // An `x-api-key` header takes precedence: it identifies a machine consumer and never
92
+ // carries a browser session's ambient authority.
93
+ const presented = headers.get('x-api-key');
94
+ if (presented && apiKeys) {
95
+ const principal = await apiKeys.resolve(presented);
96
+ if (principal) return principal;
97
+ }
98
+
99
+ const session = await auth.api.getSession({ headers });
100
+ if (!session?.user) return null;
101
+
102
+ // Role and memberships come from the database, not from the session payload, so a
103
+ // permission change takes effect on the next request rather than when better-auth's
104
+ // session cache happens to expire.
105
+ const principal = await repos.users.principal(session.user.id);
106
+ if (!principal || principal.banned) return null;
107
+
108
+ return {
109
+ userId: session.user.id,
110
+ email: session.user.email,
111
+ role: principal.role,
112
+ spaces: principal.spaces,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Promotes the very first account to `superadmin` and grants it ownership of every
118
+ * existing space, so a fresh install is reachable.
119
+ *
120
+ * Called from better-auth's user-create hook rather than at startup, so it fires for an
121
+ * account created after the server is already running.
122
+ */
123
+ export async function promoteFirstUser(
124
+ manablox: Manablox,
125
+ repos: Repositories,
126
+ userId: string,
127
+ ): Promise<void> {
128
+ const count = await repos.users.count();
129
+ if (count !== 1) return;
130
+
131
+ const user = await repos.users.findById(userId);
132
+ if (!user || user.role === 'superadmin') return;
133
+
134
+ await repos.users.setRole(userId, 'superadmin');
135
+ for (const space of await repos.spaces.all()) {
136
+ await repos.users.grant(userId, space.id, 'owner');
137
+ }
138
+
139
+ manablox.logger.info({ email: user.email }, 'first account promoted to superadmin');
140
+ }
141
+
142
+ /** Covers an instance whose first account predates this behaviour. */
143
+ export function attachBootstrapOwner(manablox: Manablox, repos: Repositories): void {
144
+ manablox.hooks.on(
145
+ 'after:start',
146
+ async () => {
147
+ const { items } = await repos.users.list({ limit: 1, offset: 0 });
148
+ const first = items[0];
149
+ if (first && (await repos.users.count()) === 1) {
150
+ await promoteFirstUser(manablox, repos, first.id);
151
+ }
152
+ },
153
+ { source: '@manablox/auth' },
154
+ );
155
+ }
package/src/rbac.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { ManabloxError } from '@manablox/core';
2
+
3
+ export type SpaceRole = 'owner' | 'admin' | 'editor' | 'author' | 'viewer';
4
+
5
+ export type Permission =
6
+ | 'space:read'
7
+ | 'space:write'
8
+ | 'space:delete'
9
+ | 'contentType:read'
10
+ | 'contentType:write'
11
+ | 'contentType:delete'
12
+ | 'content:read'
13
+ | 'content:write'
14
+ | 'content:delete'
15
+ | 'content:publish'
16
+ | 'asset:read'
17
+ | 'asset:write'
18
+ | 'asset:delete'
19
+ | 'user:read'
20
+ | 'user:write'
21
+ | 'webhook:read'
22
+ | 'webhook:write';
23
+
24
+ /** Role → permission mapping. */
25
+ const ROLE_PERMISSIONS: Record<SpaceRole, Permission[]> = {
26
+ owner: [
27
+ 'space:read',
28
+ 'space:write',
29
+ 'space:delete',
30
+ 'contentType:read',
31
+ 'contentType:write',
32
+ 'contentType:delete',
33
+ 'content:read',
34
+ 'content:write',
35
+ 'content:delete',
36
+ 'content:publish',
37
+ 'asset:read',
38
+ 'asset:write',
39
+ 'asset:delete',
40
+ 'user:read',
41
+ 'user:write',
42
+ 'webhook:read',
43
+ 'webhook:write',
44
+ ],
45
+ admin: [
46
+ 'space:read',
47
+ 'space:write',
48
+ 'contentType:read',
49
+ 'contentType:write',
50
+ 'contentType:delete',
51
+ 'content:read',
52
+ 'content:write',
53
+ 'content:delete',
54
+ 'content:publish',
55
+ 'asset:read',
56
+ 'asset:write',
57
+ 'asset:delete',
58
+ 'user:read',
59
+ 'user:write',
60
+ 'webhook:read',
61
+ 'webhook:write',
62
+ ],
63
+ editor: [
64
+ 'space:read',
65
+ 'contentType:read',
66
+ 'content:read',
67
+ 'content:write',
68
+ 'content:delete',
69
+ 'content:publish',
70
+ 'asset:read',
71
+ 'asset:write',
72
+ 'asset:delete',
73
+ 'user:read',
74
+ 'webhook:read',
75
+ ],
76
+ // An author may write and delete, but never publish — the reason the role exists.
77
+ author: [
78
+ 'space:read',
79
+ 'contentType:read',
80
+ 'content:read',
81
+ 'content:write',
82
+ 'content:delete',
83
+ 'asset:read',
84
+ 'asset:write',
85
+ 'user:read',
86
+ ],
87
+ viewer: ['space:read', 'contentType:read', 'content:read', 'asset:read', 'user:read'],
88
+ };
89
+
90
+ export interface Principal {
91
+ userId: string;
92
+ email: string;
93
+ /** Instance-wide role; `superadmin` short-circuits every space check. */
94
+ role: string;
95
+ /** Space id → role in that space. */
96
+ spaces: Record<string, SpaceRole>;
97
+ /** True when the request authenticated with an API key rather than a session. */
98
+ viaApiKey?: boolean;
99
+ /**
100
+ * Spaces this principal is confined to, or `null`/absent for no confinement. Set by an
101
+ * API key that was issued with a space restriction: it narrows the key below its
102
+ * owner's own access and, unlike a role, it also binds a superadmin.
103
+ */
104
+ allowedSpaceIds?: string[] | null;
105
+ }
106
+
107
+ export function permissionsFor(role: SpaceRole): readonly Permission[] {
108
+ return ROLE_PERMISSIONS[role] ?? [];
109
+ }
110
+
111
+ export function can(
112
+ principal: Principal | null,
113
+ spaceId: string | null,
114
+ permission: Permission,
115
+ ): boolean {
116
+ if (!principal) return false;
117
+ // Checked ahead of the superadmin short-circuit: a restricted key must not reach
118
+ // outside its spaces, and an instance-wide operation has no space to be inside.
119
+ if (principal.allowedSpaceIds && (!spaceId || !principal.allowedSpaceIds.includes(spaceId))) {
120
+ return false;
121
+ }
122
+ if (principal.role === 'superadmin') return true;
123
+ if (!spaceId) return false;
124
+
125
+ const role = principal.spaces[spaceId];
126
+ if (!role) return false;
127
+ return ROLE_PERMISSIONS[role].includes(permission);
128
+ }
129
+
130
+ export function assertCan(
131
+ principal: Principal | null,
132
+ spaceId: string | null,
133
+ permission: Permission,
134
+ ): void {
135
+ if (can(principal, spaceId, permission)) return;
136
+ if (!principal) throw ManabloxError.unauthorized();
137
+ throw ManabloxError.forbidden('auth.forbidden', { permission, spaceId });
138
+ }
139
+
140
+ /** Roles used by field-level `readRoles`/`writeRoles` checks. */
141
+ export function actorRoles(principal: Principal | null, spaceId: string | null): string[] {
142
+ if (!principal) return [];
143
+ const roles = [principal.role];
144
+ if (spaceId && principal.spaces[spaceId]) roles.push(principal.spaces[spaceId]);
145
+ return roles;
146
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { actorRoles, assertCan, can, type Principal, permissionsFor } from '../src/rbac.js';
3
+
4
+ const SPACE = 'space-1';
5
+
6
+ const principal = (over: Partial<Principal> = {}): Principal => ({
7
+ userId: 'u1',
8
+ email: 'u@example.com',
9
+ role: 'editor',
10
+ spaces: { [SPACE]: 'editor' },
11
+ ...over,
12
+ });
13
+
14
+ describe('rbac', () => {
15
+ it('denies everything to an anonymous caller', () => {
16
+ expect(can(null, SPACE, 'content:read')).toBe(false);
17
+ expect(() => assertCan(null, SPACE, 'content:read')).toThrow(/unauthorized/);
18
+ });
19
+
20
+ it('lets an author write but not publish — the reason the role exists', () => {
21
+ const author = principal({ spaces: { [SPACE]: 'author' } });
22
+ expect(can(author, SPACE, 'content:write')).toBe(true);
23
+ expect(can(author, SPACE, 'content:publish')).toBe(false);
24
+ expect(() => assertCan(author, SPACE, 'content:publish')).toThrow(/forbidden/);
25
+ });
26
+
27
+ it('confines a viewer to reads', () => {
28
+ const viewer = principal({ spaces: { [SPACE]: 'viewer' } });
29
+ expect(can(viewer, SPACE, 'content:read')).toBe(true);
30
+ expect(can(viewer, SPACE, 'content:write')).toBe(false);
31
+ expect(can(viewer, SPACE, 'asset:write')).toBe(false);
32
+ });
33
+
34
+ it('grants nothing in a space the user is not a member of', () => {
35
+ expect(can(principal(), 'other-space', 'content:read')).toBe(false);
36
+ });
37
+
38
+ it('short-circuits every check for a superadmin', () => {
39
+ const root = principal({ role: 'superadmin', spaces: {} });
40
+ expect(can(root, 'any-space', 'space:delete')).toBe(true);
41
+ });
42
+
43
+ it('reserves space deletion for the owner', () => {
44
+ expect(can(principal({ spaces: { [SPACE]: 'admin' } }), SPACE, 'space:delete')).toBe(false);
45
+ expect(can(principal({ spaces: { [SPACE]: 'owner' } }), SPACE, 'space:delete')).toBe(true);
46
+ });
47
+
48
+ it('exposes the roles a field-level permission check needs', () => {
49
+ expect(actorRoles(principal({ role: 'editor' }), SPACE)).toEqual(['editor', 'editor']);
50
+ expect(actorRoles(null, SPACE)).toEqual([]);
51
+ });
52
+
53
+ it('never grants a write permission through a read-only role', () => {
54
+ for (const permission of permissionsFor('viewer')) {
55
+ expect(permission.endsWith(':read')).toBe(true);
56
+ }
57
+ });
58
+ });
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "@manablox/config-typescript/library.json", "include": ["src", "test"] }
@@ -0,0 +1,2 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ export default defineConfig({ test: { environment: 'node', include: ['test/**/*.test.ts'] } });