@storyshelf/auth-password 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/README.md +59 -0
- package/dist/index.d.mts +26 -0
- package/dist/index.mjs +104 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @storyshelf/auth-password
|
|
2
|
+
|
|
3
|
+
A shared-password auth adapter for StoryShelf: a single server-wide password gates access, and sessions are HMAC-signed cookies with a 7-day TTL.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
nub add @storyshelf/auth-password
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
or
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @storyshelf/auth-password
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createPasswordAuth } from "@storyshelf/auth-password";
|
|
21
|
+
import { createShelfRouter } from "@storyshelf/core";
|
|
22
|
+
|
|
23
|
+
const auth = createPasswordAuth({
|
|
24
|
+
password: process.env.SHELF_PASSWORD!,
|
|
25
|
+
secret: process.env.SHELF_SECRET!,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const app = createShelfRouter({ database, storage, auth });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API
|
|
32
|
+
|
|
33
|
+
### `PasswordAuthOptions`
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
interface PasswordAuthOptions {
|
|
37
|
+
password: string; // the shared password users must enter to log in
|
|
38
|
+
secret: string; // secret used to HMAC-sign session cookies
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `createPasswordAuth(options: PasswordAuthOptions): PasswordAuth`
|
|
43
|
+
|
|
44
|
+
Returns a `PasswordAuth`, which extends `AuthAdapter` with an extra method:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
interface PasswordAuth extends AuthAdapter {
|
|
48
|
+
login(password: string, user: AuthUser): Promise<string>;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `login(password, user)` — throws if `password` does not match; otherwise creates an HMAC-signed session cookie string for the given `AuthUser`.
|
|
53
|
+
- `check(request)`, `createSession(user)`, `destroySession(sessionId)` — the standard `AuthAdapter` interface. Sessions last 7 days and are verified with timing-safe comparison.
|
|
54
|
+
|
|
55
|
+
## How it fits in
|
|
56
|
+
|
|
57
|
+
`auth-password` is the `auth` option for `createShelfRouter` when you want simple single-password protection for a self-hosted instance. When supplied, the router gates the server-rendered UI behind a login page and signs sessions with the shared secret.
|
|
58
|
+
|
|
59
|
+
See `docs/architecture.md` and ADR 0008.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import __tsdown_shims_path from 'node:path';
|
|
2
|
+
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { AuthAdapter, AuthUser } from "@storyshelf/core/adapter/auth";
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
/** Options for configuring a shared-password auth adapter. */
|
|
6
|
+
interface PasswordAuthOptions {
|
|
7
|
+
/** The shared password users must present to log in. */
|
|
8
|
+
password: string;
|
|
9
|
+
/** Secret used to sign and verify session cookies. */
|
|
10
|
+
secret: string;
|
|
11
|
+
}
|
|
12
|
+
/** Auth adapter that authenticates with a single shared password. */
|
|
13
|
+
interface PasswordAuth extends AuthAdapter {
|
|
14
|
+
/** Verify `password` and, if correct, create a session for `user`, returning a session token. */
|
|
15
|
+
login(password: string, user: AuthUser): Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a shared-password auth adapter.
|
|
19
|
+
*
|
|
20
|
+
* @param options - Password and session signing configuration.
|
|
21
|
+
* @returns A PasswordAuth instance.
|
|
22
|
+
*/
|
|
23
|
+
declare function createPasswordAuth(options: PasswordAuthOptions): PasswordAuth;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { PasswordAuth, PasswordAuthOptions, createPasswordAuth };
|
|
26
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { SESSION_COOKIE } from "@storyshelf/core/adapter/auth";
|
|
5
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
6
|
+
//#region src/index.ts
|
|
7
|
+
const SESSION_TTL_MS = 6048e5;
|
|
8
|
+
function hmacHex(secret, value) {
|
|
9
|
+
return createHmac("sha256", secret).update(value).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
function equalStrings(left, right) {
|
|
12
|
+
const leftBuffer = Buffer.from(left);
|
|
13
|
+
const rightBuffer = Buffer.from(right);
|
|
14
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
15
|
+
}
|
|
16
|
+
function encodePayload(payload) {
|
|
17
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
function decodePayload(body) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function signPayload(secret, payload) {
|
|
27
|
+
const body = encodePayload(payload);
|
|
28
|
+
return `${body}.${hmacHex(secret, body)}`;
|
|
29
|
+
}
|
|
30
|
+
function verifyPayload(secret, token) {
|
|
31
|
+
const dot = token.lastIndexOf(".");
|
|
32
|
+
if (dot === -1) return null;
|
|
33
|
+
const body = token.slice(0, dot);
|
|
34
|
+
const signature = token.slice(dot + 1);
|
|
35
|
+
if (!equalStrings(hmacHex(secret, body), signature)) return null;
|
|
36
|
+
return decodePayload(body);
|
|
37
|
+
}
|
|
38
|
+
function readCookie(request, name) {
|
|
39
|
+
const header = request.headers.get("cookie");
|
|
40
|
+
if (!header) return;
|
|
41
|
+
for (const part of header.split(";")) {
|
|
42
|
+
const eq = part.indexOf("=");
|
|
43
|
+
if (eq === -1) continue;
|
|
44
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function toUser(payload) {
|
|
48
|
+
return {
|
|
49
|
+
id: payload.userId,
|
|
50
|
+
email: payload.email,
|
|
51
|
+
name: payload.name,
|
|
52
|
+
avatarUrl: payload.avatarUrl,
|
|
53
|
+
role: payload.role
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Create a shared-password auth adapter.
|
|
58
|
+
*
|
|
59
|
+
* @param options - Password and session signing configuration.
|
|
60
|
+
* @returns A PasswordAuth instance.
|
|
61
|
+
*/
|
|
62
|
+
function createPasswordAuth(options) {
|
|
63
|
+
const { password, secret } = options;
|
|
64
|
+
const check = async (request) => {
|
|
65
|
+
const token = readCookie(request, SESSION_COOKIE);
|
|
66
|
+
if (!token) return null;
|
|
67
|
+
const payload = verifyPayload(secret, token);
|
|
68
|
+
if (!payload || payload.expiresAt <= Date.now()) return null;
|
|
69
|
+
return toUser(payload);
|
|
70
|
+
};
|
|
71
|
+
const createSession = async (user) => {
|
|
72
|
+
const payload = {
|
|
73
|
+
userId: user.id,
|
|
74
|
+
email: user.email,
|
|
75
|
+
name: user.name,
|
|
76
|
+
avatarUrl: user.avatarUrl,
|
|
77
|
+
role: user.role,
|
|
78
|
+
expiresAt: Date.now() + SESSION_TTL_MS
|
|
79
|
+
};
|
|
80
|
+
return signPayload(secret, payload);
|
|
81
|
+
};
|
|
82
|
+
const login = async (input, user) => {
|
|
83
|
+
if (!equalStrings(input, password)) throw new Error("Invalid password");
|
|
84
|
+
return await createSession(user);
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
metadata: {
|
|
88
|
+
name: "Password Auth",
|
|
89
|
+
version: "0.1.0",
|
|
90
|
+
description: "Shared-password auth adapter",
|
|
91
|
+
kind: "password"
|
|
92
|
+
},
|
|
93
|
+
check,
|
|
94
|
+
createSession,
|
|
95
|
+
async destroySession() {
|
|
96
|
+
await Promise.resolve();
|
|
97
|
+
},
|
|
98
|
+
login
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { createPasswordAuth };
|
|
103
|
+
|
|
104
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { SESSION_COOKIE, type AuthAdapter, type AuthUser } from \"@storyshelf/core/adapter/auth\";\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\ndeclare const __PKG_VERSION__: string;\n\nconst SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\ninterface SessionPayload {\n userId: string;\n email: string;\n name: string;\n avatarUrl?: string;\n role: AuthUser[\"role\"];\n expiresAt: number;\n}\n\n/** Options for configuring a shared-password auth adapter. */\nexport interface PasswordAuthOptions {\n /** The shared password users must present to log in. */\n password: string;\n /** Secret used to sign and verify session cookies. */\n secret: string;\n}\n\n/** Auth adapter that authenticates with a single shared password. */\nexport interface PasswordAuth extends AuthAdapter {\n /** Verify `password` and, if correct, create a session for `user`, returning a session token. */\n login(password: string, user: AuthUser): Promise<string>;\n}\n\nfunction hmacHex(secret: string, value: string): string {\n return createHmac(\"sha256\", secret).update(value).digest(\"hex\");\n}\n\nfunction equalStrings(left: string, right: string): boolean {\n const leftBuffer = Buffer.from(left);\n const rightBuffer = Buffer.from(right);\n return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);\n}\n\nfunction encodePayload(payload: SessionPayload): string {\n return Buffer.from(JSON.stringify(payload)).toString(\"base64url\");\n}\n\nfunction decodePayload(body: string): SessionPayload | null {\n try {\n return JSON.parse(Buffer.from(body, \"base64url\").toString(\"utf8\")) as SessionPayload;\n } catch {\n return null;\n }\n}\n\nfunction signPayload(secret: string, payload: SessionPayload): string {\n const body = encodePayload(payload);\n return `${body}.${hmacHex(secret, body)}`;\n}\n\nfunction verifyPayload(secret: string, token: string): SessionPayload | null {\n const dot = token.lastIndexOf(\".\");\n if (dot === -1) {\n return null;\n }\n const body = token.slice(0, dot);\n const signature = token.slice(dot + 1);\n if (!equalStrings(hmacHex(secret, body), signature)) {\n return null;\n }\n return decodePayload(body);\n}\n\nfunction readCookie(request: Request, name: string): string | undefined {\n const header = request.headers.get(\"cookie\");\n if (!header) {\n return undefined;\n }\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) {\n continue;\n }\n if (part.slice(0, eq).trim() === name) {\n return part.slice(eq + 1).trim();\n }\n }\n return undefined;\n}\n\nfunction toUser(payload: SessionPayload): AuthUser {\n return {\n id: payload.userId,\n email: payload.email,\n name: payload.name,\n avatarUrl: payload.avatarUrl,\n role: payload.role,\n };\n}\n\n/**\n * Create a shared-password auth adapter.\n *\n * @param options - Password and session signing configuration.\n * @returns A PasswordAuth instance.\n */\nexport function createPasswordAuth(options: PasswordAuthOptions): PasswordAuth {\n const { password, secret } = options;\n\n // Async is required by the AuthAdapter interface, though the logic is synchronous.\n // eslint-disable-next-line require-await\n const check = async (request: Request): Promise<AuthUser | null> => {\n const token = readCookie(request, SESSION_COOKIE);\n if (!token) {\n return null;\n }\n const payload = verifyPayload(secret, token);\n if (!payload || payload.expiresAt <= Date.now()) {\n return null;\n }\n return toUser(payload);\n };\n\n // eslint-disable-next-line require-await\n const createSession = async (user: AuthUser): Promise<string> => {\n const payload: SessionPayload = {\n userId: user.id,\n email: user.email,\n name: user.name,\n avatarUrl: user.avatarUrl,\n role: user.role,\n expiresAt: Date.now() + SESSION_TTL_MS,\n };\n return signPayload(secret, payload);\n };\n\n const login = async (input: string, user: AuthUser): Promise<string> => {\n if (!equalStrings(input, password)) {\n throw new Error(\"Invalid password\");\n }\n return await createSession(user);\n };\n\n return {\n metadata: {\n name: \"Password Auth\",\n version: typeof __PKG_VERSION__ === \"undefined\" ? \"0.0.0\" : __PKG_VERSION__, // oxlint-disable-line unicorn/no-typeof-undefined\n description: \"Shared-password auth adapter\",\n kind: \"password\",\n },\n check,\n createSession,\n async destroySession() {\n await Promise.resolve();\n },\n login,\n };\n}\n"],"mappings":";;;;;;AAKA,MAAM,iBAAiB;AAyBvB,SAAS,QAAQ,QAAgB,OAAuB;CACtD,OAAO,WAAW,UAAU,MAAM,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;AAChE;AAEA,SAAS,aAAa,MAAc,OAAwB;CAC1D,MAAM,aAAa,OAAO,KAAK,IAAI;CACnC,MAAM,cAAc,OAAO,KAAK,KAAK;CACrC,OAAO,WAAW,WAAW,YAAY,UAAU,gBAAgB,YAAY,WAAW;AAC5F;AAEA,SAAS,cAAc,SAAiC;CACtD,OAAO,OAAO,KAAK,KAAK,UAAU,OAAO,CAAC,CAAC,CAAC,SAAS,WAAW;AAClE;AAEA,SAAS,cAAc,MAAqC;CAC1D,IAAI;EACF,OAAO,KAAK,MAAM,OAAO,KAAK,MAAM,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC;CACnE,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,YAAY,QAAgB,SAAiC;CACpE,MAAM,OAAO,cAAc,OAAO;CAClC,OAAO,GAAG,KAAK,GAAG,QAAQ,QAAQ,IAAI;AACxC;AAEA,SAAS,cAAc,QAAgB,OAAsC;CAC3E,MAAM,MAAM,MAAM,YAAY,GAAG;CACjC,IAAI,QAAQ,IACV,OAAO;CAET,MAAM,OAAO,MAAM,MAAM,GAAG,GAAG;CAC/B,MAAM,YAAY,MAAM,MAAM,MAAM,CAAC;CACrC,IAAI,CAAC,aAAa,QAAQ,QAAQ,IAAI,GAAG,SAAS,GAChD,OAAO;CAET,OAAO,cAAc,IAAI;AAC3B;AAEA,SAAS,WAAW,SAAkB,MAAkC;CACtE,MAAM,SAAS,QAAQ,QAAQ,IAAI,QAAQ;CAC3C,IAAI,CAAC,QACH;CAEF,KAAK,MAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;EACpC,MAAM,KAAK,KAAK,QAAQ,GAAG;EAC3B,IAAI,OAAO,IACT;EAEF,IAAI,KAAK,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK,MAAM,MAC/B,OAAO,KAAK,MAAM,KAAK,CAAC,CAAC,CAAC,KAAK;CAEnC;AAEF;AAEA,SAAS,OAAO,SAAmC;CACjD,OAAO;EACL,IAAI,QAAQ;EACZ,OAAO,QAAQ;EACf,MAAM,QAAQ;EACd,WAAW,QAAQ;EACnB,MAAM,QAAQ;CAChB;AACF;;;;;;;AAQA,SAAgB,mBAAmB,SAA4C;CAC7E,MAAM,EAAE,UAAU,WAAW;CAI7B,MAAM,QAAQ,OAAO,YAA+C;EAClE,MAAM,QAAQ,WAAW,SAAS,cAAc;EAChD,IAAI,CAAC,OACH,OAAO;EAET,MAAM,UAAU,cAAc,QAAQ,KAAK;EAC3C,IAAI,CAAC,WAAW,QAAQ,aAAa,KAAK,IAAI,GAC5C,OAAO;EAET,OAAO,OAAO,OAAO;CACvB;CAGA,MAAM,gBAAgB,OAAO,SAAoC;EAC/D,MAAM,UAA0B;GAC9B,QAAQ,KAAK;GACb,OAAO,KAAK;GACZ,MAAM,KAAK;GACX,WAAW,KAAK;GAChB,MAAM,KAAK;GACX,WAAW,KAAK,IAAI,IAAI;EAC1B;EACA,OAAO,YAAY,QAAQ,OAAO;CACpC;CAEA,MAAM,QAAQ,OAAO,OAAe,SAAoC;EACtE,IAAI,CAAC,aAAa,OAAO,QAAQ,GAC/B,MAAM,IAAI,MAAM,kBAAkB;EAEpC,OAAO,MAAM,cAAc,IAAI;CACjC;CAEA,OAAO;EACL,UAAU;GACR,MAAM;GACN,SAAA;GACA,aAAa;GACb,MAAM;EACR;EACA;EACA;EACA,MAAM,iBAAiB;GACrB,MAAM,QAAQ,QAAQ;EACxB;EACA;CACF;AACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@storyshelf/auth-password",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shared-password auth adapter for StoryShelf.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Siddhant Gupta",
|
|
8
|
+
"url": "https://guptasiddhant.com"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/GuptaSiddhant/storyshelf.git",
|
|
15
|
+
"directory": "packages/auth-password"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/GuptaSiddhant/storyshelf#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/GuptaSiddhant/storyshelf/issues"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./dist/index.mjs",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown",
|
|
33
|
+
"dev": "tsdown -w",
|
|
34
|
+
"fmt": "oxfmt -c ../../.oxfmtrc.json ./src",
|
|
35
|
+
"lint": "oxlint --type-aware --type-check ./src",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"prepublishOnly": "nub run build"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@storyshelf/core": "workspace:*",
|
|
41
|
+
"hono": "^4.11.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "catalog:",
|
|
45
|
+
"@vitest/coverage-v8": "catalog:",
|
|
46
|
+
"oxfmt": "catalog:",
|
|
47
|
+
"oxlint": "catalog:",
|
|
48
|
+
"oxlint-tsgolint": "catalog:",
|
|
49
|
+
"tsdown": "catalog:",
|
|
50
|
+
"typescript": "catalog:",
|
|
51
|
+
"vitest": "catalog:"
|
|
52
|
+
},
|
|
53
|
+
"types": "./dist/index.d.mts",
|
|
54
|
+
"exports": {
|
|
55
|
+
".": {
|
|
56
|
+
"source": "./src/index.ts",
|
|
57
|
+
"default": "./dist/index.mjs"
|
|
58
|
+
},
|
|
59
|
+
"./package.json": "./package.json"
|
|
60
|
+
}
|
|
61
|
+
}
|