@pramen/auth 0.0.2
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/dist/index.d.ts +48 -0
- package/dist/index.js +116 -0
- package/package.json +36 -0
- package/src/index.ts +154 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare const authSchema: {
|
|
2
|
+
auth_users: import("@pramen/server").EntityDef<{
|
|
3
|
+
username: {
|
|
4
|
+
readonly type: "text";
|
|
5
|
+
readonly primaryKey: true;
|
|
6
|
+
readonly notNull: true;
|
|
7
|
+
};
|
|
8
|
+
passwordHash: {
|
|
9
|
+
readonly type: "text";
|
|
10
|
+
};
|
|
11
|
+
roles: {
|
|
12
|
+
readonly type: "json";
|
|
13
|
+
};
|
|
14
|
+
createdAt: {
|
|
15
|
+
readonly type: "integer";
|
|
16
|
+
};
|
|
17
|
+
}, Record<string, never>>;
|
|
18
|
+
};
|
|
19
|
+
export declare function hashPassword(password: string): Promise<string>;
|
|
20
|
+
export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
|
|
21
|
+
export declare function signToken(claims: Record<string, unknown>, secret: string, opts?: {
|
|
22
|
+
ttlSeconds?: number;
|
|
23
|
+
}): Promise<string>;
|
|
24
|
+
/** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
|
|
25
|
+
* client never picks its own roles. Spread into your handler map. */
|
|
26
|
+
export declare const authHandlers: {
|
|
27
|
+
signup: import("@pramen/server").Handler<{
|
|
28
|
+
username: string;
|
|
29
|
+
password: string;
|
|
30
|
+
}, {
|
|
31
|
+
token: string;
|
|
32
|
+
user: {
|
|
33
|
+
username: string;
|
|
34
|
+
roles: string[];
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
login: import("@pramen/server").Handler<{
|
|
38
|
+
username: string;
|
|
39
|
+
password: string;
|
|
40
|
+
}, {
|
|
41
|
+
token: string;
|
|
42
|
+
user: {
|
|
43
|
+
username: string;
|
|
44
|
+
roles: string[];
|
|
45
|
+
};
|
|
46
|
+
}>;
|
|
47
|
+
me: import("@pramen/server").Handler<unknown, import("@pramen/server").Identity | null>;
|
|
48
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @pramen/auth — optional credential→JWT login for pramen, so an app can issue
|
|
2
|
+
// tokens without a third-party IdP. The core stays verify-only (HS256 against
|
|
3
|
+
// AUTH_SECRET, or RS256/JWKS); this package signs HS256 tokens the verifier accepts.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// import { authSchema, authHandlers } from "@pramen/auth";
|
|
7
|
+
// const schema = defineSchema({ ...authSchema, notes: Entity(...) });
|
|
8
|
+
// const { query, mutation } = createApp(schema);
|
|
9
|
+
// const handlers = { ...authHandlers, ...yourHandlers };
|
|
10
|
+
//
|
|
11
|
+
// signup/login store users in the `auth_users` table and return a bearer token
|
|
12
|
+
// (sub = username, roles). Passwords are PBKDF2-hashed (WebCrypto, no deps).
|
|
13
|
+
// Requires AUTH_SECRET in the environment (ctx.env). JWKS setups don't use this.
|
|
14
|
+
import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
|
|
15
|
+
// --- schema fragment: spread into your defineSchema so the table is migrated ---
|
|
16
|
+
export const authSchema = {
|
|
17
|
+
auth_users: Entity((t) => ({
|
|
18
|
+
username: t.textId(),
|
|
19
|
+
passwordHash: t.text(),
|
|
20
|
+
roles: t.json(), // string[]
|
|
21
|
+
createdAt: t.int(),
|
|
22
|
+
})),
|
|
23
|
+
};
|
|
24
|
+
// --- base64 / base64url ---
|
|
25
|
+
const enc = (s) => new TextEncoder().encode(s);
|
|
26
|
+
function b64(bytes) {
|
|
27
|
+
let bin = "";
|
|
28
|
+
for (const b of bytes)
|
|
29
|
+
bin += String.fromCharCode(b);
|
|
30
|
+
return btoa(bin);
|
|
31
|
+
}
|
|
32
|
+
function unb64(s) {
|
|
33
|
+
const bin = atob(s);
|
|
34
|
+
const out = new Uint8Array(bin.length);
|
|
35
|
+
for (let i = 0; i < bin.length; i++)
|
|
36
|
+
out[i] = bin.charCodeAt(i);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
const b64url = (bytes) => b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
40
|
+
const b64urlStr = (s) => b64url(enc(s));
|
|
41
|
+
// --- password hashing (PBKDF2-SHA256) ---
|
|
42
|
+
const PBKDF2_ITERATIONS = 100_000;
|
|
43
|
+
export async function hashPassword(password) {
|
|
44
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
45
|
+
const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
|
|
46
|
+
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, key, 256);
|
|
47
|
+
return `pbkdf2$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
|
|
48
|
+
}
|
|
49
|
+
/** Constant-time string compare (avoids leaking the hash via timing). */
|
|
50
|
+
function constantTimeEqual(a, b) {
|
|
51
|
+
if (a.length !== b.length)
|
|
52
|
+
return false;
|
|
53
|
+
let diff = 0;
|
|
54
|
+
for (let i = 0; i < a.length; i++)
|
|
55
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
56
|
+
return diff === 0;
|
|
57
|
+
}
|
|
58
|
+
export async function verifyPassword(password, stored) {
|
|
59
|
+
const [scheme, iterStr, saltB64, hashB64] = stored.split("$");
|
|
60
|
+
if (scheme !== "pbkdf2" || !saltB64 || !hashB64)
|
|
61
|
+
return false;
|
|
62
|
+
const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
|
|
63
|
+
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: unb64(saltB64), iterations: Number(iterStr), hash: "SHA-256" }, key, 256);
|
|
64
|
+
return constantTimeEqual(b64(new Uint8Array(bits)), hashB64);
|
|
65
|
+
}
|
|
66
|
+
// --- HS256 token signing (matches the verifier in @pramen/server auth.ts) ---
|
|
67
|
+
export async function signToken(claims, secret, opts = {}) {
|
|
68
|
+
const now = Math.floor(Date.now() / 1000);
|
|
69
|
+
const header = b64urlStr(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
70
|
+
const body = b64urlStr(JSON.stringify({ iat: now, exp: now + (opts.ttlSeconds ?? 3600), ...claims }));
|
|
71
|
+
const data = `${header}.${body}`;
|
|
72
|
+
const key = await crypto.subtle.importKey("raw", enc(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
73
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc(data));
|
|
74
|
+
return `${data}.${b64url(new Uint8Array(sig))}`;
|
|
75
|
+
}
|
|
76
|
+
// --- handlers ---
|
|
77
|
+
function secretOf(ctx) {
|
|
78
|
+
const s = ctx.env.AUTH_SECRET;
|
|
79
|
+
if (typeof s !== "string" || s.length === 0)
|
|
80
|
+
throw new Error("@pramen/auth: AUTH_SECRET is not configured");
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
const DEFAULT_ROLES = ["user"];
|
|
84
|
+
const TOKEN_TTL_SECONDS = 3600;
|
|
85
|
+
function parseCreds(raw) {
|
|
86
|
+
const o = (raw ?? {});
|
|
87
|
+
if (typeof o.username !== "string" || o.username.length === 0)
|
|
88
|
+
throw new Error("username is required");
|
|
89
|
+
if (typeof o.password !== "string" || o.password.length < 8)
|
|
90
|
+
throw new Error("password must be at least 8 characters");
|
|
91
|
+
return { username: o.username, password: o.password };
|
|
92
|
+
}
|
|
93
|
+
/** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
|
|
94
|
+
* client never picks its own roles. Spread into your handler map. */
|
|
95
|
+
export const authHandlers = {
|
|
96
|
+
signup: mutation(async (ctx, input) => {
|
|
97
|
+
const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
98
|
+
if (existing.length > 0)
|
|
99
|
+
throw new BadRequest("username is taken");
|
|
100
|
+
const roles = DEFAULT_ROLES;
|
|
101
|
+
await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", input.username, await hashPassword(input.password), JSON.stringify(roles), Date.now());
|
|
102
|
+
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: TOKEN_TTL_SECONDS });
|
|
103
|
+
return { token, user: { username: input.username, roles } };
|
|
104
|
+
}, { input: parseCreds }),
|
|
105
|
+
login: mutation(async (ctx, input) => {
|
|
106
|
+
const rows = await ctx.db.exec("SELECT username, passwordHash, roles FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
107
|
+
const u = rows[0];
|
|
108
|
+
if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
109
|
+
throw new Unauthorized("invalid username or password");
|
|
110
|
+
}
|
|
111
|
+
const roles = JSON.parse(String(u.roles));
|
|
112
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: TOKEN_TTL_SECONDS });
|
|
113
|
+
return { token, user: { username: String(u.username), roles } };
|
|
114
|
+
}, { input: parseCreds }),
|
|
115
|
+
me: query((ctx) => ctx.identity),
|
|
116
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pramen/auth",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/netvarec/pramen.git",
|
|
9
|
+
"directory": "packages/auth"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/netvarec/pramen#readme",
|
|
12
|
+
"bugs": "https://github.com/netvarec/pramen/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"development": "./src/index.ts",
|
|
18
|
+
"bun": "./src/index.ts",
|
|
19
|
+
"workerd": "./src/index.ts",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"files": ["dist", "src"],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@pramen/server": "workspace:*"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @pramen/auth — optional credential→JWT login for pramen, so an app can issue
|
|
2
|
+
// tokens without a third-party IdP. The core stays verify-only (HS256 against
|
|
3
|
+
// AUTH_SECRET, or RS256/JWKS); this package signs HS256 tokens the verifier accepts.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// import { authSchema, authHandlers } from "@pramen/auth";
|
|
7
|
+
// const schema = defineSchema({ ...authSchema, notes: Entity(...) });
|
|
8
|
+
// const { query, mutation } = createApp(schema);
|
|
9
|
+
// const handlers = { ...authHandlers, ...yourHandlers };
|
|
10
|
+
//
|
|
11
|
+
// signup/login store users in the `auth_users` table and return a bearer token
|
|
12
|
+
// (sub = username, roles). Passwords are PBKDF2-hashed (WebCrypto, no deps).
|
|
13
|
+
// Requires AUTH_SECRET in the environment (ctx.env). JWKS setups don't use this.
|
|
14
|
+
|
|
15
|
+
import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
|
|
16
|
+
import type { HandlerContext } from "@pramen/server";
|
|
17
|
+
|
|
18
|
+
// --- schema fragment: spread into your defineSchema so the table is migrated ---
|
|
19
|
+
|
|
20
|
+
export const authSchema = {
|
|
21
|
+
auth_users: Entity((t) => ({
|
|
22
|
+
username: t.textId(),
|
|
23
|
+
passwordHash: t.text(),
|
|
24
|
+
roles: t.json(), // string[]
|
|
25
|
+
createdAt: t.int(),
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// --- base64 / base64url ---
|
|
30
|
+
|
|
31
|
+
const enc = (s: string) => new TextEncoder().encode(s);
|
|
32
|
+
function b64(bytes: Uint8Array): string {
|
|
33
|
+
let bin = "";
|
|
34
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
35
|
+
return btoa(bin);
|
|
36
|
+
}
|
|
37
|
+
function unb64(s: string): Uint8Array {
|
|
38
|
+
const bin = atob(s);
|
|
39
|
+
const out = new Uint8Array(bin.length);
|
|
40
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
const b64url = (bytes: Uint8Array) => b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
44
|
+
const b64urlStr = (s: string) => b64url(enc(s));
|
|
45
|
+
|
|
46
|
+
// --- password hashing (PBKDF2-SHA256) ---
|
|
47
|
+
|
|
48
|
+
const PBKDF2_ITERATIONS = 100_000;
|
|
49
|
+
|
|
50
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
51
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
52
|
+
const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
|
|
53
|
+
const bits = await crypto.subtle.deriveBits(
|
|
54
|
+
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
|
|
55
|
+
key,
|
|
56
|
+
256,
|
|
57
|
+
);
|
|
58
|
+
return `pbkdf2$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Constant-time string compare (avoids leaking the hash via timing). */
|
|
62
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
63
|
+
if (a.length !== b.length) return false;
|
|
64
|
+
let diff = 0;
|
|
65
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
66
|
+
return diff === 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
70
|
+
const [scheme, iterStr, saltB64, hashB64] = stored.split("$");
|
|
71
|
+
if (scheme !== "pbkdf2" || !saltB64 || !hashB64) return false;
|
|
72
|
+
const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
|
|
73
|
+
const bits = await crypto.subtle.deriveBits(
|
|
74
|
+
{ name: "PBKDF2", salt: unb64(saltB64), iterations: Number(iterStr), hash: "SHA-256" },
|
|
75
|
+
key,
|
|
76
|
+
256,
|
|
77
|
+
);
|
|
78
|
+
return constantTimeEqual(b64(new Uint8Array(bits)), hashB64);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- HS256 token signing (matches the verifier in @pramen/server auth.ts) ---
|
|
82
|
+
|
|
83
|
+
export async function signToken(
|
|
84
|
+
claims: Record<string, unknown>,
|
|
85
|
+
secret: string,
|
|
86
|
+
opts: { ttlSeconds?: number } = {},
|
|
87
|
+
): Promise<string> {
|
|
88
|
+
const now = Math.floor(Date.now() / 1000);
|
|
89
|
+
const header = b64urlStr(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
90
|
+
const body = b64urlStr(JSON.stringify({ iat: now, exp: now + (opts.ttlSeconds ?? 3600), ...claims }));
|
|
91
|
+
const data = `${header}.${body}`;
|
|
92
|
+
const key = await crypto.subtle.importKey("raw", enc(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
93
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc(data));
|
|
94
|
+
return `${data}.${b64url(new Uint8Array(sig))}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- handlers ---
|
|
98
|
+
|
|
99
|
+
function secretOf(ctx: HandlerContext): string {
|
|
100
|
+
const s = ctx.env.AUTH_SECRET;
|
|
101
|
+
if (typeof s !== "string" || s.length === 0) throw new Error("@pramen/auth: AUTH_SECRET is not configured");
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const DEFAULT_ROLES = ["user"];
|
|
106
|
+
const TOKEN_TTL_SECONDS = 3600;
|
|
107
|
+
|
|
108
|
+
function parseCreds(raw: unknown): { username: string; password: string } {
|
|
109
|
+
const o = (raw ?? {}) as Record<string, unknown>;
|
|
110
|
+
if (typeof o.username !== "string" || o.username.length === 0) throw new Error("username is required");
|
|
111
|
+
if (typeof o.password !== "string" || o.password.length < 8) throw new Error("password must be at least 8 characters");
|
|
112
|
+
return { username: o.username, password: o.password };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
|
|
116
|
+
* client never picks its own roles. Spread into your handler map. */
|
|
117
|
+
export const authHandlers = {
|
|
118
|
+
signup: mutation(
|
|
119
|
+
async (ctx, input: { username: string; password: string }) => {
|
|
120
|
+
const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
121
|
+
if (existing.length > 0) throw new BadRequest("username is taken");
|
|
122
|
+
const roles = DEFAULT_ROLES;
|
|
123
|
+
await ctx.db.exec(
|
|
124
|
+
"INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
|
|
125
|
+
input.username,
|
|
126
|
+
await hashPassword(input.password),
|
|
127
|
+
JSON.stringify(roles),
|
|
128
|
+
Date.now(),
|
|
129
|
+
);
|
|
130
|
+
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: TOKEN_TTL_SECONDS });
|
|
131
|
+
return { token, user: { username: input.username, roles } };
|
|
132
|
+
},
|
|
133
|
+
{ input: parseCreds },
|
|
134
|
+
),
|
|
135
|
+
|
|
136
|
+
login: mutation(
|
|
137
|
+
async (ctx, input: { username: string; password: string }) => {
|
|
138
|
+
const rows = await ctx.db.exec(
|
|
139
|
+
"SELECT username, passwordHash, roles FROM auth_users WHERE username = ? LIMIT 1",
|
|
140
|
+
input.username,
|
|
141
|
+
);
|
|
142
|
+
const u = rows[0];
|
|
143
|
+
if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
144
|
+
throw new Unauthorized("invalid username or password");
|
|
145
|
+
}
|
|
146
|
+
const roles = JSON.parse(String(u.roles)) as string[];
|
|
147
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: TOKEN_TTL_SECONDS });
|
|
148
|
+
return { token, user: { username: String(u.username), roles } };
|
|
149
|
+
},
|
|
150
|
+
{ input: parseCreds },
|
|
151
|
+
),
|
|
152
|
+
|
|
153
|
+
me: query((ctx) => ctx.identity),
|
|
154
|
+
};
|