@nerdsystem/sdk 0.0.5 → 0.0.7
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/app.d.ts +2 -0
- package/dist/app.js +3 -0
- package/dist/auth/client.d.ts +6 -1
- package/dist/auth/client.js +8 -2
- package/dist/auth/csrf.d.ts +9 -0
- package/dist/auth/csrf.js +26 -0
- package/dist/auth/middleware.js +1 -1
- package/dist/auth/routes.js +7 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/dist/app.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface CreateNerdAppOptions {
|
|
|
7
7
|
authPath?: string;
|
|
8
8
|
/** Security headers on every response. Pass false to disable, or options to tune the CSP. */
|
|
9
9
|
securityHeaders?: SecurityHeadersOptions | false;
|
|
10
|
+
/** Reject cookie-authenticated writes that a browser sent from another site. Pass false only if you handle CSRF yourself. */
|
|
11
|
+
crossSiteGuard?: false;
|
|
10
12
|
}
|
|
11
13
|
/**
|
|
12
14
|
* A Hono app with nerdsystem auth wired in: session routes at /api/auth and `c.var.user`
|
package/dist/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { attachUser } from "./auth/middleware";
|
|
3
|
+
import { crossSiteGuard } from "./auth/csrf";
|
|
3
4
|
import { authRoutes } from "./auth/routes";
|
|
4
5
|
import { securityHeaders } from "./security";
|
|
5
6
|
/**
|
|
@@ -10,6 +11,8 @@ export function createNerdApp(options = {}) {
|
|
|
10
11
|
const app = new Hono();
|
|
11
12
|
if (options.securityHeaders !== false)
|
|
12
13
|
app.use("*", securityHeaders(options.securityHeaders ?? {}));
|
|
14
|
+
if (options.crossSiteGuard !== false)
|
|
15
|
+
app.use("*", crossSiteGuard());
|
|
13
16
|
app.use("*", attachUser());
|
|
14
17
|
app.route(options.authPath ?? "/api/auth", authRoutes());
|
|
15
18
|
app.notFound((c) => c.json({ error: { code: "not_found", message: `No route for ${c.req.method} ${c.req.path}` } }, 404));
|
package/dist/auth/client.d.ts
CHANGED
|
@@ -21,8 +21,12 @@ export declare class AuthServiceError extends Error {
|
|
|
21
21
|
export declare class AuthServiceClient {
|
|
22
22
|
private readonly env;
|
|
23
23
|
private readonly fetchImpl;
|
|
24
|
+
/** The visitor's address, forwarded so the platform rate-limits per person rather than per app. */
|
|
25
|
+
private readonly clientIp?;
|
|
24
26
|
private readonly base;
|
|
25
|
-
constructor(env: Pick<NerdEnv, "NERD_AUTH_URL" | "NERD_PROJECT_ID" | "NERD_AUTH_SECRET">, fetchImpl?: typeof fetch
|
|
27
|
+
constructor(env: Pick<NerdEnv, "NERD_AUTH_URL" | "NERD_PROJECT_ID" | "NERD_AUTH_SECRET">, fetchImpl?: typeof fetch,
|
|
28
|
+
/** The visitor's address, forwarded so the platform rate-limits per person rather than per app. */
|
|
29
|
+
clientIp?: string | undefined);
|
|
26
30
|
get jwksUrl(): string;
|
|
27
31
|
private call;
|
|
28
32
|
signup(input: {
|
|
@@ -41,6 +45,7 @@ export declare class AuthServiceClient {
|
|
|
41
45
|
me(accessToken: string): Promise<{
|
|
42
46
|
user: EndUser;
|
|
43
47
|
}>;
|
|
48
|
+
/** Server-side only: the platform requires the tenant secret so nobody can turn the reset email into a phishing relay. */
|
|
44
49
|
forgotPassword(input: {
|
|
45
50
|
email: string;
|
|
46
51
|
resetUrl: string;
|
package/dist/auth/client.js
CHANGED
|
@@ -13,12 +13,16 @@ export class AuthServiceError extends Error {
|
|
|
13
13
|
export class AuthServiceClient {
|
|
14
14
|
env;
|
|
15
15
|
fetchImpl;
|
|
16
|
+
clientIp;
|
|
16
17
|
base;
|
|
17
18
|
constructor(env,
|
|
18
19
|
// Wrapped, not bare: calling a stored `fetch` as a method breaks in workerd (illegal invocation).
|
|
19
|
-
fetchImpl = (input, init) => fetch(input, init)
|
|
20
|
+
fetchImpl = (input, init) => fetch(input, init),
|
|
21
|
+
/** The visitor's address, forwarded so the platform rate-limits per person rather than per app. */
|
|
22
|
+
clientIp) {
|
|
20
23
|
this.env = env;
|
|
21
24
|
this.fetchImpl = fetchImpl;
|
|
25
|
+
this.clientIp = clientIp;
|
|
22
26
|
this.base = `${env.NERD_AUTH_URL.replace(/\/$/, "")}/t/${env.NERD_PROJECT_ID}`;
|
|
23
27
|
}
|
|
24
28
|
get jwksUrl() {
|
|
@@ -30,6 +34,7 @@ export class AuthServiceClient {
|
|
|
30
34
|
headers: {
|
|
31
35
|
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
32
36
|
...(auth ? { authorization: `Bearer ${auth}` } : {}),
|
|
37
|
+
...(this.clientIp ? { "x-nerd-client-ip": this.clientIp } : {}),
|
|
33
38
|
},
|
|
34
39
|
body: body !== undefined ? JSON.stringify(body) : null,
|
|
35
40
|
});
|
|
@@ -56,8 +61,9 @@ export class AuthServiceClient {
|
|
|
56
61
|
me(accessToken) {
|
|
57
62
|
return this.call("GET", "/me", undefined, accessToken);
|
|
58
63
|
}
|
|
64
|
+
/** Server-side only: the platform requires the tenant secret so nobody can turn the reset email into a phishing relay. */
|
|
59
65
|
forgotPassword(input) {
|
|
60
|
-
return this.call("POST", "/password/forgot", input);
|
|
66
|
+
return this.call("POST", "/password/forgot", input, this.env.NERD_AUTH_SECRET);
|
|
61
67
|
}
|
|
62
68
|
resetPassword(input) {
|
|
63
69
|
return this.call("POST", "/password/reset", input);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
/**
|
|
3
|
+
* Rejects state-changing requests that a browser sent from another site. Every app on the shared
|
|
4
|
+
* app domain is "same-site" with every other until the domain is on the Public Suffix List, and
|
|
5
|
+
* the auth cookies are SameSite=Lax, so without this a page on one app could POST to another with
|
|
6
|
+
* the visitor's cookies attached. Requests with a bearer token (mobile and server clients) and
|
|
7
|
+
* requests without browser fetch metadata are allowed through.
|
|
8
|
+
*/
|
|
9
|
+
export declare function crossSiteGuard(): MiddlewareHandler;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rejects state-changing requests that a browser sent from another site. Every app on the shared
|
|
3
|
+
* app domain is "same-site" with every other until the domain is on the Public Suffix List, and
|
|
4
|
+
* the auth cookies are SameSite=Lax, so without this a page on one app could POST to another with
|
|
5
|
+
* the visitor's cookies attached. Requests with a bearer token (mobile and server clients) and
|
|
6
|
+
* requests without browser fetch metadata are allowed through.
|
|
7
|
+
*/
|
|
8
|
+
export function crossSiteGuard() {
|
|
9
|
+
return async (c, next) => {
|
|
10
|
+
const method = c.req.method;
|
|
11
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
12
|
+
return next();
|
|
13
|
+
if (c.req.header("authorization")?.toLowerCase().startsWith("bearer "))
|
|
14
|
+
return next();
|
|
15
|
+
const site = c.req.header("sec-fetch-site");
|
|
16
|
+
if (site && site !== "same-origin" && site !== "none")
|
|
17
|
+
return reject(c);
|
|
18
|
+
const origin = c.req.header("origin");
|
|
19
|
+
if (origin && origin !== new URL(c.req.url).origin)
|
|
20
|
+
return reject(c);
|
|
21
|
+
return next();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function reject(c) {
|
|
25
|
+
return c.json({ error: { code: "forbidden", message: "Cross-site request rejected. Call this API from the app's own pages, or send a bearer token." } }, 403);
|
|
26
|
+
}
|
package/dist/auth/middleware.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function resolveUser(c) {
|
|
|
16
16
|
const token = bearer?.toLowerCase().startsWith("bearer ") ? bearer.slice(7) : readAccessCookie(c);
|
|
17
17
|
if (!token)
|
|
18
18
|
return null;
|
|
19
|
-
const client = new AuthServiceClient(c.env);
|
|
19
|
+
const client = new AuthServiceClient(c.env, undefined, c.req.header("cf-connecting-ip"));
|
|
20
20
|
const verified = await verifyAccessToken(token, { jwksUrl: client.jwksUrl, projectId: c.env.NERD_PROJECT_ID });
|
|
21
21
|
if (!verified)
|
|
22
22
|
return null;
|
package/dist/auth/routes.js
CHANGED
|
@@ -39,7 +39,7 @@ export function authRoutes() {
|
|
|
39
39
|
if (c.env.NERD_TURNSTILE_SECRET && !(await verifyTurnstile(c.env.NERD_TURNSTILE_SECRET, str(body.turnstileToken), c.req.header("cf-connecting-ip")))) {
|
|
40
40
|
return c.json({ error: { code: "bad_request", message: "Please complete the verification challenge" } }, 400);
|
|
41
41
|
}
|
|
42
|
-
const session = await new AuthServiceClient(c.env).signup({ email, password, name });
|
|
42
|
+
const session = await new AuthServiceClient(c.env, undefined, c.req.header("cf-connecting-ip")).signup({ email, password, name });
|
|
43
43
|
setSessionCookies(c, session);
|
|
44
44
|
return c.json({ user: session.user }, 201);
|
|
45
45
|
});
|
|
@@ -49,7 +49,7 @@ export function authRoutes() {
|
|
|
49
49
|
const password = str(body.password);
|
|
50
50
|
if (!email || !password)
|
|
51
51
|
return c.json({ error: { code: "bad_request", message: "email and password are required" } }, 400);
|
|
52
|
-
const session = await new AuthServiceClient(c.env).login({ email, password });
|
|
52
|
+
const session = await new AuthServiceClient(c.env, undefined, c.req.header("cf-connecting-ip")).login({ email, password });
|
|
53
53
|
setSessionCookies(c, session);
|
|
54
54
|
return c.json({ user: session.user }, 200);
|
|
55
55
|
});
|
|
@@ -58,7 +58,7 @@ export function authRoutes() {
|
|
|
58
58
|
if (!refreshToken)
|
|
59
59
|
return c.json({ error: { code: "unauthorized", message: "No session" } }, 401);
|
|
60
60
|
try {
|
|
61
|
-
const session = await new AuthServiceClient(c.env).refresh(refreshToken);
|
|
61
|
+
const session = await new AuthServiceClient(c.env, undefined, c.req.header("cf-connecting-ip")).refresh(refreshToken);
|
|
62
62
|
setSessionCookies(c, session);
|
|
63
63
|
return c.json({ user: session.user }, 200);
|
|
64
64
|
}
|
|
@@ -73,7 +73,7 @@ export function authRoutes() {
|
|
|
73
73
|
r.post("/logout", async (c) => {
|
|
74
74
|
const refreshToken = readRefreshCookie(c);
|
|
75
75
|
if (refreshToken)
|
|
76
|
-
await new AuthServiceClient(c.env).logout(refreshToken).catch(() => { });
|
|
76
|
+
await new AuthServiceClient(c.env, undefined, c.req.header("cf-connecting-ip")).logout(refreshToken).catch(() => { });
|
|
77
77
|
clearSessionCookies(c);
|
|
78
78
|
return c.json({ ok: true }, 200);
|
|
79
79
|
});
|
|
@@ -84,7 +84,7 @@ export function authRoutes() {
|
|
|
84
84
|
if (!email)
|
|
85
85
|
return c.json({ error: { code: "bad_request", message: "email is required" } }, 400);
|
|
86
86
|
const origin = new URL(c.req.url).origin;
|
|
87
|
-
await new AuthServiceClient(c.env).forgotPassword({ email, resetUrl: `${origin}/reset-password`, ...(c.env.NERD_APP_NAME ? { appName: c.env.NERD_APP_NAME } : {}) });
|
|
87
|
+
await new AuthServiceClient(c.env, undefined, c.req.header("cf-connecting-ip")).forgotPassword({ email, resetUrl: `${origin}/reset-password`, ...(c.env.NERD_APP_NAME ? { appName: c.env.NERD_APP_NAME } : {}) });
|
|
88
88
|
return c.json({ ok: true }, 200);
|
|
89
89
|
});
|
|
90
90
|
r.post("/reset", async (c) => {
|
|
@@ -93,7 +93,7 @@ export function authRoutes() {
|
|
|
93
93
|
const password = str(body.password);
|
|
94
94
|
if (!token || !password)
|
|
95
95
|
return c.json({ error: { code: "bad_request", message: "token and password are required" } }, 400);
|
|
96
|
-
const session = await new AuthServiceClient(c.env).resetPassword({ token, password });
|
|
96
|
+
const session = await new AuthServiceClient(c.env, undefined, c.req.header("cf-connecting-ip")).resetPassword({ token, password });
|
|
97
97
|
setSessionCookies(c, session);
|
|
98
98
|
return c.json({ user: session.user }, 200);
|
|
99
99
|
});
|
|
@@ -106,7 +106,7 @@ export function authRoutes() {
|
|
|
106
106
|
if (!refreshToken || readAccessCookie(c))
|
|
107
107
|
return c.json({ user: null }, 200);
|
|
108
108
|
try {
|
|
109
|
-
const session = await new AuthServiceClient(c.env).refresh(refreshToken);
|
|
109
|
+
const session = await new AuthServiceClient(c.env, undefined, c.req.header("cf-connecting-ip")).refresh(refreshToken);
|
|
110
110
|
setSessionCookies(c, session);
|
|
111
111
|
return c.json({ user: { id: session.user.id, email: session.user.email, name: session.user.name, roles: session.user.roles } }, 200);
|
|
112
112
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createNerdApp, type CreateNerdAppOptions } from "./app";
|
|
2
2
|
export type { NerdEnv, NerdUser, NerdVariables } from "./env";
|
|
3
3
|
export { attachUser, requireAuth, requireRole, resolveUser, type NerdHonoEnv } from "./auth/middleware";
|
|
4
|
+
export { crossSiteGuard } from "./auth/csrf";
|
|
4
5
|
export { AuthServiceClient, AuthServiceError, type EndUser, type Session } from "./auth/client";
|
|
5
6
|
export { verifyAccessToken, type VerifiedToken } from "./auth/jwks";
|
|
6
7
|
export { authRoutes } from "./auth/routes";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createNerdApp } from "./app";
|
|
2
2
|
export { attachUser, requireAuth, requireRole, resolveUser } from "./auth/middleware";
|
|
3
|
+
export { crossSiteGuard } from "./auth/csrf";
|
|
3
4
|
export { AuthServiceClient, AuthServiceError } from "./auth/client";
|
|
4
5
|
export { verifyAccessToken } from "./auth/jwks";
|
|
5
6
|
export { authRoutes } from "./auth/routes";
|