@hyperfixation/auth 0.1.0
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/LICENSE +21 -0
- package/dist/bootstrap.d.ts +48 -0
- package/dist/bootstrap.js +109 -0
- package/dist/factory.d.ts +3372 -0
- package/dist/factory.js +81 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/policy.d.ts +65 -0
- package/dist/policy.js +68 -0
- package/dist/require-session.d.ts +34 -0
- package/dist/require-session.js +42 -0
- package/dist/reset-second-factor.d.ts +37 -0
- package/dist/reset-second-factor.js +60 -0
- package/dist/session.d.ts +34 -0
- package/dist/session.js +31 -0
- package/package.json +50 -0
package/dist/factory.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { hfAccount, hfInvitation, hfMember, hfOrganization, hfPasskey, hfSession, hfUser, hfVerification, } from "@hyperfixation/db";
|
|
2
|
+
import { passkey } from "@better-auth/passkey";
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
|
+
import { admin, emailOTP, organization } from "better-auth/plugins";
|
|
6
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
7
|
+
import { ADMIN_ROLE } from "./policy.js";
|
|
8
|
+
import { sessionFactorForPath } from "./session.js";
|
|
9
|
+
/** Model name → the `hf_*` table it lives in. Every better-auth table is prefixed, forever. */
|
|
10
|
+
export const AUTH_SCHEMA = {
|
|
11
|
+
user: hfUser,
|
|
12
|
+
session: hfSession,
|
|
13
|
+
account: hfAccount,
|
|
14
|
+
verification: hfVerification,
|
|
15
|
+
passkey: hfPasskey,
|
|
16
|
+
organization: hfOrganization,
|
|
17
|
+
member: hfMember,
|
|
18
|
+
invitation: hfInvitation,
|
|
19
|
+
};
|
|
20
|
+
/** Promotes a code-factor session in place; see `upgradeSessionFactor`. */
|
|
21
|
+
const UPGRADE_SESSION_FACTOR_STATEMENT = "UPDATE hf_session SET factor = 'passkey', updated_at = now() WHERE token = $1 AND factor = 'code'";
|
|
22
|
+
/**
|
|
23
|
+
* The better-auth instance, wired to the seven `hf_*` tables chunk 2 created.
|
|
24
|
+
*
|
|
25
|
+
* `disableSignUp: true` on the OTP plugin is the shape of the whole product: there is no
|
|
26
|
+
* self-serve path into an app. A user exists because `bootstrapAdmin` or an existing admin put
|
|
27
|
+
* them there, and a code sent to an address with no `hf_user` row signs nobody in.
|
|
28
|
+
*
|
|
29
|
+
* `emailAndPassword` is left off entirely rather than configured off — the `password` column on
|
|
30
|
+
* `hf_account` is better-auth's, not a supported credential here — and the only two ways to
|
|
31
|
+
* hold a session are an emailed code and a passkey, which is exactly what `factor` records.
|
|
32
|
+
*/
|
|
33
|
+
export function createAuth(options) {
|
|
34
|
+
const db = drizzle(options.pool, { schema: AUTH_SCHEMA });
|
|
35
|
+
return betterAuth({
|
|
36
|
+
database: drizzleAdapter(db, { provider: "pg", schema: AUTH_SCHEMA }),
|
|
37
|
+
baseURL: options.baseURL,
|
|
38
|
+
secret: options.secret,
|
|
39
|
+
trustedOrigins: options.trustedOrigins,
|
|
40
|
+
session: {
|
|
41
|
+
additionalFields: {
|
|
42
|
+
// `input: false` — the factor is stamped from the endpoint that minted the session,
|
|
43
|
+
// never from anything a client sends.
|
|
44
|
+
factor: { type: "string", required: false, input: false, defaultValue: "code" },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
databaseHooks: {
|
|
48
|
+
session: {
|
|
49
|
+
create: {
|
|
50
|
+
before: async (session, ctx) => ({
|
|
51
|
+
data: { ...session, factor: sessionFactorForPath(ctx?.path) },
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
plugins: [
|
|
57
|
+
emailOTP({ sendVerificationOTP: options.sendVerificationOTP, disableSignUp: true }),
|
|
58
|
+
passkey({ rpID: options.rpID, rpName: options.rpName, origin: options.origin }),
|
|
59
|
+
admin({ defaultRole: "member", adminRoles: [ADMIN_ROLE] }),
|
|
60
|
+
// `invitationLimit: 0` because an invitation ends in a sign-up, and there is no sign-up:
|
|
61
|
+
// an invited stranger has nowhere to land. `hf_invitation` exists all the same — the
|
|
62
|
+
// plugin refuses to initialise with a model it cannot reach — so turning invitations on
|
|
63
|
+
// later is a config change, not a migration.
|
|
64
|
+
organization({ invitationLimit: 0 }),
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Promotes the session that just enrolled a passkey, so the user is not sent back through the
|
|
70
|
+
* email code to reach the app they enrolled from.
|
|
71
|
+
*
|
|
72
|
+
* Registration is not authentication, so it does not go through `sessionFactorForPath`: this is
|
|
73
|
+
* a deliberate second entry into `passkey`, taken only after `/passkey/verify-registration`
|
|
74
|
+
* succeeded for this very session. It grants nothing an immediate passkey sign-in would not —
|
|
75
|
+
* whoever enrolled the authenticator can use it — and the `factor = 'code'` predicate makes it
|
|
76
|
+
* a no-op on a session that already holds the stronger factor.
|
|
77
|
+
*/
|
|
78
|
+
export async function upgradeSessionFactor(pool, sessionToken) {
|
|
79
|
+
const result = await pool.query(UPGRADE_SESSION_FACTOR_STATEMENT, [sessionToken]);
|
|
80
|
+
return result.rowCount === 1;
|
|
81
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createAuth, upgradeSessionFactor, AUTH_SCHEMA, type CreateAuthOptions, type HyperfixationAuth, } from "./factory.js";
|
|
2
|
+
export { hasRole, sessionFactorForPath, sessionFactors, EMAIL_OTP_SIGN_IN_PATH, PASSKEY_AUTHENTICATION_PATH, PASSKEY_REGISTRATION_PATH, type AuthSession, type SessionFactor, type SessionUser, } from "./session.js";
|
|
3
|
+
export { evaluateAccess, routeAreaOf, ADMIN_AREA, ADMIN_ROLE, AUTH_AREA, DEFAULT_SIGN_IN_PATH, DEFAULT_STEP_UP_PATH, type AccessDecision, type AccessPaths, type AccessRefusal, type AccessRequest, type RouteArea, } from "./policy.js";
|
|
4
|
+
export { createSessionGuard, AccessRefused, type RequireSession, type RequireSessionOptions, type SessionGuardOptions, } from "./require-session.js";
|
|
5
|
+
export { bootstrapAdmin, BootstrapRefused, BOOTSTRAPPED_MARKER, BOOTSTRAP_EMAIL_ENV, type BootstrapAdminOptions, type BootstrapRefusal, type BootstrapResult, } from "./bootstrap.js";
|
|
6
|
+
export { createResetSecondFactorAction, resetSecondFactor, SECOND_FACTOR_RESET_MARKER, type ResetSecondFactorAction, type ResetSecondFactorActionOptions, type ResetSecondFactorOptions, type ResetSecondFactorResult, } from "./reset-second-factor.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createAuth, upgradeSessionFactor, AUTH_SCHEMA, } from "./factory.js";
|
|
2
|
+
export { hasRole, sessionFactorForPath, sessionFactors, EMAIL_OTP_SIGN_IN_PATH, PASSKEY_AUTHENTICATION_PATH, PASSKEY_REGISTRATION_PATH, } from "./session.js";
|
|
3
|
+
export { evaluateAccess, routeAreaOf, ADMIN_AREA, ADMIN_ROLE, AUTH_AREA, DEFAULT_SIGN_IN_PATH, DEFAULT_STEP_UP_PATH, } from "./policy.js";
|
|
4
|
+
export { createSessionGuard, AccessRefused, } from "./require-session.js";
|
|
5
|
+
export { bootstrapAdmin, BootstrapRefused, BOOTSTRAPPED_MARKER, BOOTSTRAP_EMAIL_ENV, } from "./bootstrap.js";
|
|
6
|
+
export { createResetSecondFactorAction, resetSecondFactor, SECOND_FACTOR_RESET_MARKER, } from "./reset-second-factor.js";
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AuthSession, SessionFactor } from "./session.js";
|
|
2
|
+
/** Where a code-factor session is allowed to be, and where an unauthenticated one is sent. */
|
|
3
|
+
export declare const AUTH_AREA = "auth";
|
|
4
|
+
/** The area that does not exist for anyone who cannot enter it. */
|
|
5
|
+
export declare const ADMIN_AREA = "admin";
|
|
6
|
+
export declare const DEFAULT_SIGN_IN_PATH = "/auth/sign-in";
|
|
7
|
+
export declare const DEFAULT_STEP_UP_PATH = "/auth/passkey";
|
|
8
|
+
/** The role `/admin/*` requires, and the one `bootstrapAdmin` grants. */
|
|
9
|
+
export declare const ADMIN_ROLE = "admin";
|
|
10
|
+
export type RouteArea = "auth" | "admin" | "app";
|
|
11
|
+
export type AccessRefusal = "no-session" | "banned" | "code-factor" | "missing-role";
|
|
12
|
+
export type AccessDecision = {
|
|
13
|
+
outcome: "allow";
|
|
14
|
+
session: AuthSession;
|
|
15
|
+
} | {
|
|
16
|
+
outcome: "redirect";
|
|
17
|
+
to: string;
|
|
18
|
+
refusal: AccessRefusal;
|
|
19
|
+
} | {
|
|
20
|
+
outcome: "not-found";
|
|
21
|
+
refusal: AccessRefusal;
|
|
22
|
+
};
|
|
23
|
+
export interface AccessPaths {
|
|
24
|
+
signInPath?: string;
|
|
25
|
+
stepUpPath?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface AccessRequest extends AccessPaths {
|
|
28
|
+
session: AuthSession | null | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The route being entered. A layout or route handler knows it; a server action does not, and
|
|
31
|
+
* omitting it is the strict case — see `evaluateAccess`.
|
|
32
|
+
*/
|
|
33
|
+
pathname?: string;
|
|
34
|
+
/** Raise the bar above what the route alone demands. Never lowers `/admin/*`. */
|
|
35
|
+
factor?: SessionFactor;
|
|
36
|
+
role?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The first segment, with a leading `/api` stripped, so `/api/admin/users` is admin territory
|
|
40
|
+
* to a route handler exactly as `/admin/users` is to a layout. Stripping `/api` is also what
|
|
41
|
+
* keeps `/api/auth/*` — better-auth's own handler, which a code-factor session must be able to
|
|
42
|
+
* drive in order to enrol a passkey at all — inside the auth area.
|
|
43
|
+
*/
|
|
44
|
+
export declare function routeAreaOf(pathname: string): RouteArea;
|
|
45
|
+
/**
|
|
46
|
+
* The session-factor policy, as one pure function over a fake-able session.
|
|
47
|
+
*
|
|
48
|
+
* Three rules, and the order between them is the point:
|
|
49
|
+
*
|
|
50
|
+
* 1. A check that names a role — every `/admin/*` route, and any action that asks for one —
|
|
51
|
+
* answers **404 for every refusal**, including "not signed in at all". A redirect would say
|
|
52
|
+
* that the path exists and that signing in is worth trying; the whole reason `/admin/*`
|
|
53
|
+
* 404s rather than redirects is that it must not say so. So the role test runs before the
|
|
54
|
+
* factor test: an admin holding only a code-factor session gets the same 404 as a stranger,
|
|
55
|
+
* not a step-up redirect that would confirm the area.
|
|
56
|
+
* 2. A code-factor session is confined to the auth area. Everywhere else it is refused and sent
|
|
57
|
+
* to step up, because it is one factor — possession of an inbox — and nothing more.
|
|
58
|
+
* 3. Requirements are minimums. A passkey session satisfies `factor: 'code'`; the reverse is
|
|
59
|
+
* what rule 2 refuses.
|
|
60
|
+
*
|
|
61
|
+
* `pathname` omitted means "app area", the strict case: a server action states its own bar, and
|
|
62
|
+
* one that carries no `factor` is passkey-only. An action reachable from `/auth/*` — sending a
|
|
63
|
+
* code, enrolling the first passkey — opts down with `factor: 'code'` explicitly.
|
|
64
|
+
*/
|
|
65
|
+
export declare function evaluateAccess(request: AccessRequest): AccessDecision;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { hasRole } from "./session.js";
|
|
2
|
+
/** Where a code-factor session is allowed to be, and where an unauthenticated one is sent. */
|
|
3
|
+
export const AUTH_AREA = "auth";
|
|
4
|
+
/** The area that does not exist for anyone who cannot enter it. */
|
|
5
|
+
export const ADMIN_AREA = "admin";
|
|
6
|
+
export const DEFAULT_SIGN_IN_PATH = "/auth/sign-in";
|
|
7
|
+
export const DEFAULT_STEP_UP_PATH = "/auth/passkey";
|
|
8
|
+
/** The role `/admin/*` requires, and the one `bootstrapAdmin` grants. */
|
|
9
|
+
export const ADMIN_ROLE = "admin";
|
|
10
|
+
/**
|
|
11
|
+
* The first segment, with a leading `/api` stripped, so `/api/admin/users` is admin territory
|
|
12
|
+
* to a route handler exactly as `/admin/users` is to a layout. Stripping `/api` is also what
|
|
13
|
+
* keeps `/api/auth/*` — better-auth's own handler, which a code-factor session must be able to
|
|
14
|
+
* drive in order to enrol a passkey at all — inside the auth area.
|
|
15
|
+
*/
|
|
16
|
+
export function routeAreaOf(pathname) {
|
|
17
|
+
const path = pathname.split("?")[0].split("#")[0];
|
|
18
|
+
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
19
|
+
const first = (segments[0] === "api" ? segments.slice(1) : segments)[0];
|
|
20
|
+
if (first === AUTH_AREA)
|
|
21
|
+
return "auth";
|
|
22
|
+
if (first === ADMIN_AREA)
|
|
23
|
+
return "admin";
|
|
24
|
+
return "app";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The session-factor policy, as one pure function over a fake-able session.
|
|
28
|
+
*
|
|
29
|
+
* Three rules, and the order between them is the point:
|
|
30
|
+
*
|
|
31
|
+
* 1. A check that names a role — every `/admin/*` route, and any action that asks for one —
|
|
32
|
+
* answers **404 for every refusal**, including "not signed in at all". A redirect would say
|
|
33
|
+
* that the path exists and that signing in is worth trying; the whole reason `/admin/*`
|
|
34
|
+
* 404s rather than redirects is that it must not say so. So the role test runs before the
|
|
35
|
+
* factor test: an admin holding only a code-factor session gets the same 404 as a stranger,
|
|
36
|
+
* not a step-up redirect that would confirm the area.
|
|
37
|
+
* 2. A code-factor session is confined to the auth area. Everywhere else it is refused and sent
|
|
38
|
+
* to step up, because it is one factor — possession of an inbox — and nothing more.
|
|
39
|
+
* 3. Requirements are minimums. A passkey session satisfies `factor: 'code'`; the reverse is
|
|
40
|
+
* what rule 2 refuses.
|
|
41
|
+
*
|
|
42
|
+
* `pathname` omitted means "app area", the strict case: a server action states its own bar, and
|
|
43
|
+
* one that carries no `factor` is passkey-only. An action reachable from `/auth/*` — sending a
|
|
44
|
+
* code, enrolling the first passkey — opts down with `factor: 'code'` explicitly.
|
|
45
|
+
*/
|
|
46
|
+
export function evaluateAccess(request) {
|
|
47
|
+
const area = request.pathname === undefined ? "app" : routeAreaOf(request.pathname);
|
|
48
|
+
const requiredRole = request.role ?? (area === "admin" ? ADMIN_ROLE : undefined);
|
|
49
|
+
const requiredFactor = request.factor ?? (area === "auth" ? "code" : "passkey");
|
|
50
|
+
const hidden = requiredRole !== undefined;
|
|
51
|
+
const signIn = request.signInPath ?? DEFAULT_SIGN_IN_PATH;
|
|
52
|
+
const stepUp = request.stepUpPath ?? DEFAULT_STEP_UP_PATH;
|
|
53
|
+
const refuse = (refusal, to) => hidden ? { outcome: "not-found", refusal } : { outcome: "redirect", to, refusal };
|
|
54
|
+
const { session } = request;
|
|
55
|
+
if (!session)
|
|
56
|
+
return refuse("no-session", signIn);
|
|
57
|
+
// A ban lands on the user row while their sessions are still live; it has to be read here or
|
|
58
|
+
// a banned admin keeps the tab they already had open.
|
|
59
|
+
if (session.user.banned === true)
|
|
60
|
+
return refuse("banned", signIn);
|
|
61
|
+
if (requiredRole !== undefined && !hasRole(session.user, requiredRole)) {
|
|
62
|
+
return { outcome: "not-found", refusal: "missing-role" };
|
|
63
|
+
}
|
|
64
|
+
if (requiredFactor === "passkey" && session.factor !== "passkey") {
|
|
65
|
+
return refuse("code-factor", stepUp);
|
|
66
|
+
}
|
|
67
|
+
return { outcome: "allow", session };
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type AccessDecision, type AccessPaths, type AccessRequest } from "./policy.js";
|
|
2
|
+
import type { AuthSession } from "./session.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a refusal throws when the host's handler returns instead of diverting. Next's
|
|
5
|
+
* `redirect()` and `notFound()` both throw, so in the template this is unreachable — it exists
|
|
6
|
+
* so that `requireSession` cannot return an unauthorized session under a handler that forgets.
|
|
7
|
+
*/
|
|
8
|
+
export declare class AccessRefused extends Error {
|
|
9
|
+
readonly decision: AccessDecision;
|
|
10
|
+
constructor(decision: AccessDecision);
|
|
11
|
+
}
|
|
12
|
+
export interface SessionGuardOptions extends AccessPaths {
|
|
13
|
+
/**
|
|
14
|
+
* Reads the session for the current request. In Next this is
|
|
15
|
+
* `auth.api.getSession({ headers: await headers() })`; in a test it is a fake.
|
|
16
|
+
*/
|
|
17
|
+
getSession: () => Promise<AuthSession | null | undefined>;
|
|
18
|
+
/** `redirect(to)` in Next. Expected to throw; if it returns, `AccessRefused` is thrown. */
|
|
19
|
+
onRedirect?: (to: string, decision: AccessDecision) => void | Promise<void>;
|
|
20
|
+
/** `notFound()` in Next. Same contract. */
|
|
21
|
+
onNotFound?: (decision: AccessDecision) => void | Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface RequireSessionOptions {
|
|
24
|
+
factor?: AccessRequest["factor"];
|
|
25
|
+
role?: AccessRequest["role"];
|
|
26
|
+
/** The route being entered. Omitted in a server action — see `evaluateAccess`. */
|
|
27
|
+
pathname?: string;
|
|
28
|
+
}
|
|
29
|
+
export type RequireSession = (options?: RequireSessionOptions) => Promise<AuthSession>;
|
|
30
|
+
/**
|
|
31
|
+
* `requireSession({ factor, role })` for layouts, server actions and route handlers, bound once
|
|
32
|
+
* per app to a session reader and to whatever this host diverts with.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createSessionGuard(options: SessionGuardOptions): RequireSession;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { evaluateAccess, } from "./policy.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a refusal throws when the host's handler returns instead of diverting. Next's
|
|
4
|
+
* `redirect()` and `notFound()` both throw, so in the template this is unreachable — it exists
|
|
5
|
+
* so that `requireSession` cannot return an unauthorized session under a handler that forgets.
|
|
6
|
+
*/
|
|
7
|
+
export class AccessRefused extends Error {
|
|
8
|
+
decision;
|
|
9
|
+
constructor(decision) {
|
|
10
|
+
super(decision.outcome === "not-found"
|
|
11
|
+
? `access refused (${decision.refusal}): not found`
|
|
12
|
+
: decision.outcome === "redirect"
|
|
13
|
+
? `access refused (${decision.refusal}): redirect to ${decision.to}`
|
|
14
|
+
: "access refused");
|
|
15
|
+
this.name = "AccessRefused";
|
|
16
|
+
this.decision = decision;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `requireSession({ factor, role })` for layouts, server actions and route handlers, bound once
|
|
21
|
+
* per app to a session reader and to whatever this host diverts with.
|
|
22
|
+
*/
|
|
23
|
+
export function createSessionGuard(options) {
|
|
24
|
+
return async (required = {}) => {
|
|
25
|
+
const session = await options.getSession();
|
|
26
|
+
const decision = evaluateAccess({
|
|
27
|
+
session,
|
|
28
|
+
pathname: required.pathname,
|
|
29
|
+
factor: required.factor,
|
|
30
|
+
role: required.role,
|
|
31
|
+
signInPath: options.signInPath,
|
|
32
|
+
stepUpPath: options.stepUpPath,
|
|
33
|
+
});
|
|
34
|
+
if (decision.outcome === "allow")
|
|
35
|
+
return decision.session;
|
|
36
|
+
if (decision.outcome === "redirect")
|
|
37
|
+
await options.onRedirect?.(decision.to, decision);
|
|
38
|
+
else
|
|
39
|
+
await options.onNotFound?.(decision);
|
|
40
|
+
throw new AccessRefused(decision);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import type { RequireSession } from "./require-session.js";
|
|
3
|
+
/** One line per reset; the user has just lost every way in and support will be asked why. */
|
|
4
|
+
export declare const SECOND_FACTOR_RESET_MARKER = "hf-auth: second factor reset";
|
|
5
|
+
export interface ResetSecondFactorOptions {
|
|
6
|
+
userId: string;
|
|
7
|
+
/** The admin who did it. Null only for a reset driven from the CLI. */
|
|
8
|
+
actorId?: string | null;
|
|
9
|
+
reason?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ResetSecondFactorResult {
|
|
12
|
+
userId: string;
|
|
13
|
+
passkeysRemoved: number;
|
|
14
|
+
sessionsRevoked: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Unenrols every passkey a user holds, so someone who lost their device can be let back in
|
|
18
|
+
* through the email code and enrol a new one.
|
|
19
|
+
*
|
|
20
|
+
* It revokes **all** of the user's sessions, not only the passkey-factor ones. A code-factor
|
|
21
|
+
* session is confined to `/auth/*`, but `/auth/*` is exactly where enrolment lives: leaving one
|
|
22
|
+
* alive would let whoever holds the lost device's still-valid session enrol a fresh
|
|
23
|
+
* authenticator and promote itself back to `passkey`, which is the state this reset exists to
|
|
24
|
+
* end. The passkey rows and the sessions go in one transaction for the same reason.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resetSecondFactor(pool: Pool, options: ResetSecondFactorOptions): Promise<ResetSecondFactorResult>;
|
|
27
|
+
export interface ResetSecondFactorActionOptions {
|
|
28
|
+
pool: Pool;
|
|
29
|
+
requireSession: RequireSession;
|
|
30
|
+
}
|
|
31
|
+
export type ResetSecondFactorAction = (options: ResetSecondFactorOptions) => Promise<ResetSecondFactorResult>;
|
|
32
|
+
/**
|
|
33
|
+
* The admin action, with its guard attached rather than left to the caller to remember. It
|
|
34
|
+
* takes the actor from the guarded session, so the audit row names who actually did it and not
|
|
35
|
+
* whoever the form said.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createResetSecondFactorAction(options: ResetSecondFactorActionOptions): ResetSecondFactorAction;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ADMIN_ROLE } from "./policy.js";
|
|
2
|
+
/** One line per reset; the user has just lost every way in and support will be asked why. */
|
|
3
|
+
export const SECOND_FACTOR_RESET_MARKER = "hf-auth: second factor reset";
|
|
4
|
+
const DELETE_PASSKEYS_STATEMENT = "DELETE FROM hf_passkey WHERE user_id = $1 RETURNING id";
|
|
5
|
+
const DELETE_SESSIONS_STATEMENT = "DELETE FROM hf_session WHERE user_id = $1 RETURNING id";
|
|
6
|
+
const AUDIT_STATEMENT = "INSERT INTO hf_audit (actor_id, action, target_type, target_id, meta) " +
|
|
7
|
+
"VALUES ($1, 'auth.second_factor_reset', 'user', $2, $3::jsonb)";
|
|
8
|
+
/**
|
|
9
|
+
* Unenrols every passkey a user holds, so someone who lost their device can be let back in
|
|
10
|
+
* through the email code and enrol a new one.
|
|
11
|
+
*
|
|
12
|
+
* It revokes **all** of the user's sessions, not only the passkey-factor ones. A code-factor
|
|
13
|
+
* session is confined to `/auth/*`, but `/auth/*` is exactly where enrolment lives: leaving one
|
|
14
|
+
* alive would let whoever holds the lost device's still-valid session enrol a fresh
|
|
15
|
+
* authenticator and promote itself back to `passkey`, which is the state this reset exists to
|
|
16
|
+
* end. The passkey rows and the sessions go in one transaction for the same reason.
|
|
17
|
+
*/
|
|
18
|
+
export async function resetSecondFactor(pool, options) {
|
|
19
|
+
const client = await pool.connect();
|
|
20
|
+
try {
|
|
21
|
+
await client.query("BEGIN");
|
|
22
|
+
const passkeys = await client.query(DELETE_PASSKEYS_STATEMENT, [options.userId]);
|
|
23
|
+
const sessions = await client.query(DELETE_SESSIONS_STATEMENT, [options.userId]);
|
|
24
|
+
await client.query(AUDIT_STATEMENT, [
|
|
25
|
+
options.actorId ?? null,
|
|
26
|
+
options.userId,
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
passkeysRemoved: passkeys.rowCount ?? 0,
|
|
29
|
+
sessionsRevoked: sessions.rowCount ?? 0,
|
|
30
|
+
reason: options.reason ?? null,
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
await client.query("COMMIT");
|
|
34
|
+
const result = {
|
|
35
|
+
userId: options.userId,
|
|
36
|
+
passkeysRemoved: passkeys.rowCount ?? 0,
|
|
37
|
+
sessionsRevoked: sessions.rowCount ?? 0,
|
|
38
|
+
};
|
|
39
|
+
console.info(SECOND_FACTOR_RESET_MARKER, JSON.stringify(result));
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
await client.query("ROLLBACK");
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
client.release();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The admin action, with its guard attached rather than left to the caller to remember. It
|
|
52
|
+
* takes the actor from the guarded session, so the audit row names who actually did it and not
|
|
53
|
+
* whoever the form said.
|
|
54
|
+
*/
|
|
55
|
+
export function createResetSecondFactorAction(options) {
|
|
56
|
+
return async (reset) => {
|
|
57
|
+
const session = await options.requireSession({ factor: "passkey", role: ADMIN_ROLE });
|
|
58
|
+
return resetSecondFactor(options.pool, { ...reset, actorId: session.user.id });
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { sessionFactors, type SessionFactor } from "@hyperfixation/db";
|
|
2
|
+
export type { SessionFactor };
|
|
3
|
+
export { sessionFactors };
|
|
4
|
+
/** The endpoint that proves possession of an enrolled authenticator. */
|
|
5
|
+
export declare const PASSKEY_AUTHENTICATION_PATH = "/passkey/verify-authentication";
|
|
6
|
+
/** Enrolment. It creates no session, but it does let an existing one be promoted. */
|
|
7
|
+
export declare const PASSKEY_REGISTRATION_PATH = "/passkey/verify-registration";
|
|
8
|
+
/** The email-OTP sign-in endpoint, named here so the stamping rule reads as a pair. */
|
|
9
|
+
export declare const EMAIL_OTP_SIGN_IN_PATH = "/sign-in/email-otp";
|
|
10
|
+
/** What the guard needs of a user; better-auth's own user object is a superset. */
|
|
11
|
+
export interface SessionUser {
|
|
12
|
+
id: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
role?: string | null;
|
|
15
|
+
banned?: boolean | null;
|
|
16
|
+
}
|
|
17
|
+
/** What the guard needs of a session; better-auth's `session` is a superset. */
|
|
18
|
+
export interface AuthSession {
|
|
19
|
+
factor: SessionFactor;
|
|
20
|
+
user: SessionUser;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* An **allowlist**: only the passkey authentication endpoint mints a passkey-factor session,
|
|
24
|
+
* and every other path — including one a future plugin adds — gets `code`. The column defaults
|
|
25
|
+
* the same way for the same reason; a session must not reach a passkey-gated check because
|
|
26
|
+
* nobody taught this function about the path that created it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sessionFactorForPath(path: string | null | undefined): SessionFactor;
|
|
29
|
+
/**
|
|
30
|
+
* better-auth's admin plugin stores roles as one comma-separated `text` column, so membership
|
|
31
|
+
* is a list test rather than an equality test. Comparison is case-insensitive because the role
|
|
32
|
+
* arrives from whatever wrote the column — the bootstrap path, an admin UI, or a hand-run SQL.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hasRole(user: SessionUser | null | undefined, role: string): boolean;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sessionFactors } from "@hyperfixation/db";
|
|
2
|
+
export { sessionFactors };
|
|
3
|
+
/** The endpoint that proves possession of an enrolled authenticator. */
|
|
4
|
+
export const PASSKEY_AUTHENTICATION_PATH = "/passkey/verify-authentication";
|
|
5
|
+
/** Enrolment. It creates no session, but it does let an existing one be promoted. */
|
|
6
|
+
export const PASSKEY_REGISTRATION_PATH = "/passkey/verify-registration";
|
|
7
|
+
/** The email-OTP sign-in endpoint, named here so the stamping rule reads as a pair. */
|
|
8
|
+
export const EMAIL_OTP_SIGN_IN_PATH = "/sign-in/email-otp";
|
|
9
|
+
/**
|
|
10
|
+
* An **allowlist**: only the passkey authentication endpoint mints a passkey-factor session,
|
|
11
|
+
* and every other path — including one a future plugin adds — gets `code`. The column defaults
|
|
12
|
+
* the same way for the same reason; a session must not reach a passkey-gated check because
|
|
13
|
+
* nobody taught this function about the path that created it.
|
|
14
|
+
*/
|
|
15
|
+
export function sessionFactorForPath(path) {
|
|
16
|
+
return path === PASSKEY_AUTHENTICATION_PATH ? "passkey" : "code";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* better-auth's admin plugin stores roles as one comma-separated `text` column, so membership
|
|
20
|
+
* is a list test rather than an equality test. Comparison is case-insensitive because the role
|
|
21
|
+
* arrives from whatever wrote the column — the bootstrap path, an admin UI, or a hand-run SQL.
|
|
22
|
+
*/
|
|
23
|
+
export function hasRole(user, role) {
|
|
24
|
+
if (!user?.role)
|
|
25
|
+
return false;
|
|
26
|
+
const wanted = role.trim().toLowerCase();
|
|
27
|
+
return user.role
|
|
28
|
+
.split(",")
|
|
29
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
30
|
+
.includes(wanted);
|
|
31
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyperfixation/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "better-auth factory, session-factor policy, requireSession, and the bootstrap user",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/grahamlutz/hyperfixation-core.git",
|
|
9
|
+
"directory": "packages/auth"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"!dist/**/*.test.*"
|
|
25
|
+
],
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@hyperfixation/eslint-config": "0.1.0",
|
|
28
|
+
"@hyperfixation/testing": "0.1.0",
|
|
29
|
+
"@microsoft/api-extractor": "^7.59.1",
|
|
30
|
+
"@types/pg": "^8.23.1",
|
|
31
|
+
"eslint": "^10.10.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@better-auth/passkey": "1.7.5",
|
|
35
|
+
"@hyperfixation/db": "0.1.0",
|
|
36
|
+
"@simplewebauthn/server": "13.3.3",
|
|
37
|
+
"better-auth": "1.7.5",
|
|
38
|
+
"drizzle-orm": "^0.45.2",
|
|
39
|
+
"pg": "^8.23.0",
|
|
40
|
+
"zod": "4.6.5"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.json",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
+
"lint": "eslint src",
|
|
46
|
+
"api-extractor": "api-extractor run",
|
|
47
|
+
"api-extractor:update": "api-extractor run --local",
|
|
48
|
+
"test": "vitest run --passWithNoTests"
|
|
49
|
+
}
|
|
50
|
+
}
|