or3-provider-basic-auth 0.0.1 → 0.0.2

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.
Files changed (30) hide show
  1. package/README.md +3 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/BasicAuthLockPage.client.d.vue.ts +2 -0
  4. package/dist/runtime/components/BasicAuthLockPage.client.vue +55 -0
  5. package/dist/runtime/components/BasicAuthLockPage.client.vue.d.ts +2 -0
  6. package/dist/runtime/components/BasicAuthRegisterModal.client.vue +42 -4
  7. package/dist/runtime/components/SidebarAuthButtonBasic.client.vue +2 -20
  8. package/dist/runtime/lib/constants.d.ts +13 -1
  9. package/dist/runtime/lib/constants.js +3 -1
  10. package/dist/runtime/lib/silent-refresh.client.d.ts +10 -0
  11. package/dist/runtime/lib/silent-refresh.client.js +31 -0
  12. package/dist/runtime/plugins/auth-status.client.js +4 -11
  13. package/dist/runtime/plugins/basic-auth-ui.client.js +35 -0
  14. package/dist/runtime/server/api/basic-auth/_helpers.js +21 -0
  15. package/dist/runtime/server/api/basic-auth/refresh.post.d.ts +6 -2
  16. package/dist/runtime/server/api/basic-auth/refresh.post.js +3 -56
  17. package/dist/runtime/server/api/basic-auth/register.post.js +57 -25
  18. package/dist/runtime/server/auth/basic-auth-provider.js +53 -23
  19. package/dist/runtime/server/db/client.js +9 -0
  20. package/dist/runtime/server/db/schema.d.ts +4 -0
  21. package/dist/runtime/server/lib/config.d.ts +1 -0
  22. package/dist/runtime/server/lib/config.js +6 -1
  23. package/dist/runtime/server/lib/cookies.js +18 -6
  24. package/dist/runtime/server/lib/refresh-auth-session.d.ts +23 -0
  25. package/dist/runtime/server/lib/refresh-auth-session.js +83 -0
  26. package/dist/runtime/server/lib/rotation-grace.d.ts +4 -0
  27. package/dist/runtime/server/lib/rotation-grace.js +26 -0
  28. package/dist/runtime/server/lib/session-store.d.ts +27 -6
  29. package/dist/runtime/server/lib/session-store.js +73 -9
  30. 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "or3-provider-basic-auth",
3
3
  "configKey": "or3-provider-basic-auth",
4
- "version": "0.0.1",
4
+ "version": "0.0.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -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 label="Password" name="password">
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 input and try again.";
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 tryRefreshTokens();
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
- export declare const REFRESH_COOKIE_PATH = "/api/basic-auth";
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/basic-auth";
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 trySilentRefresh();
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: boolean;
3
+ ok: false;
4
+ } | {
5
+ ok: true;
4
6
  }>;
5
7
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
6
- ok: boolean;
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 failExpired = () => {
29
- clearAuthCookies(event);
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
- createAccount,
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 = runtimeConfig.auth?.registrationMode ?? (runtimeConfig.auth?.autoProvision === false ? "disabled" : "open");
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: account.id,
98
+ sub: accountId,
75
99
  sid: sessionId,
76
- ver: account.token_version
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: account.id,
103
+ sub: accountId,
87
104
  sid: sessionId,
88
- ver: account.token_version,
89
- email: account.email,
90
- display_name: account.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 { getAccessTokenFromEvent, verifyAccessToken } from "../lib/jwt.js";
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 accessToken = getAccessTokenFromEvent(event);
12
- if (!accessToken) {
13
- return null;
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
- const session = findSessionById(payload.sid);
20
- if (!isSessionUsable(session)) {
54
+ if (!getRefreshTokenFromEvent(event)) {
21
55
  return null;
22
56
  }
23
- const account = findAccountById(payload.sub);
24
- if (!account) {
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
- if (account.token_version !== payload.ver) {
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
  }
@@ -7,6 +7,7 @@ export interface BasicAuthConfig {
7
7
  refreshSecret: string;
8
8
  accessTtlSeconds: number;
9
9
  refreshTtlSeconds: number;
10
+ rotationGraceMs: number;
10
11
  dbPath: string;
11
12
  bootstrapEmail?: string;
12
13
  bootstrapPassword?: string;
@@ -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
- httpOnly: true,
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
- httpOnly: true,
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 interface RotationResult {
29
- ok: boolean;
30
- reason?: 'not_found' | 'expired' | 'revoked' | 'replayed';
31
- accountId?: string;
32
- sessionId?: string;
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, ip_address, user_agent
89
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
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
- "UPDATE basic_auth_sessions SET revoked_at = COALESCE(revoked_at, ?) WHERE account_id = ?"
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 replayedRotatedToken = currentSession.replaced_by_session_id !== null && isSameTokenHash;
138
- if (replayedRotatedToken) {
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
- "UPDATE basic_auth_sessions SET revoked_at = ?, replaced_by_session_id = ? WHERE id = ?"
165
- ).run(now, input.newSessionId, currentSession.id);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "or3-provider-basic-auth",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Basic-authentication provider for OR3 Chat with SSR session resolution, secure cookies, and provider-owned auth UI.",
5
5
  "license": "MIT",
6
6
  "type": "module",