or3-provider-basic-auth 0.0.8 → 0.0.10

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 (26) hide show
  1. package/README.md +25 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/BasicAuthChangePasswordModal.client.vue +12 -2
  4. package/dist/runtime/components/BasicAuthRegisterModal.client.vue +8 -3
  5. package/dist/runtime/components/BasicAuthSignInModal.client.vue +2 -1
  6. package/dist/runtime/components/BasicAuthUserMenu.client.d.vue.ts +3 -0
  7. package/dist/runtime/components/BasicAuthUserMenu.client.vue +53 -10
  8. package/dist/runtime/components/BasicAuthUserMenu.client.vue.d.ts +3 -0
  9. package/dist/runtime/components/SidebarAuthButtonBasic.client.d.vue.ts +6 -1
  10. package/dist/runtime/components/SidebarAuthButtonBasic.client.vue +46 -2
  11. package/dist/runtime/components/SidebarAuthButtonBasic.client.vue.d.ts +6 -1
  12. package/dist/runtime/lib/auth-ui-layout.d.ts +7 -0
  13. package/dist/runtime/lib/auth-ui-layout.js +1 -0
  14. package/dist/runtime/password-policy.d.ts +2 -0
  15. package/dist/runtime/password-policy.js +4 -0
  16. package/dist/runtime/server/api/basic-auth/_helpers.js +3 -0
  17. package/dist/runtime/server/api/basic-auth/change-password.post.js +7 -2
  18. package/dist/runtime/server/api/basic-auth/register.post.js +7 -2
  19. package/dist/runtime/server/api/basic-auth/sign-out.post.js +38 -3
  20. package/dist/runtime/server/lib/cookies.d.ts +2 -0
  21. package/dist/runtime/server/lib/cookies.js +14 -6
  22. package/dist/runtime/server/lib/jwt.js +3 -3
  23. package/dist/runtime/server/lib/password.js +4 -0
  24. package/dist/runtime/server/lib/session-store.d.ts +8 -0
  25. package/dist/runtime/server/lib/session-store.js +30 -0
  26. package/package.json +1 -1
package/README.md CHANGED
@@ -51,6 +51,7 @@ Optional:
51
51
  - `OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD`
52
52
  - `OR3_BASIC_AUTH_RATE_LIMIT_BACKEND` (`sqlite` default; `memory` is per-process and intended for single-instance development only)
53
53
  - `OR3_BASIC_AUTH_ALLOW_INSECURE_DEV` (`true` in non-production to start with missing secrets instead of failing)
54
+ - `OR3_PLUGIN_DEV_COOKIE_SCOPE` (set by the isolated plugin development launcher to keep browser sessions separate across local hosts)
54
55
 
55
56
  Registration mode is a core-auth policy, not a provider setting:
56
57
 
@@ -72,6 +73,7 @@ Strict-mode behavior:
72
73
  - Access JWTs are short-lived and validated by `basicAuthProvider.getSession(event)`.
73
74
  - Refresh tokens are rotated and hashed at rest; replay attempts revoke active sessions.
74
75
  - The refresh cookie is scoped to `/api` so `/api/auth/session` can transparently rotate expired access tokens during session resolution.
76
+ - Sign-out never refreshes the session: the user is resolved without rotation, and revoking a token also revokes the sessions it was rotated into and clears their rotation grace, so a replayed predecessor cannot recover a successor after logout.
75
77
  - The SQLite DB file and directory are hardened to mode `0600`/`0700`; the DB uses WAL journaling.
76
78
 
77
79
  ## Runtime entrypoints
@@ -90,6 +92,13 @@ Strict-mode behavior:
90
92
  | `src/runtime/plugins/auth-status.client.ts` | Client auth-status resolver + silent-refresh recovery |
91
93
  | `src/runtime/components/*.client.vue` | Provider-owned auth UI (modals, user menu, sidebar button, lock page) |
92
94
 
95
+ New passwords must be at least eight characters and no more than 72 UTF-8 bytes, matching bcrypt's input limit. Existing account hashes remain verifiable, including passwords created before this limit was enforced.
96
+
97
+ The registered sidebar auth adapter accepts `layout="rail"` (default) and
98
+ `layout="more-sheet"`. In the More sheet it renders Account/Login with the
99
+ host's shared full-width `.more-row` structure, while keeping the existing
100
+ popover and modal behavior.
101
+
93
102
  ## Troubleshooting
94
103
 
95
104
  - `Authentication provider is not configured`
@@ -129,3 +138,19 @@ bun run type-check
129
138
  bun run test
130
139
  bun run build
131
140
  ```
141
+
142
+
143
+ ### Testing local changes in OR3 Chat
144
+
145
+ With this repository beside `or3-chat`, run `bun install` here once, then
146
+ `bun run dev:ssr` from Chat. Chat's dev wrapper rebuilds the local provider and
147
+ prints its selected path; restart it after provider edits. Missing repositories
148
+ or failed builds fall back to installed packages with a warning.
149
+ `OR3_LOCAL_PROVIDERS=false` disables local selection. Production builds use the
150
+ installed package, so local development does not publish these changes.
151
+
152
+ Basic Auth's database always uses `better-sqlite3`, independently of the sync
153
+ provider's SQLite driver. If the source launcher selects Bun for sync, this
154
+ native binding must also work under the project-pinned Bun version. The launcher
155
+ checks it in that runtime before starting Nuxt and reports incompatible bindings
156
+ or runtime crashes. Managed Cloud uses Node with `better-sqlite3`.
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.8",
4
+ "version": "0.0.10",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -31,7 +31,7 @@
31
31
 
32
32
  <USeparator />
33
33
 
34
- <UFormField label="New Password" name="newPassword">
34
+ <UFormField label="New Password" name="newPassword" hint="At least 8 characters; at most 72 UTF-8 bytes">
35
35
  <UInput
36
36
  v-model="state.newPassword"
37
37
  type="password"
@@ -83,8 +83,10 @@
83
83
 
84
84
  <script setup>
85
85
  import { computed, reactive, ref } from "vue";
86
+ import { newPasswordFitsBcrypt } from "../password-policy";
86
87
  const modalUi = {
87
- content: "sm:max-w-[400px]"
88
+ overlay: "basic-auth-modal-overlay",
89
+ content: "basic-auth-modal-content sm:max-w-[400px]"
88
90
  };
89
91
  const props = defineProps({
90
92
  modelValue: { type: Boolean, required: true },
@@ -107,6 +109,14 @@ function close() {
107
109
  emit("update:modelValue", false);
108
110
  }
109
111
  async function onSubmit() {
112
+ if (state.newPassword.length < 8) {
113
+ errorMessage.value = "New password must be at least 8 characters.";
114
+ return;
115
+ }
116
+ if (!newPasswordFitsBcrypt(state.newPassword)) {
117
+ errorMessage.value = "New password must be 72 UTF-8 bytes or fewer.";
118
+ return;
119
+ }
110
120
  if (state.newPassword !== state.confirmNewPassword) {
111
121
  errorMessage.value = "Passwords do not match";
112
122
  return;
@@ -46,7 +46,7 @@
46
46
  <UFormField
47
47
  label="Password"
48
48
  name="password"
49
- hint="At least 8 characters"
49
+ hint="At least 8 characters; at most 72 UTF-8 bytes"
50
50
  >
51
51
  <UInput
52
52
  v-model="state.password"
@@ -112,8 +112,10 @@
112
112
 
113
113
  <script setup>
114
114
  import { computed, reactive, ref } from "vue";
115
+ import { newPasswordFitsBcrypt } from "../password-policy";
115
116
  const modalUi = {
116
- content: "sm:max-w-[420px]"
117
+ overlay: "basic-auth-modal-overlay",
118
+ content: "basic-auth-modal-content sm:max-w-[420px]"
117
119
  };
118
120
  const props = defineProps({
119
121
  modelValue: { type: Boolean, required: true }
@@ -149,6 +151,9 @@ function validateClientSide() {
149
151
  if (state.password.length > 512) {
150
152
  return "Password is too long.";
151
153
  }
154
+ if (!newPasswordFitsBcrypt(state.password)) {
155
+ return "Password must be 72 UTF-8 bytes or fewer.";
156
+ }
152
157
  if (state.password !== state.confirmPassword) {
153
158
  return "Passwords must match.";
154
159
  }
@@ -185,7 +190,7 @@ async function onSubmit() {
185
190
  const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
186
191
  const serverMessage = readFetchErrorMessage(error);
187
192
  if (statusCode === 400) {
188
- errorMessage.value = serverMessage || "Please check your email and password (min 8 characters).";
193
+ errorMessage.value = serverMessage || "Please check your email and password (8 characters minimum; 72 UTF-8 bytes maximum).";
189
194
  } else if (statusCode === 403) {
190
195
  errorMessage.value = serverMessage || "Registration is restricted for this instance.";
191
196
  } else if (statusCode === 401) {
@@ -85,7 +85,8 @@
85
85
  <script setup>
86
86
  import { computed, reactive, ref } from "vue";
87
87
  const modalUi = {
88
- content: "sm:max-w-[380px]"
88
+ overlay: "basic-auth-modal-overlay",
89
+ content: "basic-auth-modal-content sm:max-w-[380px]"
89
90
  };
90
91
  const props = defineProps({
91
92
  modelValue: { type: Boolean, required: true }
@@ -1,6 +1,9 @@
1
+ import { type AuthUiLayout } from '../lib/auth-ui-layout.js';
1
2
  type __VLS_Props = {
2
3
  email?: string;
3
4
  displayName?: string;
5
+ /** Explicit host layout — preferred over inject (cross-package inject can miss). */
6
+ layout?: AuthUiLayout;
4
7
  };
5
8
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
6
9
  "signed-out": () => any;
@@ -3,9 +3,31 @@
3
3
  v-model:open="isOpen"
4
4
  portal
5
5
  :content="popoverContent"
6
- :ui="{ content: 'z-[280] max-w-[240px] p-0 border-none bg-transparent shadow-none' }"
6
+ :ui="{ content: 'basic-auth-account-popover max-w-[240px] p-0 border-none bg-transparent shadow-none' }"
7
7
  >
8
+ <!-- More sheet: identical anatomy to System .more-row -->
9
+ <button
10
+ v-if="isMoreSheetLayout"
11
+ type="button"
12
+ class="more-row"
13
+ aria-label="Account menu"
14
+ >
15
+ <span class="more-row-icon more-row-icon--admin" aria-hidden="true">
16
+ <UIcon name="lucide:user" />
17
+ </span>
18
+ <span class="more-row-copy">
19
+ <span class="more-row-label">Account</span>
20
+ <span class="more-row-desc">Manage your profile & settings</span>
21
+ </span>
22
+ <UIcon
23
+ name="lucide:chevron-right"
24
+ class="more-row-chevron"
25
+ aria-hidden="true"
26
+ />
27
+ </button>
28
+
8
29
  <UButton
30
+ v-else
9
31
  v-bind="triggerButtonProps"
10
32
  type="button"
11
33
  aria-label="Account menu"
@@ -13,7 +35,9 @@
13
35
  <template #default>
14
36
  <span class="flex flex-col items-center gap-1 w-full">
15
37
  <UIcon name="lucide:user" class="h-[18px] w-[18px]" />
16
- <span class="sidebar-rail-caption text-[7px] uppercase tracking-wider whitespace-nowrap">
38
+ <span
39
+ class="sidebar-rail-caption text-[7px] uppercase tracking-wider whitespace-nowrap"
40
+ >
17
41
  Account
18
42
  </span>
19
43
  </span>
@@ -24,17 +48,22 @@
24
48
  <div
25
49
  class="basic-auth-account-menu w-[220px] rounded-[var(--md-border-radius)] border-[length:var(--md-border-width)] border-[color:var(--md-border-color)] bg-[var(--md-surface)] overflow-hidden theme-shadow"
26
50
  >
27
- <!-- User identity section -->
28
51
  <div class="px-4 pt-4 pb-3">
29
52
  <div class="flex items-center gap-3">
30
- <div class="flex items-center justify-center w-8 h-8 rounded-full bg-[var(--md-success)]/15 text-[var(--md-success)] shrink-0">
53
+ <div
54
+ class="flex items-center justify-center w-8 h-8 rounded-full bg-[var(--md-success)]/15 text-[var(--md-success)] shrink-0"
55
+ >
31
56
  <UIcon name="lucide:user" class="w-4 h-4" />
32
57
  </div>
33
58
  <div class="min-w-0">
34
- <p class="text-[10px] uppercase tracking-wider text-[var(--md-on-surface)]/50 mb-0.5">
59
+ <p
60
+ class="text-[10px] uppercase tracking-wider text-[var(--md-on-surface)]/50 mb-0.5"
61
+ >
35
62
  Signed in as
36
63
  </p>
37
- <p class="text-xs font-medium text-[var(--md-on-surface)] break-all leading-tight">
64
+ <p
65
+ class="text-xs font-medium text-[var(--md-on-surface)] break-all leading-tight"
66
+ >
38
67
  {{ email }}
39
68
  </p>
40
69
  </div>
@@ -43,14 +72,16 @@
43
72
 
44
73
  <div class="h-[var(--md-border-width)] bg-[var(--md-border-color)] mx-3" />
45
74
 
46
- <!-- Actions -->
47
75
  <div class="p-2 space-y-1">
48
76
  <button
49
77
  type="button"
50
78
  class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--md-on-surface)] rounded-[var(--md-border-radius)] hover:bg-[var(--md-surface-hover)] active:bg-[var(--md-surface-active)] transition-colors text-left"
51
79
  @click="onChangePassword"
52
80
  >
53
- <UIcon name="lucide:key-round" class="w-4 h-4 text-[var(--md-on-surface)]/60 shrink-0" />
81
+ <UIcon
82
+ name="lucide:key-round"
83
+ class="w-4 h-4 text-[var(--md-on-surface)]/60 shrink-0"
84
+ />
54
85
  Change Password
55
86
  </button>
56
87
 
@@ -74,9 +105,13 @@ import { computed, inject, ref, unref } from "vue";
74
105
  import {
75
106
  AUTH_UI_POPOVER_CONTENT_KEY
76
107
  } from "../lib/auth-ui-popover";
77
- defineProps({
108
+ import {
109
+ AUTH_UI_LAYOUT_KEY
110
+ } from "../lib/auth-ui-layout";
111
+ const props = defineProps({
78
112
  email: { type: String, required: false },
79
- displayName: { type: String, required: false }
113
+ displayName: { type: String, required: false },
114
+ layout: { type: String, required: false }
80
115
  });
81
116
  const emit = defineEmits(["signed-out", "change-password"]);
82
117
  const isOpen = ref(false);
@@ -91,6 +126,10 @@ const injectedPopoverContent = inject(
91
126
  AUTH_UI_POPOVER_CONTENT_KEY,
92
127
  null
93
128
  );
129
+ const injectedLayout = inject(AUTH_UI_LAYOUT_KEY, null);
130
+ const isMoreSheetLayout = computed(
131
+ () => props.layout === "more-sheet" || (injectedLayout ? unref(injectedLayout) : "rail") === "more-sheet"
132
+ );
94
133
  const popoverContent = computed(() => ({
95
134
  ...DEFAULT_POPOVER_CONTENT,
96
135
  ...injectedPopoverContent ? unref(injectedPopoverContent) : null
@@ -121,3 +160,7 @@ async function onSignOut() {
121
160
  }
122
161
  }
123
162
  </script>
163
+
164
+ <style>
165
+ .basic-auth-account-popover,[data-reka-popper-content-wrapper]:has(.basic-auth-account-menu){z-index:100!important}
166
+ </style>
@@ -1,6 +1,9 @@
1
+ import { type AuthUiLayout } from '../lib/auth-ui-layout.js';
1
2
  type __VLS_Props = {
2
3
  email?: string;
3
4
  displayName?: string;
5
+ /** Explicit host layout — preferred over inject (cross-package inject can miss). */
6
+ layout?: AuthUiLayout;
4
7
  };
5
8
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
6
9
  "signed-out": () => any;
@@ -1,2 +1,7 @@
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>;
1
+ import { type AuthUiLayout } from '../lib/auth-ui-layout.js';
2
+ type __VLS_Props = {
3
+ /** Explicit host layout — preferred over inject (cross-package inject can miss). */
4
+ layout?: AuthUiLayout;
5
+ };
6
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
2
7
  export default _default;
@@ -4,10 +4,32 @@
4
4
  v-if="isSignedIn"
5
5
  :email="sessionUser?.email"
6
6
  :display-name="sessionUser?.displayName"
7
+ :layout="resolvedLayout"
7
8
  @signed-out="onSignedOut"
8
9
  @change-password="changePasswordModalOpen = true"
9
10
  />
10
11
 
12
+ <button
13
+ v-else-if="isMoreSheetLayout"
14
+ type="button"
15
+ class="more-row"
16
+ aria-label="Login"
17
+ @click="signInModalOpen = true"
18
+ >
19
+ <span class="more-row-icon more-row-icon--admin" aria-hidden="true">
20
+ <UIcon name="lucide:log-in" />
21
+ </span>
22
+ <span class="more-row-copy">
23
+ <span class="more-row-label">Login</span>
24
+ <span class="more-row-desc">Manage your profile & settings</span>
25
+ </span>
26
+ <UIcon
27
+ name="lucide:chevron-right"
28
+ class="more-row-chevron"
29
+ aria-hidden="true"
30
+ />
31
+ </button>
32
+
11
33
  <UButton
12
34
  v-else
13
35
  v-bind="loginButtonProps"
@@ -18,7 +40,9 @@
18
40
  <template #default>
19
41
  <span class="flex flex-col items-center gap-1 w-full">
20
42
  <UIcon name="lucide:log-in" class="h-[18px] w-[18px]" />
21
- <span class="sidebar-rail-caption text-[7px] uppercase tracking-wider whitespace-nowrap">
43
+ <span
44
+ class="sidebar-rail-caption text-[7px] uppercase tracking-wider whitespace-nowrap"
45
+ >
22
46
  Login
23
47
  </span>
24
48
  </span>
@@ -45,14 +69,20 @@
45
69
  </template>
46
70
 
47
71
  <script setup>
48
- import { computed, nextTick, onMounted, ref } from "vue";
72
+ import { computed, inject, nextTick, onMounted, ref, unref } from "vue";
49
73
  import { useRuntimeConfig } from "#imports";
50
74
  import { BASIC_AUTH_PROVIDER_ID } from "../lib/constants";
75
+ import {
76
+ AUTH_UI_LAYOUT_KEY
77
+ } from "../lib/auth-ui-layout";
51
78
  import { silentRefreshOnce } from "../lib/silent-refresh.client";
52
79
  import BasicAuthSignInModal from "./BasicAuthSignInModal.client.vue";
53
80
  import BasicAuthRegisterModal from "./BasicAuthRegisterModal.client.vue";
54
81
  import BasicAuthUserMenu from "./BasicAuthUserMenu.client.vue";
55
82
  import BasicAuthChangePasswordModal from "./BasicAuthChangePasswordModal.client.vue";
83
+ const props = defineProps({
84
+ layout: { type: String, required: false }
85
+ });
56
86
  const runtimeConfig = useRuntimeConfig();
57
87
  const signInModalOpen = ref(false);
58
88
  const registerModalOpen = ref(false);
@@ -95,6 +125,16 @@ const isSignedIn = computed(
95
125
  () => session.value?.authenticated === true && session.value.provider === BASIC_AUTH_PROVIDER_ID
96
126
  );
97
127
  const sessionUser = computed(() => session.value?.user);
128
+ const injectedLayout = inject(AUTH_UI_LAYOUT_KEY, null);
129
+ const resolvedLayout = computed(() => {
130
+ if (props.layout === "more-sheet" || props.layout === "rail") {
131
+ return props.layout;
132
+ }
133
+ return injectedLayout ? unref(injectedLayout) : "rail";
134
+ });
135
+ const isMoreSheetLayout = computed(
136
+ () => resolvedLayout.value === "more-sheet"
137
+ );
98
138
  const loginButtonProps = {
99
139
  block: true,
100
140
  variant: "ghost",
@@ -135,3 +175,7 @@ onMounted(() => {
135
175
  void refreshSession();
136
176
  });
137
177
  </script>
178
+
179
+ <style>
180
+ .basic-auth-modal-content,.basic-auth-modal-overlay{z-index:100!important}
181
+ </style>
@@ -1,2 +1,7 @@
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>;
1
+ import { type AuthUiLayout } from '../lib/auth-ui-layout.js';
2
+ type __VLS_Props = {
3
+ /** Explicit host layout — preferred over inject (cross-package inject can miss). */
4
+ layout?: AuthUiLayout;
5
+ };
6
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
2
7
  export default _default;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Host apps can inject a layout mode so auth triggers match the surface
3
+ * (sidebar rail tiles vs mobile More sheet list rows).
4
+ */
5
+ export type AuthUiLayout = 'rail' | 'more-sheet';
6
+ /** String key so host app + provider package can share without a linked import. */
7
+ export declare const AUTH_UI_LAYOUT_KEY = "or3:auth-ui-layout";
@@ -0,0 +1 @@
1
+ export const AUTH_UI_LAYOUT_KEY = "or3:auth-ui-layout";
@@ -0,0 +1,2 @@
1
+ export declare const MAX_BCRYPT_PASSWORD_BYTES = 72;
2
+ export declare function newPasswordFitsBcrypt(password: string): boolean;
@@ -0,0 +1,4 @@
1
+ export const MAX_BCRYPT_PASSWORD_BYTES = 72;
2
+ export function newPasswordFitsBcrypt(password) {
3
+ return new TextEncoder().encode(password).length <= MAX_BCRYPT_PASSWORD_BYTES;
4
+ }
@@ -18,6 +18,9 @@ export async function parseBodyWithSchema(event, schema) {
18
18
  const firstIssue = result.error.issues[0];
19
19
  const path = firstIssue?.path?.join(".") || "input";
20
20
  const detail = firstIssue?.message || "Invalid request";
21
+ if (detail === "Password must be 72 UTF-8 bytes or fewer.") {
22
+ throw createError({ statusCode: 400, statusMessage: detail });
23
+ }
21
24
  if (path === "password" || path === "confirmPassword") {
22
25
  throw createError({
23
26
  statusCode: 400,
@@ -11,11 +11,16 @@ import {
11
11
  import { enforceMutationOriginPolicy } from "../../lib/request-security.js";
12
12
  import { basicAuthProvider } from "../../auth/basic-auth-provider.js";
13
13
  import { assertBasicAuthReady, noStore, parseBodyWithSchema } from "./_helpers.js";
14
+ import { newPasswordFitsBcrypt } from "../../../password-policy.js";
14
15
  const changePasswordSchema = z.object({
15
16
  // Existing accounts may have legacy short passwords; only new password is length-enforced.
16
17
  currentPassword: z.string().min(1).max(512),
17
- newPassword: z.string().min(8).max(512),
18
- confirmNewPassword: z.string().min(8).max(512)
18
+ newPassword: z.string().min(8).max(512).refine(newPasswordFitsBcrypt, {
19
+ message: "Password must be 72 UTF-8 bytes or fewer."
20
+ }),
21
+ confirmNewPassword: z.string().min(8).max(512).refine(newPasswordFitsBcrypt, {
22
+ message: "Password must be 72 UTF-8 bytes or fewer."
23
+ })
19
24
  }).refine((input) => input.newPassword === input.confirmNewPassword, {
20
25
  message: "Passwords must match"
21
26
  });
@@ -23,11 +23,16 @@ import {
23
23
  getSessionMetadataFromEvent
24
24
  } from "../../lib/session-store.js";
25
25
  import { enforceMutationOriginPolicy } from "../../lib/request-security.js";
26
+ import { newPasswordFitsBcrypt } from "../../../password-policy.js";
26
27
  import { assertBasicAuthReady, noStore, parseBodyWithSchema } from "./_helpers.js";
27
28
  const registerSchema = z.object({
28
29
  email: z.string().email().max(320),
29
- password: z.string().min(8).max(512),
30
- confirmPassword: z.string().min(8).max(512),
30
+ password: z.string().min(8).max(512).refine(newPasswordFitsBcrypt, {
31
+ message: "Password must be 72 UTF-8 bytes or fewer."
32
+ }),
33
+ confirmPassword: z.string().min(8).max(512).refine(newPasswordFitsBcrypt, {
34
+ message: "Password must be 72 UTF-8 bytes or fewer."
35
+ }),
31
36
  displayName: z.string().trim().min(1).max(120).optional(),
32
37
  inviteToken: z.string().trim().min(1).max(4096).optional()
33
38
  }).refine((value) => value.password === value.confirmPassword, {
@@ -1,4 +1,5 @@
1
1
  import { defineEventHandler } from "h3";
2
+ import { revokeHostActivationsForUser } from "~~/server/utils/plugins/isolation/activation-registry";
2
3
  import { clearAuthCookies } from "../../lib/cookies.js";
3
4
  import {
4
5
  getAccessTokenFromEvent,
@@ -7,26 +8,60 @@ import {
7
8
  verifyRefreshToken
8
9
  } from "../../lib/jwt.js";
9
10
  import { enforceBasicAuthRateLimit } from "../../lib/rate-limit.js";
10
- import { revokeSessionById } from "../../lib/session-store.js";
11
+ import {
12
+ findAccountById,
13
+ findUsableSessionInChain,
14
+ revokeSessionChain
15
+ } from "../../lib/session-store.js";
11
16
  import { enforceMutationOriginPolicy } from "../../lib/request-security.js";
12
17
  import { assertBasicAuthReady, noStore } from "./_helpers.js";
18
+ async function resolveSignOutUserId(event) {
19
+ const accessToken = getAccessTokenFromEvent(event);
20
+ if (accessToken) {
21
+ const claims = await verifyAccessToken(accessToken);
22
+ if (claims && findUsableSessionInChain(claims.sid)) {
23
+ const account = findAccountById(claims.sub);
24
+ if (account && account.token_version === claims.ver) {
25
+ return account.id;
26
+ }
27
+ }
28
+ }
29
+ const refreshToken = getRefreshTokenFromEvent(event);
30
+ if (refreshToken) {
31
+ const claims = await verifyRefreshToken(refreshToken);
32
+ if (claims && findUsableSessionInChain(claims.sid)) {
33
+ const account = findAccountById(claims.sub);
34
+ if (account && account.token_version === claims.ver) {
35
+ return account.id;
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
13
41
  export async function handleSignOut(event) {
14
42
  assertBasicAuthReady(event);
15
43
  noStore(event);
16
44
  enforceMutationOriginPolicy(event);
17
45
  enforceBasicAuthRateLimit(event, "basic-auth:sign-out");
46
+ try {
47
+ const userId = await resolveSignOutUserId(event);
48
+ if (userId) {
49
+ revokeHostActivationsForUser(userId, "logout");
50
+ }
51
+ } catch {
52
+ }
18
53
  const refreshToken = getRefreshTokenFromEvent(event);
19
54
  if (refreshToken) {
20
55
  const refreshClaims = await verifyRefreshToken(refreshToken);
21
56
  if (refreshClaims) {
22
- revokeSessionById(refreshClaims.sid);
57
+ revokeSessionChain(refreshClaims.sid);
23
58
  }
24
59
  }
25
60
  const accessToken = getAccessTokenFromEvent(event);
26
61
  if (accessToken) {
27
62
  const accessClaims = await verifyAccessToken(accessToken);
28
63
  if (accessClaims) {
29
- revokeSessionById(accessClaims.sid);
64
+ revokeSessionChain(accessClaims.sid);
30
65
  }
31
66
  }
32
67
  clearAuthCookies(event);
@@ -1,4 +1,6 @@
1
1
  import { type H3Event } from 'h3';
2
+ export declare function accessCookieName(): string;
3
+ export declare function refreshCookieName(): string;
2
4
  export declare function setAccessCookie(event: H3Event, token: string, maxAgeSeconds: number): void;
3
5
  export declare function setRefreshCookie(event: H3Event, token: string, maxAgeSeconds: number): void;
4
6
  export declare function clearAuthCookies(event: H3Event): void;
@@ -9,6 +9,14 @@ import {
9
9
  function isProduction() {
10
10
  return process.env.NODE_ENV === "production";
11
11
  }
12
+ export function accessCookieName() {
13
+ const scope = process.env.OR3_PLUGIN_DEV_COOKIE_SCOPE;
14
+ return scope ? `${ACCESS_COOKIE_NAME}_${scope}` : ACCESS_COOKIE_NAME;
15
+ }
16
+ export function refreshCookieName() {
17
+ const scope = process.env.OR3_PLUGIN_DEV_COOKIE_SCOPE;
18
+ return scope ? `${REFRESH_COOKIE_NAME}_${scope}` : REFRESH_COOKIE_NAME;
19
+ }
12
20
  function refreshCookieBase() {
13
21
  return {
14
22
  httpOnly: true,
@@ -17,7 +25,7 @@ function refreshCookieBase() {
17
25
  };
18
26
  }
19
27
  export function setAccessCookie(event, token, maxAgeSeconds) {
20
- setCookie(event, ACCESS_COOKIE_NAME, token, {
28
+ setCookie(event, accessCookieName(), token, {
21
29
  httpOnly: true,
22
30
  sameSite: "lax",
23
31
  secure: isProduction(),
@@ -26,28 +34,28 @@ export function setAccessCookie(event, token, maxAgeSeconds) {
26
34
  });
27
35
  }
28
36
  export function setRefreshCookie(event, token, maxAgeSeconds) {
29
- deleteCookie(event, REFRESH_COOKIE_NAME, {
37
+ deleteCookie(event, refreshCookieName(), {
30
38
  ...refreshCookieBase(),
31
39
  path: LEGACY_REFRESH_COOKIE_PATH
32
40
  });
33
- setCookie(event, REFRESH_COOKIE_NAME, token, {
41
+ setCookie(event, refreshCookieName(), token, {
34
42
  ...refreshCookieBase(),
35
43
  path: REFRESH_COOKIE_PATH,
36
44
  maxAge: maxAgeSeconds
37
45
  });
38
46
  }
39
47
  export function clearAuthCookies(event) {
40
- deleteCookie(event, ACCESS_COOKIE_NAME, {
48
+ deleteCookie(event, accessCookieName(), {
41
49
  httpOnly: true,
42
50
  sameSite: "lax",
43
51
  secure: isProduction(),
44
52
  path: ACCESS_COOKIE_PATH
45
53
  });
46
- deleteCookie(event, REFRESH_COOKIE_NAME, {
54
+ deleteCookie(event, refreshCookieName(), {
47
55
  ...refreshCookieBase(),
48
56
  path: REFRESH_COOKIE_PATH
49
57
  });
50
- deleteCookie(event, REFRESH_COOKIE_NAME, {
58
+ deleteCookie(event, refreshCookieName(), {
51
59
  ...refreshCookieBase(),
52
60
  path: LEGACY_REFRESH_COOKIE_PATH
53
61
  });
@@ -1,7 +1,7 @@
1
1
  import { randomUUID, createHash } from "node:crypto";
2
2
  import { getCookie } from "h3";
3
3
  import jwt from "jsonwebtoken";
4
- import { ACCESS_COOKIE_NAME, REFRESH_COOKIE_NAME } from "../../lib/constants.js";
4
+ import { accessCookieName, refreshCookieName } from "./cookies.js";
5
5
  import { getBasicAuthConfig } from "./config.js";
6
6
  export async function signAccessToken(input) {
7
7
  const config = getBasicAuthConfig();
@@ -67,10 +67,10 @@ export async function verifyRefreshToken(token) {
67
67
  }
68
68
  }
69
69
  export function getAccessTokenFromEvent(event) {
70
- return getCookie(event, ACCESS_COOKIE_NAME) ?? null;
70
+ return getCookie(event, accessCookieName()) ?? null;
71
71
  }
72
72
  export function getRefreshTokenFromEvent(event) {
73
- return getCookie(event, REFRESH_COOKIE_NAME) ?? null;
73
+ return getCookie(event, refreshCookieName()) ?? null;
74
74
  }
75
75
  export function hashRefreshToken(token) {
76
76
  return createHash("sha256").update(token).digest("hex");
@@ -1,6 +1,10 @@
1
1
  import bcrypt from "bcryptjs";
2
+ import { newPasswordFitsBcrypt } from "../../password-policy.js";
2
3
  const PASSWORD_COST = 12;
3
4
  export async function hashPassword(password) {
5
+ if (!newPasswordFitsBcrypt(password)) {
6
+ throw new RangeError("New passwords must be 72 UTF-8 bytes or fewer.");
7
+ }
4
8
  return await bcrypt.hash(password, PASSWORD_COST);
5
9
  }
6
10
  export async function verifyPassword(password, hash) {
@@ -61,6 +61,14 @@ export declare function ensureBootstrapAccount(input: CreateAccountInput): Basic
61
61
  export declare function createAuthSession(input: CreateSessionInput): BasicAuthSession;
62
62
  export declare function findSessionById(sessionId: string): BasicAuthSession | null;
63
63
  export declare function revokeSessionById(sessionId: string, revokedAtMs?: number): void;
64
+ /**
65
+ * Revoke a session and every successor it was rotated into, clearing rotation
66
+ * grace on each. A replayed rotated refresh token therefore cannot recover its
67
+ * successor after sign-out.
68
+ */
69
+ export declare function revokeSessionChain(sessionId: string, revokedAtMs?: number): void;
70
+ /** First usable session walking from `sessionId` through rotation successors. */
71
+ export declare function findUsableSessionInChain(sessionId: string): BasicAuthSession | null;
64
72
  export declare function revokeAllSessionsForAccount(accountId: string, revokedAtMs?: number): void;
65
73
  export declare function rotateSession(input: RotationInput): RotationResult;
66
74
  export declare function updatePasswordAndRevokeSessions(accountId: string, newPasswordHash: string): void;
@@ -140,6 +140,36 @@ export function revokeSessionById(sessionId, revokedAtMs = Date.now()) {
140
140
  "UPDATE basic_auth_sessions SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?"
141
141
  ).run(revokedAtMs, sessionId);
142
142
  }
143
+ export function revokeSessionChain(sessionId, revokedAtMs = Date.now()) {
144
+ const db = getBasicAuthDb();
145
+ db.transaction(() => {
146
+ const visited = /* @__PURE__ */ new Set();
147
+ let currentId = sessionId;
148
+ while (currentId && !visited.has(currentId)) {
149
+ visited.add(currentId);
150
+ const session = findSessionById(currentId);
151
+ if (!session) break;
152
+ db.prepare(
153
+ `UPDATE basic_auth_sessions
154
+ SET revoked_at = COALESCE(revoked_at, ?),
155
+ rotation_grace_until = NULL,
156
+ rotation_grace_refresh_token = NULL
157
+ WHERE id = ?`
158
+ ).run(revokedAtMs, currentId);
159
+ currentId = session.replaced_by_session_id;
160
+ }
161
+ })();
162
+ }
163
+ export function findUsableSessionInChain(sessionId) {
164
+ const visited = /* @__PURE__ */ new Set();
165
+ let current = findSessionById(sessionId);
166
+ while (current && !visited.has(current.id)) {
167
+ if (isSessionUsable(current)) return current;
168
+ visited.add(current.id);
169
+ current = current.replaced_by_session_id ? findSessionById(current.replaced_by_session_id) : null;
170
+ }
171
+ return null;
172
+ }
143
173
  export function revokeAllSessionsForAccount(accountId, revokedAtMs = Date.now()) {
144
174
  const db = getBasicAuthDb();
145
175
  db.prepare(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "or3-provider-basic-auth",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Saluana/or3-provider-basic-auth.git"