or3-provider-basic-auth 0.0.1 → 0.0.3
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 +3 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/BasicAuthLockPage.client.d.vue.ts +2 -0
- package/dist/runtime/components/BasicAuthLockPage.client.vue +55 -0
- package/dist/runtime/components/BasicAuthLockPage.client.vue.d.ts +2 -0
- package/dist/runtime/components/BasicAuthRegisterModal.client.vue +42 -4
- package/dist/runtime/components/SidebarAuthButtonBasic.client.vue +2 -20
- package/dist/runtime/lib/constants.d.ts +13 -1
- package/dist/runtime/lib/constants.js +3 -1
- package/dist/runtime/lib/silent-refresh.client.d.ts +10 -0
- package/dist/runtime/lib/silent-refresh.client.js +31 -0
- package/dist/runtime/plugins/auth-status.client.js +4 -11
- package/dist/runtime/plugins/basic-auth-ui.client.js +35 -0
- package/dist/runtime/server/api/basic-auth/_helpers.js +21 -0
- package/dist/runtime/server/api/basic-auth/refresh.post.d.ts +6 -2
- package/dist/runtime/server/api/basic-auth/refresh.post.js +3 -56
- package/dist/runtime/server/api/basic-auth/register.post.js +57 -25
- package/dist/runtime/server/auth/basic-auth-provider.js +53 -23
- package/dist/runtime/server/db/client.js +9 -0
- package/dist/runtime/server/db/schema.d.ts +4 -0
- package/dist/runtime/server/lib/config.d.ts +1 -0
- package/dist/runtime/server/lib/config.js +6 -1
- package/dist/runtime/server/lib/cookies.js +18 -6
- package/dist/runtime/server/lib/refresh-auth-session.d.ts +23 -0
- package/dist/runtime/server/lib/refresh-auth-session.js +83 -0
- package/dist/runtime/server/lib/rotation-grace.d.ts +4 -0
- package/dist/runtime/server/lib/rotation-grace.js +26 -0
- package/dist/runtime/server/lib/session-store.d.ts +27 -6
- package/dist/runtime/server/lib/session-store.js +73 -9
- package/dist/runtime/server/plugins/register.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,6 +56,9 @@ Strict-mode behavior:
|
|
|
56
56
|
|
|
57
57
|
- Credentials/session state is stored in a provider-owned SQLite DB.
|
|
58
58
|
- Canonical OR3 users/workspaces are still resolved by the selected `AuthWorkspaceStore`.
|
|
59
|
+
- In `invite_only` mode, signed-token, expiry, persisted invite state/token hash, and normalized-email validation runs before Basic Auth creates an account or session.
|
|
60
|
+
- The selected `AuthWorkspaceStore` must support atomic invite provisioning; internal user/auth mapping, membership, and invite consumption are committed together by that provider.
|
|
61
|
+
- Basic Auth account and initial refresh-session rows are created in one local SQLite transaction.
|
|
59
62
|
- Access JWTs are short-lived and validated by `basicAuthProvider.getSession(event)`.
|
|
60
63
|
- Refresh tokens are rotated and hashed at rest; replay attempts revoke active sessions.
|
|
61
64
|
|
package/dist/module.json
CHANGED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-3">
|
|
3
|
+
<UButton
|
|
4
|
+
block
|
|
5
|
+
size="xl"
|
|
6
|
+
color="primary"
|
|
7
|
+
:icon="loginIcon"
|
|
8
|
+
class="theme-shadow"
|
|
9
|
+
@click="signInModalOpen = true"
|
|
10
|
+
>
|
|
11
|
+
Sign in
|
|
12
|
+
</UButton>
|
|
13
|
+
|
|
14
|
+
<p class="text-sm text-[var(--md-on-surface-variant)] text-center">
|
|
15
|
+
Use your email and password to continue.
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<BasicAuthSignInModal
|
|
19
|
+
v-model="signInModalOpen"
|
|
20
|
+
@signed-in="handleSignedIn"
|
|
21
|
+
@open-register="openRegisterFromSignIn"
|
|
22
|
+
/>
|
|
23
|
+
|
|
24
|
+
<BasicAuthRegisterModal
|
|
25
|
+
v-model="registerModalOpen"
|
|
26
|
+
@registered="handleRegistered"
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script setup>
|
|
32
|
+
import { nextTick, ref } from "vue";
|
|
33
|
+
import BasicAuthRegisterModal from "./BasicAuthRegisterModal.client.vue";
|
|
34
|
+
import BasicAuthSignInModal from "./BasicAuthSignInModal.client.vue";
|
|
35
|
+
const loginIcon = "i-lucide-log-in";
|
|
36
|
+
const signInModalOpen = ref(false);
|
|
37
|
+
const registerModalOpen = ref(false);
|
|
38
|
+
function notifyAuthSessionChanged() {
|
|
39
|
+
if (typeof window === "undefined") return;
|
|
40
|
+
window.dispatchEvent(new CustomEvent("or3:auth-session-changed"));
|
|
41
|
+
}
|
|
42
|
+
function handleSignedIn() {
|
|
43
|
+
notifyAuthSessionChanged();
|
|
44
|
+
}
|
|
45
|
+
function handleRegistered() {
|
|
46
|
+
notifyAuthSessionChanged();
|
|
47
|
+
}
|
|
48
|
+
async function openRegisterFromSignIn() {
|
|
49
|
+
signInModalOpen.value = false;
|
|
50
|
+
await nextTick();
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
registerModalOpen.value = true;
|
|
53
|
+
}, 0);
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -43,12 +43,18 @@
|
|
|
43
43
|
/>
|
|
44
44
|
</UFormField>
|
|
45
45
|
|
|
46
|
-
<UFormField
|
|
46
|
+
<UFormField
|
|
47
|
+
label="Password"
|
|
48
|
+
name="password"
|
|
49
|
+
hint="At least 8 characters"
|
|
50
|
+
>
|
|
47
51
|
<UInput
|
|
48
52
|
v-model="state.password"
|
|
49
53
|
type="password"
|
|
50
54
|
placeholder="••••••••"
|
|
51
55
|
autocomplete="new-password"
|
|
56
|
+
minlength="8"
|
|
57
|
+
maxlength="512"
|
|
52
58
|
required
|
|
53
59
|
class="w-full"
|
|
54
60
|
/>
|
|
@@ -60,6 +66,8 @@
|
|
|
60
66
|
type="password"
|
|
61
67
|
placeholder="••••••••"
|
|
62
68
|
autocomplete="new-password"
|
|
69
|
+
minlength="8"
|
|
70
|
+
maxlength="512"
|
|
63
71
|
required
|
|
64
72
|
class="w-full"
|
|
65
73
|
/>
|
|
@@ -130,9 +138,36 @@ function close() {
|
|
|
130
138
|
errorMessage.value = "";
|
|
131
139
|
emit("update:modelValue", false);
|
|
132
140
|
}
|
|
141
|
+
function validateClientSide() {
|
|
142
|
+
const email = state.email.trim();
|
|
143
|
+
if (!email || !email.includes("@")) {
|
|
144
|
+
return "Enter a valid email address.";
|
|
145
|
+
}
|
|
146
|
+
if (state.password.length < 8) {
|
|
147
|
+
return "Password must be at least 8 characters.";
|
|
148
|
+
}
|
|
149
|
+
if (state.password.length > 512) {
|
|
150
|
+
return "Password is too long.";
|
|
151
|
+
}
|
|
152
|
+
if (state.password !== state.confirmPassword) {
|
|
153
|
+
return "Passwords must match.";
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function readFetchErrorMessage(error) {
|
|
158
|
+
if (typeof error !== "object" || error === null) return null;
|
|
159
|
+
const data = error.data;
|
|
160
|
+
return data?.statusMessage || data?.message || null;
|
|
161
|
+
}
|
|
133
162
|
async function onSubmit() {
|
|
134
163
|
pending.value = true;
|
|
135
164
|
errorMessage.value = "";
|
|
165
|
+
const clientError = validateClientSide();
|
|
166
|
+
if (clientError) {
|
|
167
|
+
errorMessage.value = clientError;
|
|
168
|
+
pending.value = false;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
136
171
|
try {
|
|
137
172
|
await $fetch("/api/basic-auth/register", {
|
|
138
173
|
method: "POST",
|
|
@@ -148,14 +183,17 @@ async function onSubmit() {
|
|
|
148
183
|
close();
|
|
149
184
|
} catch (error) {
|
|
150
185
|
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
|
|
186
|
+
const serverMessage = readFetchErrorMessage(error);
|
|
151
187
|
if (statusCode === 400) {
|
|
152
|
-
errorMessage.value = "Please check your
|
|
188
|
+
errorMessage.value = serverMessage || "Please check your email and password (min 8 characters).";
|
|
153
189
|
} else if (statusCode === 403) {
|
|
154
|
-
errorMessage.value = "Registration is restricted for this instance.";
|
|
190
|
+
errorMessage.value = serverMessage || "Registration is restricted for this instance.";
|
|
191
|
+
} else if (statusCode === 401) {
|
|
192
|
+
errorMessage.value = "Email is already in use.";
|
|
155
193
|
} else if (statusCode === 409) {
|
|
156
194
|
errorMessage.value = "Email is already in use.";
|
|
157
195
|
} else {
|
|
158
|
-
errorMessage.value = "Unable to register right now. Please try again.";
|
|
196
|
+
errorMessage.value = serverMessage || "Unable to register right now. Please try again.";
|
|
159
197
|
}
|
|
160
198
|
} finally {
|
|
161
199
|
pending.value = false;
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
<script setup>
|
|
48
48
|
import { computed, nextTick, onMounted, ref } from "vue";
|
|
49
49
|
import { BASIC_AUTH_PROVIDER_ID } from "../lib/constants";
|
|
50
|
+
import { silentRefreshOnce } from "../lib/silent-refresh.client";
|
|
50
51
|
import BasicAuthSignInModal from "./BasicAuthSignInModal.client.vue";
|
|
51
52
|
import BasicAuthRegisterModal from "./BasicAuthRegisterModal.client.vue";
|
|
52
53
|
import BasicAuthUserMenu from "./BasicAuthUserMenu.client.vue";
|
|
@@ -55,31 +56,12 @@ const runtimeConfig = useRuntimeConfig();
|
|
|
55
56
|
const signInModalOpen = ref(false);
|
|
56
57
|
const registerModalOpen = ref(false);
|
|
57
58
|
const changePasswordModalOpen = ref(false);
|
|
58
|
-
let refreshRequest = null;
|
|
59
59
|
const session = ref(null);
|
|
60
60
|
async function fetchSessionPayload() {
|
|
61
61
|
return await $fetch("/api/auth/session", {
|
|
62
62
|
cache: "no-store"
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
|
-
async function tryRefreshTokens() {
|
|
66
|
-
if (refreshRequest) {
|
|
67
|
-
return refreshRequest;
|
|
68
|
-
}
|
|
69
|
-
refreshRequest = (async () => {
|
|
70
|
-
try {
|
|
71
|
-
const response = await $fetch("/api/basic-auth/refresh?silent=1", {
|
|
72
|
-
method: "POST"
|
|
73
|
-
});
|
|
74
|
-
return response?.ok === true;
|
|
75
|
-
} catch {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
})().finally(() => {
|
|
79
|
-
refreshRequest = null;
|
|
80
|
-
});
|
|
81
|
-
return refreshRequest;
|
|
82
|
-
}
|
|
83
65
|
async function refreshSession(options = {}) {
|
|
84
66
|
const allowSilentRefresh = options.allowSilentRefresh ?? true;
|
|
85
67
|
try {
|
|
@@ -92,7 +74,7 @@ async function refreshSession(options = {}) {
|
|
|
92
74
|
if (session.value || !allowSilentRefresh) {
|
|
93
75
|
return;
|
|
94
76
|
}
|
|
95
|
-
const didRefresh = await
|
|
77
|
+
const didRefresh = await silentRefreshOnce();
|
|
96
78
|
if (!didRefresh) {
|
|
97
79
|
return;
|
|
98
80
|
}
|
|
@@ -4,4 +4,16 @@ export declare const REFRESH_COOKIE_NAME = "or3_refresh";
|
|
|
4
4
|
export declare const DEFAULT_ACCESS_TTL_SECONDS = 900;
|
|
5
5
|
export declare const DEFAULT_REFRESH_TTL_SECONDS: number;
|
|
6
6
|
export declare const ACCESS_COOKIE_PATH = "/";
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Refresh cookies are scoped to `/api` so session resolution (`/api/auth/session`)
|
|
9
|
+
* can transparently rotate expired access tokens. Previously `/api/basic-auth`
|
|
10
|
+
* prevented that recovery path.
|
|
11
|
+
*/
|
|
12
|
+
export declare const REFRESH_COOKIE_PATH = "/api";
|
|
13
|
+
/** Legacy path from earlier builds; cleared on set/clear for migration. */
|
|
14
|
+
export declare const LEGACY_REFRESH_COOKIE_PATH = "/api/basic-auth";
|
|
15
|
+
/**
|
|
16
|
+
* Concurrent refresh requests within this window reuse the successor tokens
|
|
17
|
+
* instead of being treated as replay attacks (common under HMR remounts).
|
|
18
|
+
*/
|
|
19
|
+
export declare const REFRESH_ROTATION_GRACE_MS = 30000;
|
|
@@ -4,4 +4,6 @@ export const REFRESH_COOKIE_NAME = "or3_refresh";
|
|
|
4
4
|
export const DEFAULT_ACCESS_TTL_SECONDS = 900;
|
|
5
5
|
export const DEFAULT_REFRESH_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
6
6
|
export const ACCESS_COOKIE_PATH = "/";
|
|
7
|
-
export const REFRESH_COOKIE_PATH = "/api
|
|
7
|
+
export const REFRESH_COOKIE_PATH = "/api";
|
|
8
|
+
export const LEGACY_REFRESH_COOKIE_PATH = "/api/basic-auth";
|
|
9
|
+
export const REFRESH_ROTATION_GRACE_MS = 3e4;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide single-flight for silent refresh.
|
|
3
|
+
*
|
|
4
|
+
* HMR remounts and auth-status checks can otherwise POST /refresh concurrently,
|
|
5
|
+
* which races token rotation. All callers share one in-flight promise.
|
|
6
|
+
*/
|
|
7
|
+
/** Deduplicated silent refresh — safe to call from many HMR remount paths. */
|
|
8
|
+
export declare function silentRefreshOnce(): Promise<boolean>;
|
|
9
|
+
/** Test helper — clears the shared in-flight promise. */
|
|
10
|
+
export declare function resetSilentRefreshForTests(): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const GLOBAL_KEY = "__or3_basic_auth_silent_refresh__";
|
|
2
|
+
function getRegistry() {
|
|
3
|
+
const globalAny = globalThis;
|
|
4
|
+
if (!globalAny[GLOBAL_KEY]) {
|
|
5
|
+
globalAny[GLOBAL_KEY] = { inflight: null };
|
|
6
|
+
}
|
|
7
|
+
return globalAny[GLOBAL_KEY];
|
|
8
|
+
}
|
|
9
|
+
async function postSilentRefresh() {
|
|
10
|
+
try {
|
|
11
|
+
const response = await $fetch("/api/basic-auth/refresh?silent=1", {
|
|
12
|
+
method: "POST"
|
|
13
|
+
});
|
|
14
|
+
return response?.ok === true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function silentRefreshOnce() {
|
|
20
|
+
const registry = getRegistry();
|
|
21
|
+
if (registry.inflight) {
|
|
22
|
+
return registry.inflight;
|
|
23
|
+
}
|
|
24
|
+
registry.inflight = postSilentRefresh().finally(() => {
|
|
25
|
+
registry.inflight = null;
|
|
26
|
+
});
|
|
27
|
+
return registry.inflight;
|
|
28
|
+
}
|
|
29
|
+
export function resetSilentRefreshForTests() {
|
|
30
|
+
getRegistry().inflight = null;
|
|
31
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { registerClientAuthStatusResolver } from "~/composables/auth/useClientAuthStatus.client";
|
|
2
|
+
import { registerClientSessionRecovery } from "~/composables/auth/useClientSessionRecovery";
|
|
3
|
+
import { silentRefreshOnce } from "../lib/silent-refresh.client.js";
|
|
2
4
|
async function fetchSession() {
|
|
3
5
|
try {
|
|
4
6
|
return await $fetch("/api/auth/session", {
|
|
@@ -8,25 +10,16 @@ async function fetchSession() {
|
|
|
8
10
|
return null;
|
|
9
11
|
}
|
|
10
12
|
}
|
|
11
|
-
async function trySilentRefresh() {
|
|
12
|
-
try {
|
|
13
|
-
const res = await $fetch("/api/basic-auth/refresh?silent=1", {
|
|
14
|
-
method: "POST"
|
|
15
|
-
});
|
|
16
|
-
return res?.ok === true;
|
|
17
|
-
} catch {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
13
|
export default defineNuxtPlugin(() => {
|
|
22
14
|
if (import.meta.server) return;
|
|
15
|
+
registerClientSessionRecovery(async () => silentRefreshOnce());
|
|
23
16
|
registerClientAuthStatusResolver(async () => {
|
|
24
17
|
const first = await fetchSession();
|
|
25
18
|
const firstSession = first?.session;
|
|
26
19
|
if (firstSession?.authenticated === true && firstSession.provider === "basic-auth") {
|
|
27
20
|
return { ready: true, authenticated: true };
|
|
28
21
|
}
|
|
29
|
-
const refreshed = await
|
|
22
|
+
const refreshed = await silentRefreshOnce();
|
|
30
23
|
if (!refreshed) {
|
|
31
24
|
return { ready: true, authenticated: false };
|
|
32
25
|
}
|
|
@@ -29,6 +29,35 @@ function enqueueAuthUiAdapter(component) {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
function tryRegisterLockPageAdapter(component) {
|
|
33
|
+
const nuxtApp = useNuxtApp();
|
|
34
|
+
if (typeof nuxtApp.$registerLockPageAdapter !== "function") {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
nuxtApp.$registerLockPageAdapter({
|
|
38
|
+
id: BASIC_AUTH_PROVIDER_ID,
|
|
39
|
+
component
|
|
40
|
+
});
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function enqueueLockPageAdapter(component) {
|
|
44
|
+
const payload = {
|
|
45
|
+
id: BASIC_AUTH_PROVIDER_ID,
|
|
46
|
+
component
|
|
47
|
+
};
|
|
48
|
+
const globalState = globalThis;
|
|
49
|
+
if (!Array.isArray(globalState.__or3LockPageAdapterQueue__)) {
|
|
50
|
+
globalState.__or3LockPageAdapterQueue__ = [];
|
|
51
|
+
}
|
|
52
|
+
globalState.__or3LockPageAdapterQueue__.push(payload);
|
|
53
|
+
if (typeof window !== "undefined") {
|
|
54
|
+
window.dispatchEvent(
|
|
55
|
+
new CustomEvent("or3:lock-page-adapter-register", {
|
|
56
|
+
detail: payload
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
32
61
|
export default defineNuxtPlugin(async () => {
|
|
33
62
|
if (import.meta.server) return;
|
|
34
63
|
const runtimeConfig = useRuntimeConfig();
|
|
@@ -43,4 +72,10 @@ export default defineNuxtPlugin(async () => {
|
|
|
43
72
|
enqueueAuthUiAdapter(componentModule.default);
|
|
44
73
|
}
|
|
45
74
|
}
|
|
75
|
+
const lockPageModule = await import("../components/BasicAuthLockPage.client.vue");
|
|
76
|
+
if (lockPageModule.default) {
|
|
77
|
+
if (!tryRegisterLockPageAdapter(lockPageModule.default)) {
|
|
78
|
+
enqueueLockPageAdapter(lockPageModule.default);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
46
81
|
});
|
|
@@ -14,6 +14,27 @@ export async function parseBodyWithSchema(event, schema) {
|
|
|
14
14
|
const body = await readBody(event);
|
|
15
15
|
const result = schema.safeParse(body);
|
|
16
16
|
if (!result.success) {
|
|
17
|
+
const firstIssue = result.error.issues[0];
|
|
18
|
+
const path = firstIssue?.path?.join(".") || "input";
|
|
19
|
+
const detail = firstIssue?.message || "Invalid request";
|
|
20
|
+
if (path === "password" || path === "confirmPassword") {
|
|
21
|
+
throw createError({
|
|
22
|
+
statusCode: 400,
|
|
23
|
+
statusMessage: "Password must be at least 8 characters."
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (detail === "Passwords must match") {
|
|
27
|
+
throw createError({
|
|
28
|
+
statusCode: 400,
|
|
29
|
+
statusMessage: "Passwords must match."
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (path === "email") {
|
|
33
|
+
throw createError({
|
|
34
|
+
statusCode: 400,
|
|
35
|
+
statusMessage: "Enter a valid email address."
|
|
36
|
+
});
|
|
37
|
+
}
|
|
17
38
|
throw createInvalidRequestError();
|
|
18
39
|
}
|
|
19
40
|
return result.data;
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { type H3Event } from 'h3';
|
|
2
2
|
export declare function handleRefresh(event: H3Event): Promise<{
|
|
3
|
-
ok:
|
|
3
|
+
ok: false;
|
|
4
|
+
} | {
|
|
5
|
+
ok: true;
|
|
4
6
|
}>;
|
|
5
7
|
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
|
|
6
|
-
ok:
|
|
8
|
+
ok: false;
|
|
9
|
+
} | {
|
|
10
|
+
ok: true;
|
|
7
11
|
}>>;
|
|
8
12
|
export default _default;
|
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { defineEventHandler, getQuery } from "h3";
|
|
3
|
-
import { clearAuthCookies, setAccessCookie, setRefreshCookie } from "../../lib/cookies.js";
|
|
4
|
-
import { getBasicAuthConfig } from "../../lib/config.js";
|
|
5
2
|
import { createSessionExpiredError } from "../../lib/errors.js";
|
|
6
|
-
import {
|
|
7
|
-
getRefreshTokenFromEvent,
|
|
8
|
-
hashRefreshToken,
|
|
9
|
-
signAccessToken,
|
|
10
|
-
signRefreshToken,
|
|
11
|
-
verifyRefreshToken
|
|
12
|
-
} from "../../lib/jwt.js";
|
|
3
|
+
import { refreshAuthSessionAndSetCookies } from "../../lib/refresh-auth-session.js";
|
|
13
4
|
import { enforceBasicAuthRateLimit } from "../../lib/rate-limit.js";
|
|
14
|
-
import {
|
|
15
|
-
findAccountById,
|
|
16
|
-
rotateSession,
|
|
17
|
-
getSessionMetadataFromEvent
|
|
18
|
-
} from "../../lib/session-store.js";
|
|
19
5
|
import { enforceMutationOriginPolicy } from "../../lib/request-security.js";
|
|
20
6
|
import { assertBasicAuthReady, noStore } from "./_helpers.js";
|
|
21
7
|
export async function handleRefresh(event) {
|
|
@@ -25,52 +11,13 @@ export async function handleRefresh(event) {
|
|
|
25
11
|
enforceBasicAuthRateLimit(event, "basic-auth:refresh");
|
|
26
12
|
const query = getQuery(event);
|
|
27
13
|
const silent = query.silent === "1" || query.silent === "true";
|
|
28
|
-
const
|
|
29
|
-
|
|
14
|
+
const result = await refreshAuthSessionAndSetCookies(event, { clearOnFailure: true });
|
|
15
|
+
if (!result.ok) {
|
|
30
16
|
if (silent) {
|
|
31
17
|
return { ok: false };
|
|
32
18
|
}
|
|
33
19
|
throw createSessionExpiredError();
|
|
34
|
-
};
|
|
35
|
-
const refreshToken = getRefreshTokenFromEvent(event);
|
|
36
|
-
if (!refreshToken) {
|
|
37
|
-
return failExpired();
|
|
38
20
|
}
|
|
39
|
-
const claims = await verifyRefreshToken(refreshToken);
|
|
40
|
-
if (!claims) {
|
|
41
|
-
return failExpired();
|
|
42
|
-
}
|
|
43
|
-
const account = findAccountById(claims.sub);
|
|
44
|
-
if (!account || account.token_version !== claims.ver) {
|
|
45
|
-
return failExpired();
|
|
46
|
-
}
|
|
47
|
-
const config = getBasicAuthConfig();
|
|
48
|
-
const newSessionId = randomUUID();
|
|
49
|
-
const newRefreshToken = await signRefreshToken({
|
|
50
|
-
sub: account.id,
|
|
51
|
-
sid: newSessionId,
|
|
52
|
-
ver: account.token_version
|
|
53
|
-
});
|
|
54
|
-
const rotation = rotateSession({
|
|
55
|
-
currentSessionId: claims.sid,
|
|
56
|
-
currentRefreshHash: hashRefreshToken(refreshToken),
|
|
57
|
-
newSessionId,
|
|
58
|
-
newRefreshHash: hashRefreshToken(newRefreshToken),
|
|
59
|
-
newExpiresAtMs: Date.now() + config.refreshTtlSeconds * 1e3,
|
|
60
|
-
metadata: getSessionMetadataFromEvent(event)
|
|
61
|
-
});
|
|
62
|
-
if (!rotation.ok || rotation.accountId !== account.id) {
|
|
63
|
-
return failExpired();
|
|
64
|
-
}
|
|
65
|
-
const accessToken = await signAccessToken({
|
|
66
|
-
sub: account.id,
|
|
67
|
-
sid: newSessionId,
|
|
68
|
-
ver: account.token_version,
|
|
69
|
-
email: account.email,
|
|
70
|
-
display_name: account.display_name
|
|
71
|
-
});
|
|
72
|
-
setAccessCookie(event, accessToken, config.accessTtlSeconds);
|
|
73
|
-
setRefreshCookie(event, newRefreshToken, config.refreshTtlSeconds);
|
|
74
21
|
return { ok: true };
|
|
75
22
|
}
|
|
76
23
|
export default defineEventHandler(async (event) => {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createError, defineEventHandler, setCookie } from "h3";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { getAuthWorkspaceStore } from "~~/server/auth/store/registry";
|
|
5
|
+
import {
|
|
6
|
+
resolveRegistrationMode,
|
|
7
|
+
validateInviteRegistration
|
|
8
|
+
} from "~~/server/auth/registration";
|
|
4
9
|
import { clearAuthCookies, setAccessCookie, setRefreshCookie } from "../../lib/cookies.js";
|
|
5
10
|
import { getBasicAuthConfig } from "../../lib/config.js";
|
|
6
11
|
import {
|
|
@@ -11,8 +16,7 @@ import { signAccessToken, signRefreshToken, hashRefreshToken } from "../../lib/j
|
|
|
11
16
|
import { hashPassword } from "../../lib/password.js";
|
|
12
17
|
import { enforceBasicAuthRateLimit } from "../../lib/rate-limit.js";
|
|
13
18
|
import {
|
|
14
|
-
|
|
15
|
-
createAuthSession,
|
|
19
|
+
createAccountAndSession,
|
|
16
20
|
findAccountByEmail,
|
|
17
21
|
normalizeEmail,
|
|
18
22
|
getSessionMetadataFromEvent
|
|
@@ -35,6 +39,12 @@ function mapRegistrationErrorToHttp(reason) {
|
|
|
35
39
|
statusMessage: "Registration is currently disabled. Please contact an administrator."
|
|
36
40
|
});
|
|
37
41
|
}
|
|
42
|
+
if (reason === "invite_secret_missing" || reason === "invite_unsupported") {
|
|
43
|
+
throw createError({
|
|
44
|
+
statusCode: 503,
|
|
45
|
+
statusMessage: "Invite-only registration is temporarily unavailable."
|
|
46
|
+
});
|
|
47
|
+
}
|
|
38
48
|
throw createError({
|
|
39
49
|
statusCode: 403,
|
|
40
50
|
statusMessage: "A valid invite is required to register."
|
|
@@ -47,47 +57,69 @@ export async function handleRegister(event) {
|
|
|
47
57
|
enforceBasicAuthRateLimit(event, "basic-auth:register");
|
|
48
58
|
const body = await parseBodyWithSchema(event, registerSchema);
|
|
49
59
|
const email = normalizeEmail(body.email);
|
|
50
|
-
const existing = findAccountByEmail(email);
|
|
51
|
-
if (existing) {
|
|
52
|
-
throw createInvalidCredentialsError();
|
|
53
|
-
}
|
|
54
60
|
const runtimeConfig = useRuntimeConfig();
|
|
55
|
-
const mode =
|
|
61
|
+
const mode = resolveRegistrationMode(runtimeConfig);
|
|
56
62
|
if (mode === "disabled") {
|
|
57
63
|
mapRegistrationErrorToHttp("disabled");
|
|
58
64
|
}
|
|
59
65
|
if (mode === "invite_only" && !body.inviteToken?.trim()) {
|
|
60
66
|
mapRegistrationErrorToHttp("invite_required");
|
|
61
67
|
}
|
|
68
|
+
if (mode === "invite_only") {
|
|
69
|
+
const syncConfig = runtimeConfig;
|
|
70
|
+
const storeId = syncConfig.sync?.provider ?? syncConfig.public?.sync?.provider ?? "convex";
|
|
71
|
+
const store = getAuthWorkspaceStore(storeId);
|
|
72
|
+
if (!store) {
|
|
73
|
+
mapRegistrationErrorToHttp("invite_unsupported");
|
|
74
|
+
}
|
|
75
|
+
const decision = await validateInviteRegistration({
|
|
76
|
+
event,
|
|
77
|
+
store,
|
|
78
|
+
mode,
|
|
79
|
+
email,
|
|
80
|
+
inviteToken: body.inviteToken
|
|
81
|
+
});
|
|
82
|
+
if (!decision.allowed) {
|
|
83
|
+
mapRegistrationErrorToHttp(decision.reason);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const existing = findAccountByEmail(email);
|
|
87
|
+
if (existing) {
|
|
88
|
+
throw createInvalidCredentialsError();
|
|
89
|
+
}
|
|
62
90
|
const passwordHash = await hashPassword(body.password);
|
|
63
|
-
const account = createAccount({
|
|
64
|
-
email,
|
|
65
|
-
passwordHash,
|
|
66
|
-
displayName: body.displayName?.trim() || null
|
|
67
|
-
});
|
|
68
91
|
const config = getBasicAuthConfig();
|
|
69
92
|
if (config.refreshTtlSeconds <= 0 || config.accessTtlSeconds <= 0) {
|
|
70
93
|
throw createInvalidRequestError();
|
|
71
94
|
}
|
|
95
|
+
const accountId = randomUUID();
|
|
72
96
|
const sessionId = randomUUID();
|
|
73
97
|
const refreshToken = await signRefreshToken({
|
|
74
|
-
sub:
|
|
98
|
+
sub: accountId,
|
|
75
99
|
sid: sessionId,
|
|
76
|
-
ver:
|
|
77
|
-
});
|
|
78
|
-
createAuthSession({
|
|
79
|
-
accountId: account.id,
|
|
80
|
-
sessionId,
|
|
81
|
-
refreshTokenHash: hashRefreshToken(refreshToken),
|
|
82
|
-
expiresAtMs: Date.now() + config.refreshTtlSeconds * 1e3,
|
|
83
|
-
metadata: getSessionMetadataFromEvent(event)
|
|
100
|
+
ver: 0
|
|
84
101
|
});
|
|
85
102
|
const accessToken = await signAccessToken({
|
|
86
|
-
sub:
|
|
103
|
+
sub: accountId,
|
|
87
104
|
sid: sessionId,
|
|
88
|
-
ver:
|
|
89
|
-
email
|
|
90
|
-
display_name:
|
|
105
|
+
ver: 0,
|
|
106
|
+
email,
|
|
107
|
+
display_name: body.displayName?.trim() || null
|
|
108
|
+
});
|
|
109
|
+
createAccountAndSession({
|
|
110
|
+
account: {
|
|
111
|
+
id: accountId,
|
|
112
|
+
email,
|
|
113
|
+
passwordHash,
|
|
114
|
+
displayName: body.displayName?.trim() || null
|
|
115
|
+
},
|
|
116
|
+
session: {
|
|
117
|
+
accountId,
|
|
118
|
+
sessionId,
|
|
119
|
+
refreshTokenHash: hashRefreshToken(refreshToken),
|
|
120
|
+
expiresAtMs: Date.now() + config.refreshTtlSeconds * 1e3,
|
|
121
|
+
metadata: getSessionMetadataFromEvent(event)
|
|
122
|
+
}
|
|
91
123
|
});
|
|
92
124
|
setAccessCookie(event, accessToken, config.accessTtlSeconds);
|
|
93
125
|
setRefreshCookie(event, refreshToken, config.refreshTtlSeconds);
|
|
@@ -1,41 +1,71 @@
|
|
|
1
1
|
import { BASIC_AUTH_PROVIDER_ID } from "../../lib/constants.js";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getAccessTokenFromEvent,
|
|
4
|
+
getRefreshTokenFromEvent,
|
|
5
|
+
verifyAccessToken
|
|
6
|
+
} from "../lib/jwt.js";
|
|
7
|
+
import { refreshAuthSessionAndSetCookies } from "../lib/refresh-auth-session.js";
|
|
3
8
|
import {
|
|
4
9
|
findAccountById,
|
|
5
10
|
findSessionById,
|
|
6
11
|
isSessionUsable
|
|
7
12
|
} from "../lib/session-store.js";
|
|
13
|
+
function toProviderSession(account, payload) {
|
|
14
|
+
return {
|
|
15
|
+
provider: BASIC_AUTH_PROVIDER_ID,
|
|
16
|
+
user: {
|
|
17
|
+
id: payload.sub,
|
|
18
|
+
email: payload.email ?? account.email,
|
|
19
|
+
displayName: payload.display_name ?? account.display_name ?? void 0
|
|
20
|
+
},
|
|
21
|
+
expiresAt: new Date((payload.exp ?? 0) * 1e3),
|
|
22
|
+
claims: payload
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function resolveFromAccessToken(event) {
|
|
26
|
+
const accessToken = getAccessTokenFromEvent(event);
|
|
27
|
+
if (!accessToken) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const payload = await verifyAccessToken(accessToken);
|
|
31
|
+
if (!payload) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const session = findSessionById(payload.sid);
|
|
35
|
+
if (!isSessionUsable(session)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const account = findAccountById(payload.sub);
|
|
39
|
+
if (!account) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (account.token_version !== payload.ver) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return toProviderSession(account, payload);
|
|
46
|
+
}
|
|
8
47
|
export const basicAuthProvider = {
|
|
9
48
|
name: BASIC_AUTH_PROVIDER_ID,
|
|
10
49
|
async getSession(event) {
|
|
11
|
-
const
|
|
12
|
-
if (
|
|
13
|
-
return
|
|
14
|
-
}
|
|
15
|
-
const payload = await verifyAccessToken(accessToken);
|
|
16
|
-
if (!payload) {
|
|
17
|
-
return null;
|
|
50
|
+
const fromAccess = await resolveFromAccessToken(event);
|
|
51
|
+
if (fromAccess) {
|
|
52
|
+
return fromAccess;
|
|
18
53
|
}
|
|
19
|
-
|
|
20
|
-
if (!isSessionUsable(session)) {
|
|
54
|
+
if (!getRefreshTokenFromEvent(event)) {
|
|
21
55
|
return null;
|
|
22
56
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
57
|
+
const refreshed = await refreshAuthSessionAndSetCookies(event, {
|
|
58
|
+
// Do not clear cookies on transient refresh misses during migration
|
|
59
|
+
// (legacy path cookies may still only be sent to /api/basic-auth/*).
|
|
60
|
+
clearOnFailure: false
|
|
61
|
+
});
|
|
62
|
+
if (!refreshed.ok) {
|
|
25
63
|
return null;
|
|
26
64
|
}
|
|
27
|
-
|
|
65
|
+
const accessPayload = await verifyAccessToken(refreshed.accessToken);
|
|
66
|
+
if (!accessPayload) {
|
|
28
67
|
return null;
|
|
29
68
|
}
|
|
30
|
-
return
|
|
31
|
-
provider: BASIC_AUTH_PROVIDER_ID,
|
|
32
|
-
user: {
|
|
33
|
-
id: payload.sub,
|
|
34
|
-
email: payload.email ?? account.email,
|
|
35
|
-
displayName: payload.display_name ?? account.display_name ?? void 0
|
|
36
|
-
},
|
|
37
|
-
expiresAt: new Date((payload.exp ?? 0) * 1e3),
|
|
38
|
-
claims: payload
|
|
39
|
-
};
|
|
69
|
+
return toProviderSession(refreshed.account, accessPayload);
|
|
40
70
|
}
|
|
41
71
|
};
|
|
@@ -69,6 +69,15 @@ function runMigrations(db) {
|
|
|
69
69
|
`);
|
|
70
70
|
db.pragma("user_version = 2");
|
|
71
71
|
}
|
|
72
|
+
if (version < 3) {
|
|
73
|
+
db.exec(`
|
|
74
|
+
ALTER TABLE basic_auth_sessions
|
|
75
|
+
ADD COLUMN rotation_grace_until INTEGER;
|
|
76
|
+
ALTER TABLE basic_auth_sessions
|
|
77
|
+
ADD COLUMN rotation_grace_refresh_token TEXT;
|
|
78
|
+
`);
|
|
79
|
+
db.pragma("user_version = 3");
|
|
80
|
+
}
|
|
72
81
|
}
|
|
73
82
|
export function getBasicAuthDb() {
|
|
74
83
|
if (dbSingleton) return dbSingleton;
|
|
@@ -16,6 +16,10 @@ export interface BasicAuthSession {
|
|
|
16
16
|
created_at: number;
|
|
17
17
|
rotated_from_session_id: string | null;
|
|
18
18
|
replaced_by_session_id: string | null;
|
|
19
|
+
/** Epoch ms until concurrent same-token refresh may reuse successor tokens. */
|
|
20
|
+
rotation_grace_until: number | null;
|
|
21
|
+
/** AES-GCM sealed successor refresh token, cleared after grace expires. */
|
|
22
|
+
rotation_grace_refresh_token: string | null;
|
|
19
23
|
ip_address: string | null;
|
|
20
24
|
user_agent: string | null;
|
|
21
25
|
}
|
|
@@ -2,7 +2,8 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import {
|
|
3
3
|
BASIC_AUTH_PROVIDER_ID,
|
|
4
4
|
DEFAULT_ACCESS_TTL_SECONDS,
|
|
5
|
-
DEFAULT_REFRESH_TTL_SECONDS
|
|
5
|
+
DEFAULT_REFRESH_TTL_SECONDS,
|
|
6
|
+
REFRESH_ROTATION_GRACE_MS
|
|
6
7
|
} from "../../lib/constants.js";
|
|
7
8
|
export const BASIC_AUTH_INSECURE_DEV_ESCAPE_HATCH_ENV = "OR3_BASIC_AUTH_ALLOW_INSECURE_DEV";
|
|
8
9
|
function parsePositiveInt(input, fallback) {
|
|
@@ -44,6 +45,10 @@ export function getBasicAuthConfig(runtimeConfig) {
|
|
|
44
45
|
process.env.OR3_BASIC_AUTH_REFRESH_TTL_SECONDS,
|
|
45
46
|
DEFAULT_REFRESH_TTL_SECONDS
|
|
46
47
|
),
|
|
48
|
+
rotationGraceMs: parsePositiveInt(
|
|
49
|
+
process.env.OR3_BASIC_AUTH_ROTATION_GRACE_MS,
|
|
50
|
+
REFRESH_ROTATION_GRACE_MS
|
|
51
|
+
),
|
|
47
52
|
dbPath,
|
|
48
53
|
bootstrapEmail: process.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL,
|
|
49
54
|
bootstrapPassword: process.env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD
|
|
@@ -2,12 +2,20 @@ import { setCookie, deleteCookie } from "h3";
|
|
|
2
2
|
import {
|
|
3
3
|
ACCESS_COOKIE_NAME,
|
|
4
4
|
ACCESS_COOKIE_PATH,
|
|
5
|
+
LEGACY_REFRESH_COOKIE_PATH,
|
|
5
6
|
REFRESH_COOKIE_NAME,
|
|
6
7
|
REFRESH_COOKIE_PATH
|
|
7
8
|
} from "../../lib/constants.js";
|
|
8
9
|
function isProduction() {
|
|
9
10
|
return process.env.NODE_ENV === "production";
|
|
10
11
|
}
|
|
12
|
+
function refreshCookieBase() {
|
|
13
|
+
return {
|
|
14
|
+
httpOnly: true,
|
|
15
|
+
sameSite: "lax",
|
|
16
|
+
secure: isProduction()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
11
19
|
export function setAccessCookie(event, token, maxAgeSeconds) {
|
|
12
20
|
setCookie(event, ACCESS_COOKIE_NAME, token, {
|
|
13
21
|
httpOnly: true,
|
|
@@ -18,10 +26,12 @@ export function setAccessCookie(event, token, maxAgeSeconds) {
|
|
|
18
26
|
});
|
|
19
27
|
}
|
|
20
28
|
export function setRefreshCookie(event, token, maxAgeSeconds) {
|
|
29
|
+
deleteCookie(event, REFRESH_COOKIE_NAME, {
|
|
30
|
+
...refreshCookieBase(),
|
|
31
|
+
path: LEGACY_REFRESH_COOKIE_PATH
|
|
32
|
+
});
|
|
21
33
|
setCookie(event, REFRESH_COOKIE_NAME, token, {
|
|
22
|
-
|
|
23
|
-
sameSite: "lax",
|
|
24
|
-
secure: isProduction(),
|
|
34
|
+
...refreshCookieBase(),
|
|
25
35
|
path: REFRESH_COOKIE_PATH,
|
|
26
36
|
maxAge: maxAgeSeconds
|
|
27
37
|
});
|
|
@@ -34,9 +44,11 @@ export function clearAuthCookies(event) {
|
|
|
34
44
|
path: ACCESS_COOKIE_PATH
|
|
35
45
|
});
|
|
36
46
|
deleteCookie(event, REFRESH_COOKIE_NAME, {
|
|
37
|
-
|
|
38
|
-
sameSite: "lax",
|
|
39
|
-
secure: isProduction(),
|
|
47
|
+
...refreshCookieBase(),
|
|
40
48
|
path: REFRESH_COOKIE_PATH
|
|
41
49
|
});
|
|
50
|
+
deleteCookie(event, REFRESH_COOKIE_NAME, {
|
|
51
|
+
...refreshCookieBase(),
|
|
52
|
+
path: LEGACY_REFRESH_COOKIE_PATH
|
|
53
|
+
});
|
|
42
54
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { BasicAuthAccount } from '../db/schema.js';
|
|
3
|
+
export type RefreshAuthSessionFailureReason = 'missing_token' | 'invalid_token' | 'account' | 'not_found' | 'expired' | 'revoked' | 'replayed';
|
|
4
|
+
export type RefreshAuthSessionResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
account: BasicAuthAccount;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
accessToken: string;
|
|
9
|
+
refreshToken: string;
|
|
10
|
+
reused: boolean;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: RefreshAuthSessionFailureReason;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Rotate (or grace-reuse) the refresh session for this request and mint a fresh
|
|
17
|
+
* access token. Does not set cookies — callers decide whether to write them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function refreshAuthSession(event: H3Event): Promise<RefreshAuthSessionResult>;
|
|
20
|
+
/** Apply refreshed tokens as cookies, or clear auth cookies on failure. */
|
|
21
|
+
export declare function refreshAuthSessionAndSetCookies(event: H3Event, options?: {
|
|
22
|
+
clearOnFailure?: boolean;
|
|
23
|
+
}): Promise<RefreshAuthSessionResult>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { clearAuthCookies, setAccessCookie, setRefreshCookie } from "./cookies.js";
|
|
3
|
+
import { getBasicAuthConfig } from "./config.js";
|
|
4
|
+
import {
|
|
5
|
+
getRefreshTokenFromEvent,
|
|
6
|
+
hashRefreshToken,
|
|
7
|
+
signAccessToken,
|
|
8
|
+
signRefreshToken,
|
|
9
|
+
verifyRefreshToken
|
|
10
|
+
} from "./jwt.js";
|
|
11
|
+
import {
|
|
12
|
+
findAccountById,
|
|
13
|
+
getSessionMetadataFromEvent,
|
|
14
|
+
rotateSession
|
|
15
|
+
} from "./session-store.js";
|
|
16
|
+
export async function refreshAuthSession(event) {
|
|
17
|
+
const refreshToken = getRefreshTokenFromEvent(event);
|
|
18
|
+
if (!refreshToken) {
|
|
19
|
+
return { ok: false, reason: "missing_token" };
|
|
20
|
+
}
|
|
21
|
+
const claims = await verifyRefreshToken(refreshToken);
|
|
22
|
+
if (!claims) {
|
|
23
|
+
return { ok: false, reason: "invalid_token" };
|
|
24
|
+
}
|
|
25
|
+
const account = findAccountById(claims.sub);
|
|
26
|
+
if (!account || account.token_version !== claims.ver) {
|
|
27
|
+
return { ok: false, reason: "account" };
|
|
28
|
+
}
|
|
29
|
+
const config = getBasicAuthConfig();
|
|
30
|
+
const newSessionId = randomUUID();
|
|
31
|
+
const newRefreshToken = await signRefreshToken({
|
|
32
|
+
sub: account.id,
|
|
33
|
+
sid: newSessionId,
|
|
34
|
+
ver: account.token_version
|
|
35
|
+
});
|
|
36
|
+
const rotation = rotateSession({
|
|
37
|
+
currentSessionId: claims.sid,
|
|
38
|
+
currentRefreshHash: hashRefreshToken(refreshToken),
|
|
39
|
+
newSessionId,
|
|
40
|
+
newRefreshHash: hashRefreshToken(newRefreshToken),
|
|
41
|
+
newRefreshToken,
|
|
42
|
+
newExpiresAtMs: Date.now() + config.refreshTtlSeconds * 1e3,
|
|
43
|
+
graceMs: config.rotationGraceMs,
|
|
44
|
+
metadata: getSessionMetadataFromEvent(event)
|
|
45
|
+
});
|
|
46
|
+
if (!rotation.ok || rotation.accountId !== account.id) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
reason: rotation.ok ? "account" : rotation.reason
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const effectiveRefreshToken = rotation.reused && rotation.refreshToken ? rotation.refreshToken : newRefreshToken;
|
|
53
|
+
const effectiveSessionId = rotation.sessionId;
|
|
54
|
+
const accessToken = await signAccessToken({
|
|
55
|
+
sub: account.id,
|
|
56
|
+
sid: effectiveSessionId,
|
|
57
|
+
ver: account.token_version,
|
|
58
|
+
email: account.email,
|
|
59
|
+
display_name: account.display_name
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
account,
|
|
64
|
+
sessionId: effectiveSessionId,
|
|
65
|
+
accessToken,
|
|
66
|
+
refreshToken: effectiveRefreshToken,
|
|
67
|
+
reused: rotation.reused
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function refreshAuthSessionAndSetCookies(event, options = {}) {
|
|
71
|
+
const clearOnFailure = options.clearOnFailure ?? true;
|
|
72
|
+
const result = await refreshAuthSession(event);
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
if (clearOnFailure) {
|
|
75
|
+
clearAuthCookies(event);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
const config = getBasicAuthConfig();
|
|
80
|
+
setAccessCookie(event, result.accessToken, config.accessTtlSeconds);
|
|
81
|
+
setRefreshCookie(event, result.refreshToken, config.refreshTtlSeconds);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Encrypt a successor refresh token for short-lived grace storage. */
|
|
2
|
+
export declare function sealRotationGraceToken(token: string): string;
|
|
3
|
+
/** Decrypt a grace-stored successor refresh token. Returns null on failure. */
|
|
4
|
+
export declare function unsealRotationGraceToken(sealed: string): string | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { getBasicAuthConfig } from "./config.js";
|
|
3
|
+
function graceKey() {
|
|
4
|
+
return createHash("sha256").update(`basic-auth-rotation-grace:${getBasicAuthConfig().refreshSecret}`).digest();
|
|
5
|
+
}
|
|
6
|
+
export function sealRotationGraceToken(token) {
|
|
7
|
+
const iv = randomBytes(12);
|
|
8
|
+
const cipher = createCipheriv("aes-256-gcm", graceKey(), iv);
|
|
9
|
+
const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
|
|
10
|
+
const tag = cipher.getAuthTag();
|
|
11
|
+
return Buffer.concat([iv, tag, encrypted]).toString("base64url");
|
|
12
|
+
}
|
|
13
|
+
export function unsealRotationGraceToken(sealed) {
|
|
14
|
+
try {
|
|
15
|
+
const buf = Buffer.from(sealed, "base64url");
|
|
16
|
+
if (buf.length < 28) return null;
|
|
17
|
+
const iv = buf.subarray(0, 12);
|
|
18
|
+
const tag = buf.subarray(12, 28);
|
|
19
|
+
const encrypted = buf.subarray(28);
|
|
20
|
+
const decipher = createDecipheriv("aes-256-gcm", graceKey(), iv);
|
|
21
|
+
decipher.setAuthTag(tag);
|
|
22
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type H3Event } from 'h3';
|
|
2
2
|
import type { BasicAuthAccount, BasicAuthSession } from '../db/schema.js';
|
|
3
3
|
export interface CreateAccountInput {
|
|
4
|
+
id?: string;
|
|
4
5
|
email: string;
|
|
5
6
|
passwordHash: string;
|
|
6
7
|
displayName?: string | null;
|
|
@@ -22,20 +23,40 @@ export interface RotationInput {
|
|
|
22
23
|
currentRefreshHash: string;
|
|
23
24
|
newSessionId: string;
|
|
24
25
|
newRefreshHash: string;
|
|
26
|
+
/** Plaintext successor refresh token; sealed into the grace window for concurrent reuse. */
|
|
27
|
+
newRefreshToken: string;
|
|
25
28
|
newExpiresAtMs: number;
|
|
26
29
|
metadata?: SessionMetadata;
|
|
30
|
+
/** Injectable clock for tests. */
|
|
31
|
+
nowMs?: number;
|
|
32
|
+
/** Override grace window length for tests. */
|
|
33
|
+
graceMs?: number;
|
|
27
34
|
}
|
|
28
|
-
export
|
|
29
|
-
ok:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
export type RotationResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
accountId: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
reused: boolean;
|
|
40
|
+
/** Present when `reused` is true — the already-issued successor refresh token. */
|
|
41
|
+
refreshToken?: string;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
reason: 'not_found' | 'expired' | 'revoked' | 'replayed';
|
|
45
|
+
};
|
|
34
46
|
export declare function normalizeEmail(email: string): string;
|
|
35
47
|
export declare function getSessionMetadataFromEvent(event: H3Event): SessionMetadata;
|
|
36
48
|
export declare function findAccountByEmail(email: string): BasicAuthAccount | null;
|
|
37
49
|
export declare function findAccountById(id: string): BasicAuthAccount | null;
|
|
38
50
|
export declare function createAccount(input: CreateAccountInput): BasicAuthAccount;
|
|
51
|
+
export declare function createAccountAndSession(input: {
|
|
52
|
+
account: CreateAccountInput & {
|
|
53
|
+
id: string;
|
|
54
|
+
};
|
|
55
|
+
session: CreateSessionInput;
|
|
56
|
+
}): {
|
|
57
|
+
account: BasicAuthAccount;
|
|
58
|
+
session: BasicAuthSession;
|
|
59
|
+
};
|
|
39
60
|
export declare function ensureBootstrapAccount(input: CreateAccountInput): BasicAuthAccount;
|
|
40
61
|
export declare function createAuthSession(input: CreateSessionInput): BasicAuthSession;
|
|
41
62
|
export declare function findSessionById(sessionId: string): BasicAuthSession | null;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { getRequestHeader } from "h3";
|
|
3
3
|
import { getBasicAuthDb } from "../db/client.js";
|
|
4
|
+
import { REFRESH_ROTATION_GRACE_MS } from "../../lib/constants.js";
|
|
4
5
|
import {
|
|
5
6
|
getClientIp as getProxyClientIp,
|
|
6
7
|
normalizeProxyTrustConfig
|
|
7
8
|
} from "./request-identity.js";
|
|
9
|
+
import { sealRotationGraceToken, unsealRotationGraceToken } from "./rotation-grace.js";
|
|
8
10
|
export function normalizeEmail(email) {
|
|
9
11
|
return email.trim().toLowerCase();
|
|
10
12
|
}
|
|
@@ -18,6 +20,14 @@ function safeHexEquals(left, right) {
|
|
|
18
20
|
if (leftBuffer.length !== rightBuffer.length) return false;
|
|
19
21
|
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
20
22
|
}
|
|
23
|
+
function clearExpiredRotationGrace(sessionId) {
|
|
24
|
+
const db = getBasicAuthDb();
|
|
25
|
+
db.prepare(
|
|
26
|
+
`UPDATE basic_auth_sessions
|
|
27
|
+
SET rotation_grace_until = NULL, rotation_grace_refresh_token = NULL
|
|
28
|
+
WHERE id = ?`
|
|
29
|
+
).run(sessionId);
|
|
30
|
+
}
|
|
21
31
|
export function getSessionMetadataFromEvent(event) {
|
|
22
32
|
const proxyConfig = normalizeProxyTrustConfig(useRuntimeConfig(event).security?.proxy);
|
|
23
33
|
return {
|
|
@@ -39,7 +49,7 @@ export function createAccount(input) {
|
|
|
39
49
|
const db = getBasicAuthDb();
|
|
40
50
|
const now = Date.now();
|
|
41
51
|
const account = {
|
|
42
|
-
id: randomUUID(),
|
|
52
|
+
id: input.id ?? randomUUID(),
|
|
43
53
|
email: normalizeEmail(input.email),
|
|
44
54
|
password_hash: input.passwordHash,
|
|
45
55
|
display_name: input.displayName ?? null,
|
|
@@ -62,6 +72,17 @@ export function createAccount(input) {
|
|
|
62
72
|
);
|
|
63
73
|
return account;
|
|
64
74
|
}
|
|
75
|
+
export function createAccountAndSession(input) {
|
|
76
|
+
const db = getBasicAuthDb();
|
|
77
|
+
return db.transaction(() => {
|
|
78
|
+
const account = createAccount(input.account);
|
|
79
|
+
if (input.session.accountId !== account.id) {
|
|
80
|
+
throw new Error("Session account does not match the account being created");
|
|
81
|
+
}
|
|
82
|
+
const session = createAuthSession(input.session);
|
|
83
|
+
return { account, session };
|
|
84
|
+
})();
|
|
85
|
+
}
|
|
65
86
|
export function ensureBootstrapAccount(input) {
|
|
66
87
|
const existing = findAccountByEmail(input.email);
|
|
67
88
|
if (existing) return existing;
|
|
@@ -79,14 +100,18 @@ export function createAuthSession(input) {
|
|
|
79
100
|
created_at: createdAt,
|
|
80
101
|
rotated_from_session_id: input.rotatedFromSessionId ?? null,
|
|
81
102
|
replaced_by_session_id: null,
|
|
103
|
+
rotation_grace_until: null,
|
|
104
|
+
rotation_grace_refresh_token: null,
|
|
82
105
|
ip_address: input.metadata?.ipAddress ?? null,
|
|
83
106
|
user_agent: input.metadata?.userAgent ?? null
|
|
84
107
|
};
|
|
85
108
|
db.prepare(
|
|
86
109
|
`INSERT INTO basic_auth_sessions (
|
|
87
110
|
id, account_id, refresh_token_hash, expires_at, revoked_at, created_at,
|
|
88
|
-
rotated_from_session_id, replaced_by_session_id,
|
|
89
|
-
|
|
111
|
+
rotated_from_session_id, replaced_by_session_id,
|
|
112
|
+
rotation_grace_until, rotation_grace_refresh_token,
|
|
113
|
+
ip_address, user_agent
|
|
114
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
90
115
|
).run(
|
|
91
116
|
row.id,
|
|
92
117
|
row.account_id,
|
|
@@ -96,6 +121,8 @@ export function createAuthSession(input) {
|
|
|
96
121
|
row.created_at,
|
|
97
122
|
row.rotated_from_session_id,
|
|
98
123
|
row.replaced_by_session_id,
|
|
124
|
+
row.rotation_grace_until,
|
|
125
|
+
row.rotation_grace_refresh_token,
|
|
99
126
|
row.ip_address,
|
|
100
127
|
row.user_agent
|
|
101
128
|
);
|
|
@@ -115,12 +142,39 @@ export function revokeSessionById(sessionId, revokedAtMs = Date.now()) {
|
|
|
115
142
|
export function revokeAllSessionsForAccount(accountId, revokedAtMs = Date.now()) {
|
|
116
143
|
const db = getBasicAuthDb();
|
|
117
144
|
db.prepare(
|
|
118
|
-
|
|
145
|
+
`UPDATE basic_auth_sessions
|
|
146
|
+
SET revoked_at = COALESCE(revoked_at, ?),
|
|
147
|
+
rotation_grace_until = NULL,
|
|
148
|
+
rotation_grace_refresh_token = NULL
|
|
149
|
+
WHERE account_id = ?`
|
|
119
150
|
).run(revokedAtMs, accountId);
|
|
120
151
|
}
|
|
152
|
+
function tryReuseRotatedSession(currentSession, isSameTokenHash, now) {
|
|
153
|
+
if (currentSession.replaced_by_session_id === null || !isSameTokenHash) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const graceUntil = currentSession.rotation_grace_until;
|
|
157
|
+
const sealedToken = currentSession.rotation_grace_refresh_token;
|
|
158
|
+
if (typeof graceUntil === "number" && graceUntil > now && typeof sealedToken === "string" && sealedToken.length > 0) {
|
|
159
|
+
const refreshToken = unsealRotationGraceToken(sealedToken);
|
|
160
|
+
const successor = findSessionById(currentSession.replaced_by_session_id);
|
|
161
|
+
if (refreshToken && successor && isSessionUsable(successor)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
reused: true,
|
|
165
|
+
accountId: currentSession.account_id,
|
|
166
|
+
sessionId: successor.id,
|
|
167
|
+
refreshToken
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
clearExpiredRotationGrace(currentSession.id);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
121
174
|
export function rotateSession(input) {
|
|
122
175
|
const db = getBasicAuthDb();
|
|
123
|
-
const now = Date.now();
|
|
176
|
+
const now = input.nowMs ?? Date.now();
|
|
177
|
+
const graceMs = input.graceMs ?? REFRESH_ROTATION_GRACE_MS;
|
|
124
178
|
const currentSession = findSessionById(input.currentSessionId);
|
|
125
179
|
if (!currentSession) {
|
|
126
180
|
return { ok: false, reason: "not_found" };
|
|
@@ -134,8 +188,11 @@ export function rotateSession(input) {
|
|
|
134
188
|
input.currentRefreshHash
|
|
135
189
|
);
|
|
136
190
|
if (currentSession.revoked_at !== null) {
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
191
|
+
const reused = tryReuseRotatedSession(currentSession, isSameTokenHash, now);
|
|
192
|
+
if (reused) {
|
|
193
|
+
return reused;
|
|
194
|
+
}
|
|
195
|
+
if (currentSession.replaced_by_session_id !== null && isSameTokenHash) {
|
|
139
196
|
revokeAllSessionsForAccount(currentSession.account_id, now);
|
|
140
197
|
return {
|
|
141
198
|
ok: false,
|
|
@@ -151,6 +208,7 @@ export function rotateSession(input) {
|
|
|
151
208
|
reason: "replayed"
|
|
152
209
|
};
|
|
153
210
|
}
|
|
211
|
+
const sealedGraceToken = sealRotationGraceToken(input.newRefreshToken);
|
|
154
212
|
db.transaction(() => {
|
|
155
213
|
createAuthSession({
|
|
156
214
|
accountId: currentSession.account_id,
|
|
@@ -161,11 +219,17 @@ export function rotateSession(input) {
|
|
|
161
219
|
metadata: input.metadata
|
|
162
220
|
});
|
|
163
221
|
db.prepare(
|
|
164
|
-
|
|
165
|
-
|
|
222
|
+
`UPDATE basic_auth_sessions
|
|
223
|
+
SET revoked_at = ?,
|
|
224
|
+
replaced_by_session_id = ?,
|
|
225
|
+
rotation_grace_until = ?,
|
|
226
|
+
rotation_grace_refresh_token = ?
|
|
227
|
+
WHERE id = ?`
|
|
228
|
+
).run(now, input.newSessionId, now + graceMs, sealedGraceToken, currentSession.id);
|
|
166
229
|
})();
|
|
167
230
|
return {
|
|
168
231
|
ok: true,
|
|
232
|
+
reused: false,
|
|
169
233
|
accountId: currentSession.account_id,
|
|
170
234
|
sessionId: input.newSessionId
|
|
171
235
|
};
|
|
@@ -52,7 +52,6 @@ export default defineNitroPlugin(async () => {
|
|
|
52
52
|
}
|
|
53
53
|
throw new Error(message);
|
|
54
54
|
}
|
|
55
|
-
await ensureBootstrapUserIfConfigured();
|
|
56
55
|
registerAuthProvider({
|
|
57
56
|
id: BASIC_AUTH_PROVIDER_ID,
|
|
58
57
|
order: 100,
|
|
@@ -60,4 +59,5 @@ export default defineNitroPlugin(async () => {
|
|
|
60
59
|
});
|
|
61
60
|
registerProviderTokenBroker(BASIC_AUTH_PROVIDER_ID, createBasicAuthTokenBroker);
|
|
62
61
|
registerProviderAdminAdapter(basicAuthAdminAdapter);
|
|
62
|
+
await ensureBootstrapUserIfConfigured();
|
|
63
63
|
});
|
package/package.json
CHANGED