or3-provider-basic-auth 0.0.1
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 +99 -0
- package/dist/module.d.mts +5 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +42 -0
- package/dist/runtime/components/BasicAuthChangePasswordModal.client.d.vue.ts +12 -0
- package/dist/runtime/components/BasicAuthChangePasswordModal.client.vue +133 -0
- package/dist/runtime/components/BasicAuthChangePasswordModal.client.vue.d.ts +12 -0
- package/dist/runtime/components/BasicAuthRegisterModal.client.d.vue.ts +11 -0
- package/dist/runtime/components/BasicAuthRegisterModal.client.vue +164 -0
- package/dist/runtime/components/BasicAuthRegisterModal.client.vue.d.ts +11 -0
- package/dist/runtime/components/BasicAuthSignInModal.client.d.vue.ts +13 -0
- package/dist/runtime/components/BasicAuthSignInModal.client.vue +136 -0
- package/dist/runtime/components/BasicAuthSignInModal.client.vue.d.ts +13 -0
- package/dist/runtime/components/BasicAuthUserMenu.client.d.vue.ts +12 -0
- package/dist/runtime/components/BasicAuthUserMenu.client.vue +106 -0
- package/dist/runtime/components/BasicAuthUserMenu.client.vue.d.ts +12 -0
- package/dist/runtime/components/SidebarAuthButtonBasic.client.d.vue.ts +2 -0
- package/dist/runtime/components/SidebarAuthButtonBasic.client.vue +154 -0
- package/dist/runtime/components/SidebarAuthButtonBasic.client.vue.d.ts +2 -0
- package/dist/runtime/lib/constants.d.ts +7 -0
- package/dist/runtime/lib/constants.js +7 -0
- package/dist/runtime/plugins/auth-status.client.d.ts +2 -0
- package/dist/runtime/plugins/auth-status.client.js +38 -0
- package/dist/runtime/plugins/basic-auth-ui.client.d.ts +2 -0
- package/dist/runtime/plugins/basic-auth-ui.client.js +46 -0
- package/dist/runtime/server/admin/adapters/auth-basic-auth.d.ts +2 -0
- package/dist/runtime/server/admin/adapters/auth-basic-auth.js +34 -0
- package/dist/runtime/server/api/basic-auth/_helpers.d.ts +6 -0
- package/dist/runtime/server/api/basic-auth/_helpers.js +26 -0
- package/dist/runtime/server/api/basic-auth/change-password.post.d.ts +8 -0
- package/dist/runtime/server/api/basic-auth/change-password.post.js +49 -0
- package/dist/runtime/server/api/basic-auth/refresh.post.d.ts +8 -0
- package/dist/runtime/server/api/basic-auth/refresh.post.js +78 -0
- package/dist/runtime/server/api/basic-auth/register.post.d.ts +8 -0
- package/dist/runtime/server/api/basic-auth/register.post.js +112 -0
- package/dist/runtime/server/api/basic-auth/sign-in.post.d.ts +8 -0
- package/dist/runtime/server/api/basic-auth/sign-in.post.js +75 -0
- package/dist/runtime/server/api/basic-auth/sign-out.post.d.ts +8 -0
- package/dist/runtime/server/api/basic-auth/sign-out.post.js +37 -0
- package/dist/runtime/server/auth/basic-auth-provider.d.ts +2 -0
- package/dist/runtime/server/auth/basic-auth-provider.js +41 -0
- package/dist/runtime/server/auth/index.d.ts +1 -0
- package/dist/runtime/server/auth/index.js +1 -0
- package/dist/runtime/server/db/client.d.ts +3 -0
- package/dist/runtime/server/db/client.js +106 -0
- package/dist/runtime/server/db/schema.d.ts +21 -0
- package/dist/runtime/server/db/schema.js +0 -0
- package/dist/runtime/server/lib/config.d.ts +22 -0
- package/dist/runtime/server/lib/config.js +94 -0
- package/dist/runtime/server/lib/cookies.d.ts +4 -0
- package/dist/runtime/server/lib/cookies.js +42 -0
- package/dist/runtime/server/lib/errors.d.ts +4 -0
- package/dist/runtime/server/lib/errors.js +25 -0
- package/dist/runtime/server/lib/jwt.d.ts +37 -0
- package/dist/runtime/server/lib/jwt.js +77 -0
- package/dist/runtime/server/lib/password.d.ts +2 -0
- package/dist/runtime/server/lib/password.js +8 -0
- package/dist/runtime/server/lib/rate-limit.d.ts +6 -0
- package/dist/runtime/server/lib/rate-limit.js +163 -0
- package/dist/runtime/server/lib/request-identity.d.ts +16 -0
- package/dist/runtime/server/lib/request-identity.js +32 -0
- package/dist/runtime/server/lib/request-security.d.ts +2 -0
- package/dist/runtime/server/lib/request-security.js +37 -0
- package/dist/runtime/server/lib/session-store.d.ts +46 -0
- package/dist/runtime/server/lib/session-store.js +190 -0
- package/dist/runtime/server/plugins/register.d.ts +2 -0
- package/dist/runtime/server/plugins/register.js +63 -0
- package/dist/runtime/server/token-broker/basic-auth-token-broker.d.ts +6 -0
- package/dist/runtime/server/token-broker/basic-auth-token-broker.js +8 -0
- package/dist/runtime/server/token-broker/index.d.ts +1 -0
- package/dist/runtime/server/token-broker/index.js +1 -0
- package/dist/types.d.mts +7 -0
- package/package.json +70 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { getCookie } from "h3";
|
|
3
|
+
import jwt from "jsonwebtoken";
|
|
4
|
+
import { ACCESS_COOKIE_NAME, REFRESH_COOKIE_NAME } from "../../lib/constants.js";
|
|
5
|
+
import { getBasicAuthConfig } from "./config.js";
|
|
6
|
+
export async function signAccessToken(input) {
|
|
7
|
+
const config = getBasicAuthConfig();
|
|
8
|
+
return jwt.sign(
|
|
9
|
+
{
|
|
10
|
+
sid: input.sid,
|
|
11
|
+
ver: input.ver,
|
|
12
|
+
typ: "access",
|
|
13
|
+
email: input.email,
|
|
14
|
+
display_name: input.display_name ?? null
|
|
15
|
+
},
|
|
16
|
+
config.jwtSecret,
|
|
17
|
+
{
|
|
18
|
+
expiresIn: config.accessTtlSeconds,
|
|
19
|
+
subject: input.sub
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
export async function signRefreshToken(input) {
|
|
24
|
+
const config = getBasicAuthConfig();
|
|
25
|
+
return jwt.sign(
|
|
26
|
+
{
|
|
27
|
+
sid: input.sid,
|
|
28
|
+
ver: input.ver,
|
|
29
|
+
typ: "refresh",
|
|
30
|
+
jti: randomUUID()
|
|
31
|
+
},
|
|
32
|
+
config.refreshSecret,
|
|
33
|
+
{
|
|
34
|
+
expiresIn: config.refreshTtlSeconds,
|
|
35
|
+
subject: input.sub
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
function isAccessClaims(payload) {
|
|
40
|
+
return payload.typ === "access" && typeof payload.sub === "string" && typeof payload.sid === "string" && typeof payload.ver === "number";
|
|
41
|
+
}
|
|
42
|
+
function isRefreshClaims(payload) {
|
|
43
|
+
return payload.typ === "refresh" && typeof payload.sub === "string" && typeof payload.sid === "string" && typeof payload.ver === "number" && typeof payload.jti === "string";
|
|
44
|
+
}
|
|
45
|
+
export async function verifyAccessToken(token) {
|
|
46
|
+
try {
|
|
47
|
+
const config = getBasicAuthConfig();
|
|
48
|
+
const payload = jwt.verify(token, config.jwtSecret, {
|
|
49
|
+
algorithms: ["HS256"]
|
|
50
|
+
});
|
|
51
|
+
if (!isAccessClaims(payload)) return null;
|
|
52
|
+
return payload;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function verifyRefreshToken(token) {
|
|
58
|
+
try {
|
|
59
|
+
const config = getBasicAuthConfig();
|
|
60
|
+
const payload = jwt.verify(token, config.refreshSecret, {
|
|
61
|
+
algorithms: ["HS256"]
|
|
62
|
+
});
|
|
63
|
+
if (!isRefreshClaims(payload)) return null;
|
|
64
|
+
return payload;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function getAccessTokenFromEvent(event) {
|
|
70
|
+
return getCookie(event, ACCESS_COOKIE_NAME) ?? null;
|
|
71
|
+
}
|
|
72
|
+
export function getRefreshTokenFromEvent(event) {
|
|
73
|
+
return getCookie(event, REFRESH_COOKIE_NAME) ?? null;
|
|
74
|
+
}
|
|
75
|
+
export function hashRefreshToken(token) {
|
|
76
|
+
return createHash("sha256").update(token).digest("hex");
|
|
77
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import bcrypt from "bcryptjs";
|
|
2
|
+
const PASSWORD_COST = 12;
|
|
3
|
+
export async function hashPassword(password) {
|
|
4
|
+
return await bcrypt.hash(password, PASSWORD_COST);
|
|
5
|
+
}
|
|
6
|
+
export async function verifyPassword(password, hash) {
|
|
7
|
+
return await bcrypt.compare(password, hash);
|
|
8
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type H3Event } from 'h3';
|
|
2
|
+
type Operation = 'basic-auth:sign-in' | 'basic-auth:register' | 'basic-auth:refresh' | 'basic-auth:sign-out' | 'basic-auth:change-password';
|
|
3
|
+
export declare function enforceBasicAuthRateLimit(event: H3Event, operation: Operation): void;
|
|
4
|
+
export declare function resetBasicAuthRateLimitStore(): void;
|
|
5
|
+
export declare function getBasicAuthRateLimitStoreSizeForTests(): number;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createError, setResponseHeader } from "h3";
|
|
2
|
+
import {
|
|
3
|
+
getClientIp as getProxyClientIp,
|
|
4
|
+
normalizeProxyTrustConfig
|
|
5
|
+
} from "./request-identity.js";
|
|
6
|
+
import { getBasicAuthDb } from "../db/client.js";
|
|
7
|
+
const RULES = {
|
|
8
|
+
"basic-auth:sign-in": { windowMs: 6e4, maxRequests: 20 },
|
|
9
|
+
"basic-auth:register": { windowMs: 6e4, maxRequests: 10 },
|
|
10
|
+
"basic-auth:refresh": { windowMs: 6e4, maxRequests: 120 },
|
|
11
|
+
"basic-auth:sign-out": { windowMs: 6e4, maxRequests: 60 },
|
|
12
|
+
"basic-auth:change-password": { windowMs: 6e4, maxRequests: 20 }
|
|
13
|
+
};
|
|
14
|
+
const store = /* @__PURE__ */ new Map();
|
|
15
|
+
const MAX_WINDOW_MS = Math.max(...Object.values(RULES).map((rule) => rule.windowMs));
|
|
16
|
+
const SWEEP_INTERVAL_MS = 6e4;
|
|
17
|
+
let lastSweepAt = 0;
|
|
18
|
+
const RATE_LIMIT_BACKEND_ENV = "OR3_BASIC_AUTH_RATE_LIMIT_BACKEND";
|
|
19
|
+
const DEFAULT_RATE_LIMIT_BACKEND = "sqlite";
|
|
20
|
+
function getClientIp(event) {
|
|
21
|
+
const runtimeConfig = useRuntimeConfig(event);
|
|
22
|
+
const proxyConfig = normalizeProxyTrustConfig(runtimeConfig.security?.proxy);
|
|
23
|
+
return getProxyClientIp(event, proxyConfig) ?? event.node.req.socket.remoteAddress ?? "unknown";
|
|
24
|
+
}
|
|
25
|
+
function getKey(subject, operation) {
|
|
26
|
+
return `${subject}:${operation}`;
|
|
27
|
+
}
|
|
28
|
+
function getWindowKey(subject, operation, windowStartMs) {
|
|
29
|
+
return `${subject}:${operation}:${windowStartMs}`;
|
|
30
|
+
}
|
|
31
|
+
function resolveRateLimitBackend() {
|
|
32
|
+
const raw = (process.env[RATE_LIMIT_BACKEND_ENV] ?? "").trim().toLowerCase();
|
|
33
|
+
if (raw === "memory") return "memory";
|
|
34
|
+
return DEFAULT_RATE_LIMIT_BACKEND;
|
|
35
|
+
}
|
|
36
|
+
function pruneRateLimitStore(now) {
|
|
37
|
+
if (now - lastSweepAt < SWEEP_INTERVAL_MS) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const cutoff = now - MAX_WINDOW_MS;
|
|
41
|
+
for (const [key, entry] of store) {
|
|
42
|
+
const recent = entry.timestamps.filter((timestamp) => timestamp > cutoff);
|
|
43
|
+
if (recent.length === 0) {
|
|
44
|
+
store.delete(key);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
entry.timestamps = recent;
|
|
48
|
+
}
|
|
49
|
+
lastSweepAt = now;
|
|
50
|
+
}
|
|
51
|
+
function pruneSqliteRateLimitStore(now) {
|
|
52
|
+
if (now - lastSweepAt < SWEEP_INTERVAL_MS) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
getBasicAuthDb().prepare("DELETE FROM basic_auth_rate_limits WHERE updated_at <= ?").run(now - MAX_WINDOW_MS);
|
|
56
|
+
lastSweepAt = now;
|
|
57
|
+
}
|
|
58
|
+
function enforceInMemoryRateLimit(event, operation, now) {
|
|
59
|
+
pruneRateLimitStore(now);
|
|
60
|
+
const subject = getClientIp(event);
|
|
61
|
+
const key = getKey(subject, operation);
|
|
62
|
+
const rule = RULES[operation];
|
|
63
|
+
const windowStart = now - rule.windowMs;
|
|
64
|
+
const current = store.get(key) ?? { timestamps: [] };
|
|
65
|
+
const recent = current.timestamps.filter((timestamp) => timestamp > windowStart);
|
|
66
|
+
if (recent.length >= rule.maxRequests) {
|
|
67
|
+
const oldest = recent[0] ?? now;
|
|
68
|
+
const retryAfterMs = Math.max(0, oldest + rule.windowMs - now);
|
|
69
|
+
const retryAfterSeconds = Math.ceil(retryAfterMs / 1e3);
|
|
70
|
+
setResponseHeader(event, "Retry-After", retryAfterSeconds);
|
|
71
|
+
setResponseHeader(event, "X-RateLimit-Limit", String(rule.maxRequests));
|
|
72
|
+
setResponseHeader(event, "X-RateLimit-Remaining", "0");
|
|
73
|
+
throw createError({
|
|
74
|
+
statusCode: 429,
|
|
75
|
+
statusMessage: "Too many requests"
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
recent.push(now);
|
|
79
|
+
store.set(key, { timestamps: recent });
|
|
80
|
+
setResponseHeader(event, "X-RateLimit-Limit", String(rule.maxRequests));
|
|
81
|
+
setResponseHeader(
|
|
82
|
+
event,
|
|
83
|
+
"X-RateLimit-Remaining",
|
|
84
|
+
String(Math.max(0, rule.maxRequests - recent.length))
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
function enforceSqliteRateLimit(event, operation, now) {
|
|
88
|
+
pruneSqliteRateLimitStore(now);
|
|
89
|
+
const subject = getClientIp(event);
|
|
90
|
+
const rule = RULES[operation];
|
|
91
|
+
const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs;
|
|
92
|
+
const windowKey = getWindowKey(subject, operation, windowStart);
|
|
93
|
+
const db = getBasicAuthDb();
|
|
94
|
+
const loadCount = db.prepare(
|
|
95
|
+
"SELECT request_count FROM basic_auth_rate_limits WHERE key = ?"
|
|
96
|
+
);
|
|
97
|
+
const insertCount = db.prepare(`
|
|
98
|
+
INSERT INTO basic_auth_rate_limits (key, subject, operation, window_started_at, request_count, updated_at)
|
|
99
|
+
VALUES (?, ?, ?, ?, 1, ?)
|
|
100
|
+
`);
|
|
101
|
+
const incrementCount = db.prepare(`
|
|
102
|
+
UPDATE basic_auth_rate_limits
|
|
103
|
+
SET request_count = request_count + 1, updated_at = ?
|
|
104
|
+
WHERE key = ?
|
|
105
|
+
`);
|
|
106
|
+
const evaluate = db.transaction(() => {
|
|
107
|
+
const existing = loadCount.get(windowKey);
|
|
108
|
+
if (!existing) {
|
|
109
|
+
insertCount.run(windowKey, subject, operation, windowStart, now);
|
|
110
|
+
return { blocked: false, requestCount: 1 };
|
|
111
|
+
}
|
|
112
|
+
if (existing.request_count >= rule.maxRequests) {
|
|
113
|
+
return { blocked: true, requestCount: existing.request_count };
|
|
114
|
+
}
|
|
115
|
+
incrementCount.run(now, windowKey);
|
|
116
|
+
return { blocked: false, requestCount: existing.request_count + 1 };
|
|
117
|
+
});
|
|
118
|
+
const result = evaluate.immediate();
|
|
119
|
+
if (result.blocked) {
|
|
120
|
+
const retryAfterMs = Math.max(0, windowStart + rule.windowMs - now);
|
|
121
|
+
const retryAfterSeconds = Math.ceil(retryAfterMs / 1e3);
|
|
122
|
+
setResponseHeader(event, "Retry-After", retryAfterSeconds);
|
|
123
|
+
setResponseHeader(event, "X-RateLimit-Limit", String(rule.maxRequests));
|
|
124
|
+
setResponseHeader(event, "X-RateLimit-Remaining", "0");
|
|
125
|
+
throw createError({
|
|
126
|
+
statusCode: 429,
|
|
127
|
+
statusMessage: "Too many requests"
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
setResponseHeader(event, "X-RateLimit-Limit", String(rule.maxRequests));
|
|
131
|
+
setResponseHeader(
|
|
132
|
+
event,
|
|
133
|
+
"X-RateLimit-Remaining",
|
|
134
|
+
String(Math.max(0, rule.maxRequests - result.requestCount))
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
export function enforceBasicAuthRateLimit(event, operation) {
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
if (resolveRateLimitBackend() === "memory") {
|
|
140
|
+
enforceInMemoryRateLimit(event, operation, now);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
enforceSqliteRateLimit(event, operation, now);
|
|
144
|
+
}
|
|
145
|
+
export function resetBasicAuthRateLimitStore() {
|
|
146
|
+
store.clear();
|
|
147
|
+
lastSweepAt = 0;
|
|
148
|
+
try {
|
|
149
|
+
getBasicAuthDb().prepare("DELETE FROM basic_auth_rate_limits").run();
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function getBasicAuthRateLimitStoreSizeForTests() {
|
|
154
|
+
if (resolveRateLimitBackend() === "memory") {
|
|
155
|
+
return store.size;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const row = getBasicAuthDb().prepare("SELECT COUNT(*) as count FROM basic_auth_rate_limits").get();
|
|
159
|
+
return row?.count ?? 0;
|
|
160
|
+
} catch {
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type H3Event } from 'h3';
|
|
2
|
+
export type ForwardedForHeader = 'x-forwarded-for' | 'x-real-ip';
|
|
3
|
+
export type ForwardedHostHeader = 'x-forwarded-host';
|
|
4
|
+
export interface ProxyTrustConfig {
|
|
5
|
+
trustProxy: boolean;
|
|
6
|
+
forwardedForHeader: ForwardedForHeader;
|
|
7
|
+
forwardedHostHeader: ForwardedHostHeader;
|
|
8
|
+
}
|
|
9
|
+
export interface ProxyTrustConfigInput {
|
|
10
|
+
trustProxy?: boolean;
|
|
11
|
+
forwardedForHeader?: string;
|
|
12
|
+
forwardedHostHeader?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function normalizeProxyTrustConfig(input?: ProxyTrustConfigInput): ProxyTrustConfig;
|
|
15
|
+
export declare function getClientIp(event: H3Event, config: ProxyTrustConfig): string | null;
|
|
16
|
+
export declare function getProxyRequestHost(event: H3Event, config: ProxyTrustConfig): string | null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getRequestHeader } from "h3";
|
|
2
|
+
const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$|^[0-9a-fA-F:.]+$/;
|
|
3
|
+
export function normalizeProxyTrustConfig(input) {
|
|
4
|
+
return {
|
|
5
|
+
trustProxy: input?.trustProxy === true,
|
|
6
|
+
forwardedForHeader: input?.forwardedForHeader === "x-real-ip" ? "x-real-ip" : "x-forwarded-for",
|
|
7
|
+
forwardedHostHeader: "x-forwarded-host"
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function parseForwardedFor(value) {
|
|
11
|
+
const firstIp = value.split(",")[0]?.trim();
|
|
12
|
+
if (!firstIp) return null;
|
|
13
|
+
if (!IP_REGEX.test(firstIp)) return null;
|
|
14
|
+
return firstIp;
|
|
15
|
+
}
|
|
16
|
+
export function getClientIp(event, config) {
|
|
17
|
+
if (config.trustProxy) {
|
|
18
|
+
const forwarded = getRequestHeader(event, config.forwardedForHeader);
|
|
19
|
+
if (!forwarded) return null;
|
|
20
|
+
return parseForwardedFor(forwarded);
|
|
21
|
+
}
|
|
22
|
+
return event.node.req.socket.remoteAddress ?? null;
|
|
23
|
+
}
|
|
24
|
+
export function getProxyRequestHost(event, config) {
|
|
25
|
+
if (config.trustProxy) {
|
|
26
|
+
const forwardedHost = getRequestHeader(event, config.forwardedHostHeader);
|
|
27
|
+
const firstHost = forwardedHost?.split(",")[0]?.trim().toLowerCase();
|
|
28
|
+
return firstHost && firstHost.length > 0 ? firstHost : null;
|
|
29
|
+
}
|
|
30
|
+
const host = getRequestHeader(event, "host");
|
|
31
|
+
return host ? host.trim().toLowerCase() : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createError, getRequestHeader } from "h3";
|
|
2
|
+
import { getProxyRequestHost, normalizeProxyTrustConfig } from "./request-identity.js";
|
|
3
|
+
function getOriginHost(originOrReferer) {
|
|
4
|
+
try {
|
|
5
|
+
return new URL(originOrReferer).host.toLowerCase();
|
|
6
|
+
} catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function normalizeHost(host) {
|
|
11
|
+
return host.trim().toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
function getRequestHost(event) {
|
|
14
|
+
const runtimeConfig = useRuntimeConfig(event);
|
|
15
|
+
const proxyConfig = normalizeProxyTrustConfig(runtimeConfig.security?.proxy);
|
|
16
|
+
return getProxyRequestHost(event, proxyConfig);
|
|
17
|
+
}
|
|
18
|
+
function isSafeMethod(method) {
|
|
19
|
+
const normalized = (method ?? "GET").toUpperCase();
|
|
20
|
+
return normalized === "GET" || normalized === "HEAD" || normalized === "OPTIONS";
|
|
21
|
+
}
|
|
22
|
+
export function enforceMutationOriginPolicy(event) {
|
|
23
|
+
if (isSafeMethod(event.method)) return;
|
|
24
|
+
const originHeader = getRequestHeader(event, "origin") ?? getRequestHeader(event, "referer");
|
|
25
|
+
if (!originHeader) return;
|
|
26
|
+
const originHost = getOriginHost(originHeader);
|
|
27
|
+
if (!originHost) {
|
|
28
|
+
throw createError({ statusCode: 403, statusMessage: "Forbidden" });
|
|
29
|
+
}
|
|
30
|
+
const requestHost = getRequestHost(event);
|
|
31
|
+
if (!requestHost) {
|
|
32
|
+
throw createError({ statusCode: 403, statusMessage: "Forbidden" });
|
|
33
|
+
}
|
|
34
|
+
if (normalizeHost(originHost) !== normalizeHost(requestHost)) {
|
|
35
|
+
throw createError({ statusCode: 403, statusMessage: "Forbidden" });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type H3Event } from 'h3';
|
|
2
|
+
import type { BasicAuthAccount, BasicAuthSession } from '../db/schema.js';
|
|
3
|
+
export interface CreateAccountInput {
|
|
4
|
+
email: string;
|
|
5
|
+
passwordHash: string;
|
|
6
|
+
displayName?: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface SessionMetadata {
|
|
9
|
+
ipAddress?: string | null;
|
|
10
|
+
userAgent?: string | null;
|
|
11
|
+
}
|
|
12
|
+
export interface CreateSessionInput {
|
|
13
|
+
accountId: string;
|
|
14
|
+
sessionId: string;
|
|
15
|
+
refreshTokenHash: string;
|
|
16
|
+
expiresAtMs: number;
|
|
17
|
+
rotatedFromSessionId?: string | null;
|
|
18
|
+
metadata?: SessionMetadata;
|
|
19
|
+
}
|
|
20
|
+
export interface RotationInput {
|
|
21
|
+
currentSessionId: string;
|
|
22
|
+
currentRefreshHash: string;
|
|
23
|
+
newSessionId: string;
|
|
24
|
+
newRefreshHash: string;
|
|
25
|
+
newExpiresAtMs: number;
|
|
26
|
+
metadata?: SessionMetadata;
|
|
27
|
+
}
|
|
28
|
+
export interface RotationResult {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
reason?: 'not_found' | 'expired' | 'revoked' | 'replayed';
|
|
31
|
+
accountId?: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function normalizeEmail(email: string): string;
|
|
35
|
+
export declare function getSessionMetadataFromEvent(event: H3Event): SessionMetadata;
|
|
36
|
+
export declare function findAccountByEmail(email: string): BasicAuthAccount | null;
|
|
37
|
+
export declare function findAccountById(id: string): BasicAuthAccount | null;
|
|
38
|
+
export declare function createAccount(input: CreateAccountInput): BasicAuthAccount;
|
|
39
|
+
export declare function ensureBootstrapAccount(input: CreateAccountInput): BasicAuthAccount;
|
|
40
|
+
export declare function createAuthSession(input: CreateSessionInput): BasicAuthSession;
|
|
41
|
+
export declare function findSessionById(sessionId: string): BasicAuthSession | null;
|
|
42
|
+
export declare function revokeSessionById(sessionId: string, revokedAtMs?: number): void;
|
|
43
|
+
export declare function revokeAllSessionsForAccount(accountId: string, revokedAtMs?: number): void;
|
|
44
|
+
export declare function rotateSession(input: RotationInput): RotationResult;
|
|
45
|
+
export declare function updatePasswordAndRevokeSessions(accountId: string, newPasswordHash: string): void;
|
|
46
|
+
export declare function isSessionUsable(session: BasicAuthSession | null): boolean;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { getRequestHeader } from "h3";
|
|
3
|
+
import { getBasicAuthDb } from "../db/client.js";
|
|
4
|
+
import {
|
|
5
|
+
getClientIp as getProxyClientIp,
|
|
6
|
+
normalizeProxyTrustConfig
|
|
7
|
+
} from "./request-identity.js";
|
|
8
|
+
export function normalizeEmail(email) {
|
|
9
|
+
return email.trim().toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
function toHexBuffer(value) {
|
|
12
|
+
return Buffer.from(value, "hex");
|
|
13
|
+
}
|
|
14
|
+
function safeHexEquals(left, right) {
|
|
15
|
+
if (left.length !== right.length) return false;
|
|
16
|
+
const leftBuffer = toHexBuffer(left);
|
|
17
|
+
const rightBuffer = toHexBuffer(right);
|
|
18
|
+
if (leftBuffer.length !== rightBuffer.length) return false;
|
|
19
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
20
|
+
}
|
|
21
|
+
export function getSessionMetadataFromEvent(event) {
|
|
22
|
+
const proxyConfig = normalizeProxyTrustConfig(useRuntimeConfig(event).security?.proxy);
|
|
23
|
+
return {
|
|
24
|
+
ipAddress: getProxyClientIp(event, proxyConfig) ?? event.node.req.socket.remoteAddress ?? null,
|
|
25
|
+
userAgent: getRequestHeader(event, "user-agent") ?? null
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function findAccountByEmail(email) {
|
|
29
|
+
const db = getBasicAuthDb();
|
|
30
|
+
const row = db.prepare("SELECT * FROM basic_auth_accounts WHERE email = ? LIMIT 1").get(normalizeEmail(email));
|
|
31
|
+
return row ?? null;
|
|
32
|
+
}
|
|
33
|
+
export function findAccountById(id) {
|
|
34
|
+
const db = getBasicAuthDb();
|
|
35
|
+
const row = db.prepare("SELECT * FROM basic_auth_accounts WHERE id = ? LIMIT 1").get(id);
|
|
36
|
+
return row ?? null;
|
|
37
|
+
}
|
|
38
|
+
export function createAccount(input) {
|
|
39
|
+
const db = getBasicAuthDb();
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const account = {
|
|
42
|
+
id: randomUUID(),
|
|
43
|
+
email: normalizeEmail(input.email),
|
|
44
|
+
password_hash: input.passwordHash,
|
|
45
|
+
display_name: input.displayName ?? null,
|
|
46
|
+
token_version: 0,
|
|
47
|
+
created_at: now,
|
|
48
|
+
updated_at: now
|
|
49
|
+
};
|
|
50
|
+
db.prepare(
|
|
51
|
+
`INSERT INTO basic_auth_accounts (
|
|
52
|
+
id, email, password_hash, display_name, token_version, created_at, updated_at
|
|
53
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
54
|
+
).run(
|
|
55
|
+
account.id,
|
|
56
|
+
account.email,
|
|
57
|
+
account.password_hash,
|
|
58
|
+
account.display_name,
|
|
59
|
+
account.token_version,
|
|
60
|
+
account.created_at,
|
|
61
|
+
account.updated_at
|
|
62
|
+
);
|
|
63
|
+
return account;
|
|
64
|
+
}
|
|
65
|
+
export function ensureBootstrapAccount(input) {
|
|
66
|
+
const existing = findAccountByEmail(input.email);
|
|
67
|
+
if (existing) return existing;
|
|
68
|
+
return createAccount(input);
|
|
69
|
+
}
|
|
70
|
+
export function createAuthSession(input) {
|
|
71
|
+
const db = getBasicAuthDb();
|
|
72
|
+
const createdAt = Date.now();
|
|
73
|
+
const row = {
|
|
74
|
+
id: input.sessionId,
|
|
75
|
+
account_id: input.accountId,
|
|
76
|
+
refresh_token_hash: input.refreshTokenHash,
|
|
77
|
+
expires_at: input.expiresAtMs,
|
|
78
|
+
revoked_at: null,
|
|
79
|
+
created_at: createdAt,
|
|
80
|
+
rotated_from_session_id: input.rotatedFromSessionId ?? null,
|
|
81
|
+
replaced_by_session_id: null,
|
|
82
|
+
ip_address: input.metadata?.ipAddress ?? null,
|
|
83
|
+
user_agent: input.metadata?.userAgent ?? null
|
|
84
|
+
};
|
|
85
|
+
db.prepare(
|
|
86
|
+
`INSERT INTO basic_auth_sessions (
|
|
87
|
+
id, account_id, refresh_token_hash, expires_at, revoked_at, created_at,
|
|
88
|
+
rotated_from_session_id, replaced_by_session_id, ip_address, user_agent
|
|
89
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
90
|
+
).run(
|
|
91
|
+
row.id,
|
|
92
|
+
row.account_id,
|
|
93
|
+
row.refresh_token_hash,
|
|
94
|
+
row.expires_at,
|
|
95
|
+
row.revoked_at,
|
|
96
|
+
row.created_at,
|
|
97
|
+
row.rotated_from_session_id,
|
|
98
|
+
row.replaced_by_session_id,
|
|
99
|
+
row.ip_address,
|
|
100
|
+
row.user_agent
|
|
101
|
+
);
|
|
102
|
+
return row;
|
|
103
|
+
}
|
|
104
|
+
export function findSessionById(sessionId) {
|
|
105
|
+
const db = getBasicAuthDb();
|
|
106
|
+
const row = db.prepare("SELECT * FROM basic_auth_sessions WHERE id = ? LIMIT 1").get(sessionId);
|
|
107
|
+
return row ?? null;
|
|
108
|
+
}
|
|
109
|
+
export function revokeSessionById(sessionId, revokedAtMs = Date.now()) {
|
|
110
|
+
const db = getBasicAuthDb();
|
|
111
|
+
db.prepare(
|
|
112
|
+
"UPDATE basic_auth_sessions SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?"
|
|
113
|
+
).run(revokedAtMs, sessionId);
|
|
114
|
+
}
|
|
115
|
+
export function revokeAllSessionsForAccount(accountId, revokedAtMs = Date.now()) {
|
|
116
|
+
const db = getBasicAuthDb();
|
|
117
|
+
db.prepare(
|
|
118
|
+
"UPDATE basic_auth_sessions SET revoked_at = COALESCE(revoked_at, ?) WHERE account_id = ?"
|
|
119
|
+
).run(revokedAtMs, accountId);
|
|
120
|
+
}
|
|
121
|
+
export function rotateSession(input) {
|
|
122
|
+
const db = getBasicAuthDb();
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const currentSession = findSessionById(input.currentSessionId);
|
|
125
|
+
if (!currentSession) {
|
|
126
|
+
return { ok: false, reason: "not_found" };
|
|
127
|
+
}
|
|
128
|
+
if (currentSession.expires_at <= now) {
|
|
129
|
+
revokeSessionById(currentSession.id, now);
|
|
130
|
+
return { ok: false, reason: "expired" };
|
|
131
|
+
}
|
|
132
|
+
const isSameTokenHash = safeHexEquals(
|
|
133
|
+
currentSession.refresh_token_hash,
|
|
134
|
+
input.currentRefreshHash
|
|
135
|
+
);
|
|
136
|
+
if (currentSession.revoked_at !== null) {
|
|
137
|
+
const replayedRotatedToken = currentSession.replaced_by_session_id !== null && isSameTokenHash;
|
|
138
|
+
if (replayedRotatedToken) {
|
|
139
|
+
revokeAllSessionsForAccount(currentSession.account_id, now);
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: "replayed"
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return { ok: false, reason: "revoked" };
|
|
146
|
+
}
|
|
147
|
+
if (!isSameTokenHash) {
|
|
148
|
+
revokeAllSessionsForAccount(currentSession.account_id, now);
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: "replayed"
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
db.transaction(() => {
|
|
155
|
+
createAuthSession({
|
|
156
|
+
accountId: currentSession.account_id,
|
|
157
|
+
sessionId: input.newSessionId,
|
|
158
|
+
refreshTokenHash: input.newRefreshHash,
|
|
159
|
+
expiresAtMs: input.newExpiresAtMs,
|
|
160
|
+
rotatedFromSessionId: currentSession.id,
|
|
161
|
+
metadata: input.metadata
|
|
162
|
+
});
|
|
163
|
+
db.prepare(
|
|
164
|
+
"UPDATE basic_auth_sessions SET revoked_at = ?, replaced_by_session_id = ? WHERE id = ?"
|
|
165
|
+
).run(now, input.newSessionId, currentSession.id);
|
|
166
|
+
})();
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
accountId: currentSession.account_id,
|
|
170
|
+
sessionId: input.newSessionId
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function updatePasswordAndRevokeSessions(accountId, newPasswordHash) {
|
|
174
|
+
const db = getBasicAuthDb();
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
db.transaction(() => {
|
|
177
|
+
db.prepare(
|
|
178
|
+
`UPDATE basic_auth_accounts
|
|
179
|
+
SET password_hash = ?, token_version = token_version + 1, updated_at = ?
|
|
180
|
+
WHERE id = ?`
|
|
181
|
+
).run(newPasswordHash, now, accountId);
|
|
182
|
+
revokeAllSessionsForAccount(accountId, now);
|
|
183
|
+
})();
|
|
184
|
+
}
|
|
185
|
+
export function isSessionUsable(session) {
|
|
186
|
+
if (!session) return false;
|
|
187
|
+
if (session.revoked_at !== null) return false;
|
|
188
|
+
if (session.expires_at <= Date.now()) return false;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { BASIC_AUTH_PROVIDER_ID } from "../../lib/constants.js";
|
|
2
|
+
import { registerAuthProvider } from "~~/server/auth/registry";
|
|
3
|
+
import { registerProviderAdminAdapter } from "~~/server/admin/providers/registry";
|
|
4
|
+
import { registerProviderTokenBroker } from "~~/server/auth/token-broker/registry";
|
|
5
|
+
import { basicAuthProvider } from "../auth/basic-auth-provider.js";
|
|
6
|
+
import { createBasicAuthTokenBroker } from "../token-broker/basic-auth-token-broker.js";
|
|
7
|
+
import {
|
|
8
|
+
BASIC_AUTH_INSECURE_DEV_ESCAPE_HATCH_ENV,
|
|
9
|
+
getBasicAuthConfig,
|
|
10
|
+
isBasicAuthInsecureDevEscapeHatchEnabled,
|
|
11
|
+
validateBasicAuthConfig
|
|
12
|
+
} from "../lib/config.js";
|
|
13
|
+
import { hashPassword } from "../lib/password.js";
|
|
14
|
+
import { ensureBootstrapAccount, findAccountByEmail } from "../lib/session-store.js";
|
|
15
|
+
import { basicAuthAdminAdapter } from "../admin/adapters/auth-basic-auth.js";
|
|
16
|
+
async function ensureBootstrapUserIfConfigured() {
|
|
17
|
+
const config = getBasicAuthConfig();
|
|
18
|
+
const email = config.bootstrapEmail?.trim();
|
|
19
|
+
const password = config.bootstrapPassword;
|
|
20
|
+
if (!email || !password) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (findAccountByEmail(email)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const passwordHash = await hashPassword(password);
|
|
27
|
+
ensureBootstrapAccount({
|
|
28
|
+
email,
|
|
29
|
+
passwordHash,
|
|
30
|
+
displayName: email
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export default defineNitroPlugin(async () => {
|
|
34
|
+
const runtimeConfig = useRuntimeConfig();
|
|
35
|
+
const diagnostics = validateBasicAuthConfig(runtimeConfig);
|
|
36
|
+
for (const warning of diagnostics.warnings) {
|
|
37
|
+
console.warn(`[basic-auth] ${warning}`);
|
|
38
|
+
}
|
|
39
|
+
if (!diagnostics.config.enabled) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (diagnostics.config.providerId !== BASIC_AUTH_PROVIDER_ID) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!diagnostics.isValid) {
|
|
46
|
+
const message = `${diagnostics.errors.join(" ")} Install/configure basic-auth provider env values and restart.`;
|
|
47
|
+
if (isBasicAuthInsecureDevEscapeHatchEnabled() && !diagnostics.config.strict) {
|
|
48
|
+
console.warn(
|
|
49
|
+
`[basic-auth] ${message} Startup continues because ${BASIC_AUTH_INSECURE_DEV_ESCAPE_HATCH_ENV}=true (development only).`
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
throw new Error(message);
|
|
54
|
+
}
|
|
55
|
+
await ensureBootstrapUserIfConfigured();
|
|
56
|
+
registerAuthProvider({
|
|
57
|
+
id: BASIC_AUTH_PROVIDER_ID,
|
|
58
|
+
order: 100,
|
|
59
|
+
create: () => basicAuthProvider
|
|
60
|
+
});
|
|
61
|
+
registerProviderTokenBroker(BASIC_AUTH_PROVIDER_ID, createBasicAuthTokenBroker);
|
|
62
|
+
registerProviderAdminAdapter(basicAuthAdminAdapter);
|
|
63
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { ProviderTokenBroker, ProviderTokenRequest } from '~~/server/auth/token-broker/types';
|
|
3
|
+
export declare class BasicAuthTokenBroker implements ProviderTokenBroker {
|
|
4
|
+
getProviderToken(_event: H3Event, _req: ProviderTokenRequest): Promise<string | null>;
|
|
5
|
+
}
|
|
6
|
+
export declare function createBasicAuthTokenBroker(): ProviderTokenBroker;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BasicAuthTokenBroker, createBasicAuthTokenBroker } from './basic-auth-token-broker.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BasicAuthTokenBroker, createBasicAuthTokenBroker } from "./basic-auth-token-broker.js";
|