@open-neko/plugin-magic-link 0.3.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/LICENSE +202 -0
- package/README.md +65 -0
- package/dist/email.d.ts +34 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +190 -0
- package/dist/email.js.map +1 -0
- package/dist/plugin.d.ts +35 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +131 -0
- package/dist/plugin.js.map +1 -0
- package/dist/run.d.ts +2 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +15675 -0
- package/dist/run.js.map +1 -0
- package/dist/token.d.ts +45 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +89 -0
- package/dist/token.js.map +1 -0
- package/package.json +87 -0
package/dist/run.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run.js","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,MAAM,mBAAmB,CAAC,MAAM,CAAC,CAAC"}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless single-use sign-in tokens.
|
|
3
|
+
*
|
|
4
|
+
* The plugin VM keeps no storage between calls, so the token itself
|
|
5
|
+
* carries everything verification needs: the email it was issued for,
|
|
6
|
+
* the CSRF state of the browser that requested it, and an expiry —
|
|
7
|
+
* HMAC-SHA256-signed so none of it can be altered.
|
|
8
|
+
*
|
|
9
|
+
* Single-use is enforced by the core, not here: the state cookie is
|
|
10
|
+
* read-and-cleared on the first callback, and a token only verifies
|
|
11
|
+
* against the state it was minted for, so a replayed link finds no
|
|
12
|
+
* matching cookie and dies at the core's login-CSRF check. The same
|
|
13
|
+
* binding means a link only works in the browser that requested it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MIN_SECRET_LENGTH = 32;
|
|
16
|
+
export declare const DEFAULT_TTL_SECONDS = 600;
|
|
17
|
+
export declare const MIN_TTL_SECONDS = 60;
|
|
18
|
+
/** The core's state cookie dies at 10 minutes; longer tokens buy nothing. */
|
|
19
|
+
export declare const MAX_TTL_SECONDS = 600;
|
|
20
|
+
export interface TokenClaims {
|
|
21
|
+
email: string;
|
|
22
|
+
state: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function mintToken(input: {
|
|
26
|
+
email: string;
|
|
27
|
+
state: string;
|
|
28
|
+
secret: string;
|
|
29
|
+
ttlSeconds?: number;
|
|
30
|
+
nowMs?: number;
|
|
31
|
+
}): string;
|
|
32
|
+
export declare function clampTtl(ttlSeconds: number | undefined): number;
|
|
33
|
+
/**
|
|
34
|
+
* Verify signature, expiry, and state binding. Returns the claims or
|
|
35
|
+
* throws — callers surface the message on the sign-in page, so keep
|
|
36
|
+
* reasons generic (no oracle for which part failed beyond expiry,
|
|
37
|
+
* which the user genuinely needs to know to re-request a link).
|
|
38
|
+
*/
|
|
39
|
+
export declare function verifyToken(input: {
|
|
40
|
+
token: string;
|
|
41
|
+
state: string;
|
|
42
|
+
secret: string;
|
|
43
|
+
nowMs?: number;
|
|
44
|
+
}): TokenClaims;
|
|
45
|
+
//# sourceMappingURL=token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,mBAAmB,MAAM,CAAC;AACvC,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC,6EAA6E;AAC7E,eAAO,MAAM,eAAe,MAAM,CAAC;AAEnC,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAUD,wBAAgB,SAAS,CAAC,KAAK,EAAE;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,CAcT;AAED,wBAAgB,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAK/D;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,WAAW,CAyCd"}
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless single-use sign-in tokens.
|
|
3
|
+
*
|
|
4
|
+
* The plugin VM keeps no storage between calls, so the token itself
|
|
5
|
+
* carries everything verification needs: the email it was issued for,
|
|
6
|
+
* the CSRF state of the browser that requested it, and an expiry —
|
|
7
|
+
* HMAC-SHA256-signed so none of it can be altered.
|
|
8
|
+
*
|
|
9
|
+
* Single-use is enforced by the core, not here: the state cookie is
|
|
10
|
+
* read-and-cleared on the first callback, and a token only verifies
|
|
11
|
+
* against the state it was minted for, so a replayed link finds no
|
|
12
|
+
* matching cookie and dies at the core's login-CSRF check. The same
|
|
13
|
+
* binding means a link only works in the browser that requested it.
|
|
14
|
+
*/
|
|
15
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
16
|
+
export const MIN_SECRET_LENGTH = 32;
|
|
17
|
+
export const DEFAULT_TTL_SECONDS = 600;
|
|
18
|
+
export const MIN_TTL_SECONDS = 60;
|
|
19
|
+
/** The core's state cookie dies at 10 minutes; longer tokens buy nothing. */
|
|
20
|
+
export const MAX_TTL_SECONDS = 600;
|
|
21
|
+
function b64url(data) {
|
|
22
|
+
return Buffer.from(data).toString("base64url");
|
|
23
|
+
}
|
|
24
|
+
function sign(payload, secret) {
|
|
25
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
26
|
+
}
|
|
27
|
+
export function mintToken(input) {
|
|
28
|
+
if (input.secret.length < MIN_SECRET_LENGTH) {
|
|
29
|
+
throw new Error(`MAGIC_LINK_SIGNING_SECRET must be at least ${MIN_SECRET_LENGTH} characters`);
|
|
30
|
+
}
|
|
31
|
+
const ttl = clampTtl(input.ttlSeconds);
|
|
32
|
+
const claims = {
|
|
33
|
+
email: input.email,
|
|
34
|
+
state: input.state,
|
|
35
|
+
expiresAt: Math.floor((input.nowMs ?? Date.now()) / 1000) + ttl,
|
|
36
|
+
};
|
|
37
|
+
const payload = b64url(JSON.stringify(claims));
|
|
38
|
+
return `${payload}.${sign(payload, input.secret)}`;
|
|
39
|
+
}
|
|
40
|
+
export function clampTtl(ttlSeconds) {
|
|
41
|
+
if (ttlSeconds === undefined || Number.isNaN(ttlSeconds)) {
|
|
42
|
+
return DEFAULT_TTL_SECONDS;
|
|
43
|
+
}
|
|
44
|
+
return Math.min(MAX_TTL_SECONDS, Math.max(MIN_TTL_SECONDS, Math.floor(ttlSeconds)));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Verify signature, expiry, and state binding. Returns the claims or
|
|
48
|
+
* throws — callers surface the message on the sign-in page, so keep
|
|
49
|
+
* reasons generic (no oracle for which part failed beyond expiry,
|
|
50
|
+
* which the user genuinely needs to know to re-request a link).
|
|
51
|
+
*/
|
|
52
|
+
export function verifyToken(input) {
|
|
53
|
+
const parts = input.token.split(".");
|
|
54
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
55
|
+
throw new Error("this sign-in link is not valid");
|
|
56
|
+
}
|
|
57
|
+
const [payload, signature] = parts;
|
|
58
|
+
const expected = sign(payload, input.secret);
|
|
59
|
+
const provided = Buffer.from(signature, "base64url");
|
|
60
|
+
const wanted = Buffer.from(expected, "base64url");
|
|
61
|
+
if (provided.length !== wanted.length || !timingSafeEqual(provided, wanted)) {
|
|
62
|
+
throw new Error("this sign-in link is not valid");
|
|
63
|
+
}
|
|
64
|
+
let claims;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
67
|
+
if (typeof parsed.email !== "string" ||
|
|
68
|
+
parsed.email.length === 0 ||
|
|
69
|
+
typeof parsed.state !== "string" ||
|
|
70
|
+
parsed.state.length === 0 ||
|
|
71
|
+
typeof parsed.expiresAt !== "number") {
|
|
72
|
+
throw new Error("malformed claims");
|
|
73
|
+
}
|
|
74
|
+
claims = parsed;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error("this sign-in link is not valid");
|
|
78
|
+
}
|
|
79
|
+
if (claims.state !== input.state) {
|
|
80
|
+
// Wrong browser or a replayed link — the state cookie can't match.
|
|
81
|
+
throw new Error("this sign-in link must be opened in the browser that requested it");
|
|
82
|
+
}
|
|
83
|
+
const now = Math.floor((input.nowMs ?? Date.now()) / 1000);
|
|
84
|
+
if (claims.expiresAt <= now) {
|
|
85
|
+
throw new Error("this sign-in link has expired — request a new one");
|
|
86
|
+
}
|
|
87
|
+
return claims;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.js","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-neko/plugin-magic-link",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Passwordless email sign-in for OpenNeko. Implements the auth contract with manual provisioning: an admin creates each user (email + role) in OpenNeko first, and only those users can request a single-use, HMAC-signed sign-in link — possession of a mailbox never mints an account. Delivery via Resend, Postmark, or SendGrid.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/open-neko/plugins.git",
|
|
9
|
+
"directory": "packages/magic-link"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"openneko": {
|
|
17
|
+
"runner": "./dist/run.js",
|
|
18
|
+
"permissions": {
|
|
19
|
+
"network": [
|
|
20
|
+
"api.resend.com",
|
|
21
|
+
"api.postmarkapp.com",
|
|
22
|
+
"api.sendgrid.com"
|
|
23
|
+
],
|
|
24
|
+
"env": [
|
|
25
|
+
{
|
|
26
|
+
"key": "MAGIC_LINK_SIGNING_SECRET",
|
|
27
|
+
"required": true,
|
|
28
|
+
"secret": true,
|
|
29
|
+
"autogenerate": true,
|
|
30
|
+
"description": "HMAC key that signs each sign-in token. Generated and stored by the host automatically — never prompted, never seen. Deleting the stored value and restarting rotates it, which invalidates every outstanding link."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"key": "MAGIC_LINK_FROM",
|
|
34
|
+
"required": true,
|
|
35
|
+
"secret": false,
|
|
36
|
+
"description": "From address for the sign-in email, e.g. \"OpenNeko <signin@company.com>\". Must be a sender verified with your email provider."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"key": "RESEND_API_KEY",
|
|
40
|
+
"required": false,
|
|
41
|
+
"secret": true,
|
|
42
|
+
"description": "Resend API key. Set exactly one of RESEND_API_KEY, POSTMARK_SERVER_TOKEN, or SENDGRID_API_KEY — it selects the delivery provider."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"key": "POSTMARK_SERVER_TOKEN",
|
|
46
|
+
"required": false,
|
|
47
|
+
"secret": true,
|
|
48
|
+
"description": "Postmark server token. Set exactly one of RESEND_API_KEY, POSTMARK_SERVER_TOKEN, or SENDGRID_API_KEY."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"key": "SENDGRID_API_KEY",
|
|
52
|
+
"required": false,
|
|
53
|
+
"secret": true,
|
|
54
|
+
"description": "SendGrid API key. Set exactly one of RESEND_API_KEY, POSTMARK_SERVER_TOKEN, or SENDGRID_API_KEY."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"key": "MAGIC_LINK_TTL_SECONDS",
|
|
58
|
+
"required": false,
|
|
59
|
+
"secret": false,
|
|
60
|
+
"description": "Sign-in link lifetime in seconds, 60–600. Defaults to 600 (10 minutes) — the core's state cookie expires then anyway, so longer values buy nothing."
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"capabilities": {
|
|
65
|
+
"auth": {
|
|
66
|
+
"providerLabel": "Email link",
|
|
67
|
+
"provisioning": "manual",
|
|
68
|
+
"loginHintRequired": true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"@open-neko/plugin-types": "0.10.0"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@types/node": "^20.19.0",
|
|
77
|
+
"esbuild": "^0.25.0",
|
|
78
|
+
"typescript": "^5.6.3",
|
|
79
|
+
"vitest": "^2.1.8"
|
|
80
|
+
},
|
|
81
|
+
"scripts": {
|
|
82
|
+
"build": "tsc -p tsconfig.json && node scripts/bundle.mjs",
|
|
83
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
84
|
+
"test": "vitest run",
|
|
85
|
+
"test:watch": "vitest"
|
|
86
|
+
}
|
|
87
|
+
}
|