or3-provider-basic-auth 0.0.9 → 0.0.11

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 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
 
@@ -68,10 +69,12 @@ Strict-mode behavior:
68
69
  - Canonical OR3 users/workspaces are still resolved by the selected `AuthWorkspaceStore`.
69
70
  - 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.
70
71
  - The selected `AuthWorkspaceStore` must support atomic invite provisioning; internal user/auth mapping, membership, and invite consumption are committed together by that provider.
72
+ - Invite links (`/?invite=TOKEN`, copied from Admin → Workspaces, or carried in the lock page's `next`) open registration for signed-out visitors with the token filled in. The token is removed from the address bar on first read and kept in memory for that page; only one mounted auth control (sidebar, mobile More sheet or lock page) opens registration per link. On mobile the account control lives in More, so registration opens when the visitor opens More. This invite-link handoff requires published `or3-provider-basic-auth@0.0.11` or later.
71
73
  - Basic Auth account and initial refresh-session rows are created in one local SQLite transaction.
72
74
  - Access JWTs are short-lived and validated by `basicAuthProvider.getSession(event)`.
73
75
  - Refresh tokens are rotated and hashed at rest; replay attempts revoke active sessions.
74
76
  - The refresh cookie is scoped to `/api` so `/api/auth/session` can transparently rotate expired access tokens during session resolution.
77
+ - 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
78
  - The SQLite DB file and directory are hardened to mode `0600`/`0700`; the DB uses WAL journaling.
76
79
 
77
80
  ## Runtime entrypoints
@@ -90,6 +93,8 @@ Strict-mode behavior:
90
93
  | `src/runtime/plugins/auth-status.client.ts` | Client auth-status resolver + silent-refresh recovery |
91
94
  | `src/runtime/components/*.client.vue` | Provider-owned auth UI (modals, user menu, sidebar button, lock page) |
92
95
 
96
+ 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.
97
+
93
98
  The registered sidebar auth adapter accepts `layout="rail"` (default) and
94
99
  `layout="more-sheet"`. In the More sheet it renders Account/Login with the
95
100
  host's shared full-width `.more-row` structure, while keeping the existing
@@ -134,3 +139,19 @@ bun run type-check
134
139
  bun run test
135
140
  bun run build
136
141
  ```
142
+
143
+
144
+ ### Testing local changes in OR3 Chat
145
+
146
+ With this repository beside `or3-chat`, run `bun install` here once, then
147
+ `bun run dev:ssr` from Chat. Chat's dev wrapper rebuilds the local provider and
148
+ prints its selected path; restart it after provider edits. Missing repositories
149
+ or failed builds fall back to installed packages with a warning.
150
+ `OR3_LOCAL_PROVIDERS=false` disables local selection. Production builds use the
151
+ installed package, so local development does not publish these changes.
152
+
153
+ Basic Auth's database always uses `better-sqlite3`, independently of the sync
154
+ provider's SQLite driver. If the source launcher selects Bun for sync, this
155
+ native binding must also work under the project-pinned Bun version. The launcher
156
+ checks it in that runtime before starting Nuxt and reports incompatible bindings
157
+ 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.9",
4
+ "version": "0.0.11",
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,6 +83,7 @@
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
88
  overlay: "basic-auth-modal-overlay",
88
89
  content: "basic-auth-modal-content sm:max-w-[400px]"
@@ -108,6 +109,14 @@ function close() {
108
109
  emit("update:modelValue", false);
109
110
  }
110
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
+ }
111
120
  if (state.newPassword !== state.confirmNewPassword) {
112
121
  errorMessage.value = "Passwords do not match";
113
122
  return;
@@ -23,18 +23,25 @@
23
23
 
24
24
  <BasicAuthRegisterModal
25
25
  v-model="registerModalOpen"
26
+ :invite-token="inviteToken"
26
27
  @registered="handleRegistered"
27
28
  />
28
29
  </div>
29
30
  </template>
30
31
 
31
32
  <script setup>
32
- import { nextTick, ref } from "vue";
33
+ import { nextTick, onMounted, ref } from "vue";
33
34
  import BasicAuthRegisterModal from "./BasicAuthRegisterModal.client.vue";
34
35
  import BasicAuthSignInModal from "./BasicAuthSignInModal.client.vue";
36
+ import { claimInviteLinkAutoOpen, readInviteLinkToken } from "../lib/invite-link.client";
35
37
  const loginIcon = "i-lucide-log-in";
36
38
  const signInModalOpen = ref(false);
37
39
  const registerModalOpen = ref(false);
40
+ const inviteToken = ref(null);
41
+ onMounted(() => {
42
+ inviteToken.value = readInviteLinkToken();
43
+ if (claimInviteLinkAutoOpen()) registerModalOpen.value = true;
44
+ });
38
45
  function notifyAuthSessionChanged() {
39
46
  if (typeof window === "undefined") return;
40
47
  window.dispatchEvent(new CustomEvent("or3:auth-session-changed"));
@@ -1,5 +1,7 @@
1
1
  type __VLS_Props = {
2
2
  modelValue: boolean;
3
+ /** Token from an invite link; filled in when the form opens. */
4
+ inviteToken?: string | null;
3
5
  };
4
6
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
7
  "update:modelValue": (value: boolean) => any;
@@ -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"
@@ -73,7 +73,10 @@
73
73
  />
74
74
  </UFormField>
75
75
 
76
- <UFormField label="Invite token (optional)" name="inviteToken">
76
+ <UFormField
77
+ :label="props.inviteToken ? 'Invite token (from your invite link)' : 'Invite token (optional)'"
78
+ name="inviteToken"
79
+ >
77
80
  <UInput
78
81
  v-model="state.inviteToken"
79
82
  type="text"
@@ -111,13 +114,15 @@
111
114
  </template>
112
115
 
113
116
  <script setup>
114
- import { computed, reactive, ref } from "vue";
117
+ import { computed, reactive, ref, watch } from "vue";
118
+ import { newPasswordFitsBcrypt } from "../password-policy";
115
119
  const modalUi = {
116
120
  overlay: "basic-auth-modal-overlay",
117
121
  content: "basic-auth-modal-content sm:max-w-[420px]"
118
122
  };
119
123
  const props = defineProps({
120
- modelValue: { type: Boolean, required: true }
124
+ modelValue: { type: Boolean, required: true },
125
+ inviteToken: { type: [String, null], required: false }
121
126
  });
122
127
  const emit = defineEmits(["update:modelValue", "registered"]);
123
128
  const state = reactive({
@@ -135,6 +140,9 @@ const isOpen = computed({
135
140
  emit("update:modelValue", value);
136
141
  }
137
142
  });
143
+ watch(() => [props.modelValue, props.inviteToken], ([open, token]) => {
144
+ if (open && token && !state.inviteToken.trim()) state.inviteToken = token;
145
+ }, { immediate: true });
138
146
  function close() {
139
147
  errorMessage.value = "";
140
148
  emit("update:modelValue", false);
@@ -150,6 +158,9 @@ function validateClientSide() {
150
158
  if (state.password.length > 512) {
151
159
  return "Password is too long.";
152
160
  }
161
+ if (!newPasswordFitsBcrypt(state.password)) {
162
+ return "Password must be 72 UTF-8 bytes or fewer.";
163
+ }
153
164
  if (state.password !== state.confirmPassword) {
154
165
  return "Passwords must match.";
155
166
  }
@@ -186,7 +197,7 @@ async function onSubmit() {
186
197
  const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
187
198
  const serverMessage = readFetchErrorMessage(error);
188
199
  if (statusCode === 400) {
189
- errorMessage.value = serverMessage || "Please check your email and password (min 8 characters).";
200
+ errorMessage.value = serverMessage || "Please check your email and password (8 characters minimum; 72 UTF-8 bytes maximum).";
190
201
  } else if (statusCode === 403) {
191
202
  errorMessage.value = serverMessage || "Registration is restricted for this instance.";
192
203
  } else if (statusCode === 401) {
@@ -1,5 +1,7 @@
1
1
  type __VLS_Props = {
2
2
  modelValue: boolean;
3
+ /** Token from an invite link; filled in when the form opens. */
4
+ inviteToken?: string | null;
3
5
  };
4
6
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
7
  "update:modelValue": (value: boolean) => any;
@@ -57,6 +57,7 @@
57
57
 
58
58
  <BasicAuthRegisterModal
59
59
  v-model="registerModalOpen"
60
+ :invite-token="inviteToken"
60
61
  @registered="onRegistered"
61
62
  />
62
63
 
@@ -76,6 +77,7 @@ import {
76
77
  AUTH_UI_LAYOUT_KEY
77
78
  } from "../lib/auth-ui-layout";
78
79
  import { silentRefreshOnce } from "../lib/silent-refresh.client";
80
+ import { claimInviteLinkAutoOpen, readInviteLinkToken } from "../lib/invite-link.client";
79
81
  import BasicAuthSignInModal from "./BasicAuthSignInModal.client.vue";
80
82
  import BasicAuthRegisterModal from "./BasicAuthRegisterModal.client.vue";
81
83
  import BasicAuthUserMenu from "./BasicAuthUserMenu.client.vue";
@@ -171,8 +173,13 @@ async function onRegistered() {
171
173
  await refreshSession({ allowSilentRefresh: false });
172
174
  notifyAuthSessionChanged();
173
175
  }
174
- onMounted(() => {
175
- void refreshSession();
176
+ const inviteToken = ref(null);
177
+ onMounted(async () => {
178
+ inviteToken.value = readInviteLinkToken();
179
+ await refreshSession();
180
+ if (!isSignedIn.value && isBasicAuthProvider.value && claimInviteLinkAutoOpen()) {
181
+ registerModalOpen.value = true;
182
+ }
176
183
  });
177
184
  </script>
178
185
 
@@ -0,0 +1,4 @@
1
+ export declare function readInviteLinkToken(): string | null;
2
+ /** True for the one caller that should open registration for this link. */
3
+ export declare function claimInviteLinkAutoOpen(): boolean;
4
+ export declare function resetInviteLinkForTests(): void;
@@ -0,0 +1,39 @@
1
+ let token;
2
+ let autoOpenClaimed = false;
3
+ function takeInvite(url) {
4
+ const direct = url.searchParams.get("invite")?.trim();
5
+ if (direct) {
6
+ url.searchParams.delete("invite");
7
+ return direct;
8
+ }
9
+ const next = url.searchParams.get("next");
10
+ if (!next) return null;
11
+ const target = new URL(next, url.origin);
12
+ const carried = target.searchParams.get("invite")?.trim();
13
+ if (!carried) return null;
14
+ target.searchParams.delete("invite");
15
+ url.searchParams.set("next", target.pathname + target.search + target.hash);
16
+ return carried;
17
+ }
18
+ export function readInviteLinkToken() {
19
+ if (token !== void 0) return token;
20
+ token = null;
21
+ if (typeof window === "undefined") return token;
22
+ try {
23
+ const url = new URL(window.location.href);
24
+ token = takeInvite(url);
25
+ if (token) window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
26
+ } catch {
27
+ token = null;
28
+ }
29
+ return token;
30
+ }
31
+ export function claimInviteLinkAutoOpen() {
32
+ if (!readInviteLinkToken() || autoOpenClaimed) return false;
33
+ autoOpenClaimed = true;
34
+ return true;
35
+ }
36
+ export function resetInviteLinkForTests() {
37
+ token = void 0;
38
+ autoOpenClaimed = false;
39
+ }
@@ -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.9",
3
+ "version": "0.0.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Saluana/or3-provider-basic-auth.git"