@jxsuite/auth 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/actions.ts +50 -28
- package/src/client.ts +17 -6
- package/src/config.ts +119 -61
- package/src/server.ts +19 -15
- package/src/session.ts +2 -2
- package/src/worker.ts +25 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/auth",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Better Auth sessions, sign-in flows, and table permissions for Jx sites over /_jx/auth",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,13 +38,13 @@
|
|
|
38
38
|
"upgrade": "bun update --latest && bun install"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@jxsuite/connector": "^0.6.
|
|
42
|
-
"@jxsuite/schema": "^
|
|
43
|
-
"better-auth": "^1.7.
|
|
41
|
+
"@jxsuite/connector": "^0.6.3",
|
|
42
|
+
"@jxsuite/schema": "^4.0.0",
|
|
43
|
+
"better-auth": "^1.7.10",
|
|
44
44
|
"kysely": "^0.29.6"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@happy-dom/global-registrator": "^
|
|
47
|
+
"@happy-dom/global-registrator": "^21.0.3",
|
|
48
48
|
"ajv": "^8.20.0"
|
|
49
49
|
},
|
|
50
50
|
"jx": "./jx-extension.json"
|
package/src/actions.ts
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
* `"onsubmit": { "$ref": "auth.signInEmail" }` on a form with email/password fields.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { isFunction, isString } from "@jxsuite/schema/guards";
|
|
12
13
|
import { getAuthClient } from "./client.ts";
|
|
13
14
|
import { clearSession, fetchSession } from "./session.ts";
|
|
14
|
-
import type {
|
|
15
|
+
import type { ActionScope } from "@jxsuite/connector/table-actions";
|
|
16
|
+
import type { AuthClientResult, SocialSignInInput } from "./client.ts";
|
|
17
|
+
import type { AuthSection } from "./config.ts";
|
|
15
18
|
|
|
16
19
|
/** Redirect paths applied after auth state changes. */
|
|
17
20
|
export interface AuthRedirects {
|
|
@@ -26,34 +29,48 @@ export interface AuthActionsConfig {
|
|
|
26
29
|
provider?: string;
|
|
27
30
|
baseUrl?: string;
|
|
28
31
|
/** Injected by hosts (compiler/dev server): the project sections, including `auth`. */
|
|
29
|
-
_project?: { auth?:
|
|
30
|
-
[key: string]: unknown;
|
|
32
|
+
_project?: { auth?: AuthSection };
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
type Scope = Record<string, unknown>;
|
|
34
|
-
|
|
35
35
|
/** A `(scope, event)`-callable auth handler resolving true when the call succeeded. */
|
|
36
|
-
export type AuthActionHandler = (scope:
|
|
36
|
+
export type AuthActionHandler = (scope: ActionScope, event?: Event) => Promise<boolean>;
|
|
37
|
+
|
|
38
|
+
/** The handler map `AuthActions.resolve()` returns, one entry per `$ref`-able action. */
|
|
39
|
+
export interface AuthActionHandlers {
|
|
40
|
+
signInEmail: AuthActionHandler;
|
|
41
|
+
signInSocial: AuthActionHandler;
|
|
42
|
+
signOut: AuthActionHandler;
|
|
43
|
+
signUpEmail: AuthActionHandler;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** String field values of a submitted form, keyed by field name. */
|
|
47
|
+
type FormFields = Record<string, string>;
|
|
37
48
|
|
|
38
49
|
/** The submitting form: the event target itself, or the target control's owner form. */
|
|
39
50
|
function formOf(event?: Event): HTMLFormElement | null {
|
|
40
|
-
const target = event?.target
|
|
51
|
+
const target = event?.target;
|
|
41
52
|
if (!target) {
|
|
42
53
|
return null;
|
|
43
54
|
}
|
|
44
|
-
|
|
55
|
+
if (target instanceof HTMLFormElement) {
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
58
|
+
// Form-associated controls (input, button, select, …) expose their owner form as `.form`.
|
|
59
|
+
if (target instanceof Element && "form" in target && target.form instanceof HTMLFormElement) {
|
|
60
|
+
return target.form;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
/** String field values of the submitting form (files are ignored). */
|
|
48
|
-
function formValues(event?: Event)
|
|
66
|
+
function formValues(event?: Event) {
|
|
67
|
+
const out: FormFields = {};
|
|
49
68
|
const form = formOf(event);
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (typeof value === "string") {
|
|
56
|
-
out[key] = value;
|
|
69
|
+
if (form) {
|
|
70
|
+
for (const [key, value] of new FormData(form)) {
|
|
71
|
+
if (isString(value)) {
|
|
72
|
+
out[key] = value;
|
|
73
|
+
}
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
76
|
return out;
|
|
@@ -67,8 +84,8 @@ function navigate(path: string | undefined): void {
|
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
/** Bump the read-after-write version so owner-scoped queries re-run under the new session. */
|
|
70
|
-
function bumpVersion(scope:
|
|
71
|
-
scope._v = (
|
|
87
|
+
function bumpVersion(scope: ActionScope): void {
|
|
88
|
+
scope._v = (scope._v || 0) + 1;
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
export class AuthActions {
|
|
@@ -84,7 +101,7 @@ export class AuthActions {
|
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
/** The handler map, wired via $ref (e.g. `onsubmit: { "$ref": "auth.signInEmail" }`). */
|
|
87
|
-
resolve():
|
|
104
|
+
resolve(): AuthActionHandlers {
|
|
88
105
|
return {
|
|
89
106
|
signInEmail: this.#authCall((values, client) =>
|
|
90
107
|
client.signIn.email({ email: values.email ?? "", password: values.password ?? "" }),
|
|
@@ -93,16 +110,19 @@ export class AuthActions {
|
|
|
93
110
|
(values, client) => {
|
|
94
111
|
const provider = values.provider ?? this.config.provider ?? "";
|
|
95
112
|
const callbackURL = this.redirects().afterSignIn;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
113
|
+
const input: SocialSignInInput = { provider };
|
|
114
|
+
if (callbackURL !== undefined) {
|
|
115
|
+
input.callbackURL = callbackURL;
|
|
116
|
+
}
|
|
117
|
+
return client.signIn.social(input);
|
|
100
118
|
// Social sign-in redirects the whole page itself — no local navigation.
|
|
101
119
|
},
|
|
102
120
|
{ redirect: false },
|
|
103
121
|
),
|
|
104
|
-
signOut: async (scope:
|
|
105
|
-
event
|
|
122
|
+
signOut: async (scope: ActionScope, event?: Event): Promise<boolean> => {
|
|
123
|
+
if (event && isFunction(event.preventDefault)) {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
}
|
|
106
126
|
const { error } = await getAuthClient(this.config.baseUrl).signOut();
|
|
107
127
|
if (error) {
|
|
108
128
|
return false;
|
|
@@ -126,13 +146,15 @@ export class AuthActions {
|
|
|
126
146
|
/** Build a form-reading handler around one auth client call. */
|
|
127
147
|
#authCall(
|
|
128
148
|
call: (
|
|
129
|
-
values:
|
|
149
|
+
values: FormFields,
|
|
130
150
|
client: ReturnType<typeof getAuthClient>,
|
|
131
151
|
) => Promise<AuthClientResult>,
|
|
132
152
|
options: { redirect?: boolean } = {},
|
|
133
153
|
): AuthActionHandler {
|
|
134
|
-
return async (scope:
|
|
135
|
-
event
|
|
154
|
+
return async (scope: ActionScope, event?: Event): Promise<boolean> => {
|
|
155
|
+
if (event && isFunction(event.preventDefault)) {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
}
|
|
136
158
|
const client = getAuthClient(this.config.baseUrl);
|
|
137
159
|
const { error } = await call(formValues(event), client);
|
|
138
160
|
if (error) {
|
package/src/client.ts
CHANGED
|
@@ -10,11 +10,22 @@
|
|
|
10
10
|
|
|
11
11
|
import { createAuthClient } from "better-auth/client";
|
|
12
12
|
import { AUTH_BASE_PATH } from "./config.ts";
|
|
13
|
+
import type { AuthSessionPayload } from "./config.ts";
|
|
13
14
|
|
|
14
|
-
/** The `{ data, error }` envelope every client call resolves to. */
|
|
15
|
+
/** The `{ data, error }` envelope every client call resolves to (only `error` is consumed). */
|
|
15
16
|
export interface AuthClientResult {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
error?: { message?: string | undefined; status?: number | undefined } | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The `getSession()` envelope: `data` is the parsed `{ user, session }` payload, or null. */
|
|
21
|
+
export interface AuthSessionResult extends AuthClientResult {
|
|
22
|
+
data?: AuthSessionPayload | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The `signIn.social` input: the provider id, plus where to land after the OAuth round trip. */
|
|
26
|
+
export interface SocialSignInInput {
|
|
27
|
+
provider: string;
|
|
28
|
+
callbackURL?: string;
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
/** The Better Auth client slice consumed by the session store and the auth actions. */
|
|
@@ -25,13 +36,13 @@ export interface JxAuthClient {
|
|
|
25
36
|
password: string;
|
|
26
37
|
callbackURL?: string;
|
|
27
38
|
}) => Promise<AuthClientResult>;
|
|
28
|
-
social: (input:
|
|
39
|
+
social: (input: SocialSignInInput) => Promise<AuthClientResult>;
|
|
29
40
|
};
|
|
30
41
|
signUp: {
|
|
31
42
|
email: (input: { email: string; password: string; name: string }) => Promise<AuthClientResult>;
|
|
32
43
|
};
|
|
33
44
|
signOut: () => Promise<AuthClientResult>;
|
|
34
|
-
getSession: () => Promise<
|
|
45
|
+
getSession: () => Promise<AuthSessionResult>;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
let client: JxAuthClient | null = null;
|
|
@@ -63,7 +74,7 @@ export function getAuthClient(baseUrl?: string): JxAuthClient {
|
|
|
63
74
|
}
|
|
64
75
|
const base = resolveAuthBaseUrl(baseUrl);
|
|
65
76
|
if (!client || clientBase !== base) {
|
|
66
|
-
client = createAuthClient({ baseURL: base })
|
|
77
|
+
client = createAuthClient({ baseURL: base });
|
|
67
78
|
clientBase = base;
|
|
68
79
|
}
|
|
69
80
|
return client;
|
package/src/config.ts
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
* The section carries identifiers and env-var NAMES only (specs/extensions.md §13): the signing
|
|
5
5
|
* secret comes from `env[secretEnv]` (default BETTER_AUTH_SECRET), social-provider credentials from
|
|
6
6
|
* their `clientIdEnv`/`clientSecretEnv` vars (defaults <ID>_CLIENT_ID / <ID>_CLIENT_SECRET). This
|
|
7
|
-
* module is pure
|
|
8
|
-
* section types and the SessionInfo mapping without pulling the Better Auth
|
|
7
|
+
* module is pure (its only runtime import is the schema guards) so browser bundles (session store,
|
|
8
|
+
* actions) can share the section types and the SessionInfo mapping without pulling the Better Auth
|
|
9
|
+
* server in.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
|
-
import
|
|
12
|
+
import { isString } from "@jxsuite/schema/guards";
|
|
13
|
+
import type { Dialect } from "kysely";
|
|
14
|
+
import type { HostEnv } from "@jxsuite/connector/provider-utils";
|
|
15
|
+
import type { AuthRecord, ConnectionDef, SessionInfo } from "@jxsuite/connector/types";
|
|
12
16
|
|
|
13
17
|
/** The Better Auth route subtree (specs/extensions.md §11: the auth mount's basePath). */
|
|
14
18
|
export const AUTH_BASE_PATH = "/_jx/auth";
|
|
@@ -46,12 +50,12 @@ const COOKIE_IDS = ["session_token", "session_data", "dont_remember", "account_d
|
|
|
46
50
|
* genuinely serves auth over plain HTTP is the local dev server, and it pins `BETTER_AUTH_URL` to
|
|
47
51
|
* its own origin so this reads `http:` there rather than guessing.
|
|
48
52
|
*
|
|
49
|
-
* @param {
|
|
53
|
+
* @param {HostEnv} env
|
|
50
54
|
* @returns {boolean}
|
|
51
55
|
*/
|
|
52
|
-
export function cookiesAreSecure(env:
|
|
56
|
+
export function cookiesAreSecure(env: HostEnv): boolean {
|
|
53
57
|
const baseURL = env["BETTER_AUTH_URL"];
|
|
54
|
-
if (
|
|
58
|
+
if (isString(baseURL) && baseURL !== "") {
|
|
55
59
|
return !baseURL.startsWith("http://");
|
|
56
60
|
}
|
|
57
61
|
return true;
|
|
@@ -78,14 +82,14 @@ export function cookiesAreSecure(env: Env): boolean {
|
|
|
78
82
|
* whenever the embedding page changed.
|
|
79
83
|
*
|
|
80
84
|
* @param {boolean} secure
|
|
81
|
-
* @returns {
|
|
85
|
+
* @returns {AuthAdvancedOptions}
|
|
82
86
|
*/
|
|
83
|
-
function advancedCookieOptions(secure: boolean):
|
|
87
|
+
function advancedCookieOptions(secure: boolean): AuthAdvancedOptions {
|
|
84
88
|
if (!secure) {
|
|
85
89
|
// Plain-HTTP dev origin: a prefixed cookie without `Secure` is rejected outright.
|
|
86
90
|
return { cookiePrefix: COOKIE_PREFIX, useSecureCookies: false };
|
|
87
91
|
}
|
|
88
|
-
const cookies:
|
|
92
|
+
const cookies: AuthCookieNames = {};
|
|
89
93
|
for (const id of COOKIE_IDS) {
|
|
90
94
|
cookies[id] = { name: `__Host-${COOKIE_PREFIX}.${id}` };
|
|
91
95
|
}
|
|
@@ -97,6 +101,17 @@ function advancedCookieOptions(secure: boolean): Record<string, unknown> {
|
|
|
97
101
|
};
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
/** Cookie names keyed by Better Auth cookie id (`session_token`, …). */
|
|
105
|
+
export type AuthCookieNames = Record<string, { name: string }>;
|
|
106
|
+
|
|
107
|
+
/** The `advanced` block of BetterAuthOptions this module sets (cookie names and attributes). */
|
|
108
|
+
export interface AuthAdvancedOptions {
|
|
109
|
+
cookiePrefix: string;
|
|
110
|
+
cookies?: AuthCookieNames;
|
|
111
|
+
defaultCookieAttributes?: { secure: boolean };
|
|
112
|
+
useSecureCookies: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
100
115
|
/** One social provider entry: env-var NAMES for the OAuth credentials, never values. */
|
|
101
116
|
export interface AuthProviderDef {
|
|
102
117
|
clientIdEnv?: string;
|
|
@@ -113,18 +128,14 @@ export interface AuthSection {
|
|
|
113
128
|
redirects?: { afterSignIn?: string; afterSignOut?: string };
|
|
114
129
|
roles?: string[];
|
|
115
130
|
trustedOrigins?: string[];
|
|
116
|
-
[key: string]: unknown;
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
/** The `connections`-bearing slice of a project config (the connector owns the full type). */
|
|
120
134
|
export interface AuthProjectConfig {
|
|
121
|
-
connections?: Record<string,
|
|
135
|
+
connections?: Record<string, ConnectionDef>;
|
|
122
136
|
auth?: AuthSection;
|
|
123
|
-
[key: string]: unknown;
|
|
124
137
|
}
|
|
125
138
|
|
|
126
|
-
type Env = Record<string, unknown>;
|
|
127
|
-
|
|
128
139
|
/** The connection name the auth tables live on: declared, else the first-declared connection. */
|
|
129
140
|
export function resolveAuthConnectionName(
|
|
130
141
|
section: AuthSection,
|
|
@@ -141,10 +152,10 @@ export function resolveAuthConnectionName(
|
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
/** The signing secret from `env[secretEnv]`, failing closed with the env-var name when unset. */
|
|
144
|
-
export function resolveAuthSecret(section: AuthSection, env:
|
|
155
|
+
export function resolveAuthSecret(section: AuthSection, env: HostEnv): string {
|
|
145
156
|
const name = section.secretEnv ?? DEFAULT_SECRET_ENV;
|
|
146
157
|
const value = env[name];
|
|
147
|
-
if (
|
|
158
|
+
if (!isString(value) || value === "") {
|
|
148
159
|
throw new Error(
|
|
149
160
|
`Auth is not configured: set the ${name} secret (in .dev.vars locally, ` +
|
|
150
161
|
`or \`wrangler secret put ${name}\` in production)`,
|
|
@@ -158,48 +169,77 @@ function defaultProviderEnv(providerId: string, suffix: "CLIENT_ID" | "CLIENT_SE
|
|
|
158
169
|
return `${providerId.toUpperCase().replaceAll(/[^A-Z0-9]+/g, "_")}_${suffix}`;
|
|
159
170
|
}
|
|
160
171
|
|
|
172
|
+
/** Resolved OAuth credentials per social provider id (Better Auth's `socialProviders` shape). */
|
|
173
|
+
export type AuthSocialProviders = Record<string, { clientId: string; clientSecret: string }>;
|
|
174
|
+
|
|
161
175
|
/**
|
|
162
176
|
* Social providers with resolvable credentials, in Better Auth's `socialProviders` shape. Providers
|
|
163
177
|
* whose env vars are unset are omitted (fail-closed: no half-configured OAuth).
|
|
164
178
|
*/
|
|
165
|
-
export function resolveSocialProviders(
|
|
166
|
-
|
|
167
|
-
env: Env,
|
|
168
|
-
): Record<string, { clientId: string; clientSecret: string }> {
|
|
169
|
-
const out: Record<string, { clientId: string; clientSecret: string }> = {};
|
|
179
|
+
export function resolveSocialProviders(section: AuthSection, env: HostEnv) {
|
|
180
|
+
const out: AuthSocialProviders = {};
|
|
170
181
|
for (const [id, def] of Object.entries(section.providers ?? {})) {
|
|
171
182
|
const clientId = env[def.clientIdEnv ?? defaultProviderEnv(id, "CLIENT_ID")];
|
|
172
183
|
const clientSecret = env[def.clientSecretEnv ?? defaultProviderEnv(id, "CLIENT_SECRET")];
|
|
173
|
-
if (
|
|
184
|
+
if (isString(clientId) && clientId !== "" && isString(clientSecret)) {
|
|
174
185
|
out[id] = { clientId, clientSecret };
|
|
175
186
|
}
|
|
176
187
|
}
|
|
177
188
|
return out;
|
|
178
189
|
}
|
|
179
190
|
|
|
191
|
+
/** Better Auth's Kysely database config: the connection's dialect and its SQL family. */
|
|
192
|
+
export interface AuthDatabaseConfig {
|
|
193
|
+
dialect: Dialect;
|
|
194
|
+
type: "sqlite" | "postgres";
|
|
195
|
+
}
|
|
196
|
+
|
|
180
197
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
198
|
+
* The BetterAuthOptions this module produces (typed structurally — the exact BetterAuthOptions type
|
|
199
|
+
* lives in better-auth, which this pure module never imports).
|
|
200
|
+
*/
|
|
201
|
+
export interface JxAuthOptions {
|
|
202
|
+
baseURL?: string;
|
|
203
|
+
advanced: AuthAdvancedOptions;
|
|
204
|
+
basePath: string;
|
|
205
|
+
emailAndPassword: { enabled: boolean };
|
|
206
|
+
rateLimit: { enabled: boolean; max: number; window: number };
|
|
207
|
+
session: { expiresIn: number; updateAge: number };
|
|
208
|
+
socialProviders?: AuthSocialProviders;
|
|
209
|
+
user?: {
|
|
210
|
+
additionalFields: { role: { input: boolean; required: boolean; type: "string" } };
|
|
211
|
+
};
|
|
212
|
+
trustedOrigins?: string[];
|
|
213
|
+
database?: AuthDatabaseConfig;
|
|
214
|
+
secret?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Map the auth section + env onto BetterAuthOptions. Optional keys are present only when set.
|
|
183
219
|
*
|
|
184
220
|
* @param {AuthSection} section
|
|
185
|
-
* @param {
|
|
221
|
+
* @param {HostEnv} env
|
|
186
222
|
* @param {object} [extras]
|
|
187
|
-
* @param {
|
|
223
|
+
* @param {AuthDatabaseConfig} [extras.database] - Better Auth database config ({ dialect, type })
|
|
188
224
|
* @param {string} [extras.secret] - Signing secret (omit for secret-free uses like migrations)
|
|
189
|
-
* @returns {
|
|
225
|
+
* @returns {JxAuthOptions} Options for `betterAuth()` / `getMigrations()`
|
|
190
226
|
*/
|
|
191
227
|
export function buildAuthOptions(
|
|
192
228
|
section: AuthSection,
|
|
193
|
-
env:
|
|
194
|
-
extras: { database?:
|
|
195
|
-
):
|
|
229
|
+
env: HostEnv,
|
|
230
|
+
extras: { database?: AuthDatabaseConfig; secret?: string } = {},
|
|
231
|
+
): JxAuthOptions {
|
|
196
232
|
const socialProviders = resolveSocialProviders(section, env);
|
|
197
233
|
const roles = section.roles ?? [];
|
|
198
234
|
const baseURL = env["BETTER_AUTH_URL"];
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
235
|
+
const pinned: Pick<JxAuthOptions, "baseURL"> = {};
|
|
236
|
+
// Without BETTER_AUTH_URL Better Auth derives the origin from each incoming request, which
|
|
237
|
+
// Is correct for same-origin /_jx/auth mounts; setting it pins callbacks for OAuth flows.
|
|
238
|
+
if (isString(baseURL) && baseURL !== "") {
|
|
239
|
+
pinned.baseURL = baseURL;
|
|
240
|
+
}
|
|
241
|
+
const options: JxAuthOptions = {
|
|
242
|
+
...pinned,
|
|
203
243
|
advanced: advancedCookieOptions(cookiesAreSecure(env)),
|
|
204
244
|
basePath: AUTH_BASE_PATH,
|
|
205
245
|
emailAndPassword: { enabled: section.methods?.emailPassword ?? true },
|
|
@@ -213,43 +253,61 @@ export function buildAuthOptions(
|
|
|
213
253
|
*/
|
|
214
254
|
rateLimit: { enabled: true, max: RATE_LIMIT_MAX, window: RATE_LIMIT_WINDOW_SECONDS },
|
|
215
255
|
session: { expiresIn: SESSION_EXPIRES_SECONDS, updateAge: SESSION_UPDATE_SECONDS },
|
|
216
|
-
...(Object.keys(socialProviders).length > 0 ? { socialProviders } : {}),
|
|
217
|
-
...(roles.length > 0
|
|
218
|
-
? {
|
|
219
|
-
user: {
|
|
220
|
-
additionalFields: {
|
|
221
|
-
// Roles are assigned via the data grid, never by sign-up input.
|
|
222
|
-
role: { input: false, required: false, type: "string" },
|
|
223
|
-
},
|
|
224
|
-
},
|
|
225
|
-
}
|
|
226
|
-
: {}),
|
|
227
|
-
...(section.trustedOrigins && section.trustedOrigins.length > 0
|
|
228
|
-
? { trustedOrigins: section.trustedOrigins }
|
|
229
|
-
: {}),
|
|
230
|
-
...(extras.database === undefined ? {} : { database: extras.database }),
|
|
231
|
-
...(extras.secret === undefined ? {} : { secret: extras.secret }),
|
|
232
256
|
};
|
|
257
|
+
if (Object.keys(socialProviders).length > 0) {
|
|
258
|
+
options.socialProviders = socialProviders;
|
|
259
|
+
}
|
|
260
|
+
if (roles.length > 0) {
|
|
261
|
+
options.user = {
|
|
262
|
+
additionalFields: {
|
|
263
|
+
// Roles are assigned via the data grid, never by sign-up input.
|
|
264
|
+
role: { input: false, required: false, type: "string" },
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (section.trustedOrigins && section.trustedOrigins.length > 0) {
|
|
269
|
+
options.trustedOrigins = section.trustedOrigins;
|
|
270
|
+
}
|
|
271
|
+
if (extras.database !== undefined) {
|
|
272
|
+
options.database = extras.database;
|
|
273
|
+
}
|
|
274
|
+
if (extras.secret !== undefined) {
|
|
275
|
+
options.secret = extras.secret;
|
|
276
|
+
}
|
|
277
|
+
return options;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A Better Auth session payload: `auth.api.getSession`'s result on the server (records carry `Date`
|
|
282
|
+
* fields), or the client's parsed `getSession().data` (dates revived from ISO strings).
|
|
283
|
+
*/
|
|
284
|
+
export interface AuthSessionPayload {
|
|
285
|
+
user?: AuthRecord | null;
|
|
286
|
+
session?: AuthRecord | null;
|
|
233
287
|
}
|
|
234
288
|
|
|
235
289
|
/**
|
|
236
290
|
* Map a Better Auth session payload ({ user, session }) onto the connector's SessionInfo shape.
|
|
237
291
|
*
|
|
238
|
-
* @param {
|
|
292
|
+
* @param {AuthSessionPayload | null | undefined} data - `auth.api.getSession` result or the
|
|
293
|
+
* client's `getSession().data`
|
|
239
294
|
* @returns {SessionInfo | null}
|
|
240
295
|
*/
|
|
241
|
-
export function toSessionInfo(data:
|
|
242
|
-
if (!data
|
|
296
|
+
export function toSessionInfo(data: AuthSessionPayload | null | undefined): SessionInfo | null {
|
|
297
|
+
if (!data) {
|
|
243
298
|
return null;
|
|
244
299
|
}
|
|
245
|
-
const { user, session } = data
|
|
246
|
-
if (!user ||
|
|
300
|
+
const { user, session } = data;
|
|
301
|
+
if (!user || !isString(user.id)) {
|
|
247
302
|
return null;
|
|
248
303
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
304
|
+
const info: SessionInfo = { userId: user.id };
|
|
305
|
+
if (isString(user.role) && user.role !== "") {
|
|
306
|
+
info.role = user.role;
|
|
307
|
+
}
|
|
308
|
+
info.user = user;
|
|
309
|
+
if (session !== undefined) {
|
|
310
|
+
info.session = session;
|
|
311
|
+
}
|
|
312
|
+
return info;
|
|
255
313
|
}
|
package/src/server.ts
CHANGED
|
@@ -20,21 +20,25 @@ import {
|
|
|
20
20
|
toSessionInfo,
|
|
21
21
|
} from "./config.ts";
|
|
22
22
|
import type { Dialect } from "kysely";
|
|
23
|
+
import type { HostEnv } from "@jxsuite/connector/provider-utils";
|
|
23
24
|
import type { ConnectionDef, SessionInfo } from "@jxsuite/connector/types";
|
|
24
|
-
import type {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
import type {
|
|
26
|
+
AuthDatabaseConfig,
|
|
27
|
+
AuthProjectConfig,
|
|
28
|
+
AuthSection,
|
|
29
|
+
AuthSessionPayload,
|
|
30
|
+
} from "./config.ts";
|
|
27
31
|
|
|
28
32
|
/** The provider surface the auth extension needs (a slice of ConnectorProvider). */
|
|
29
33
|
export interface AuthConnectorProvider {
|
|
30
34
|
kind: "sqlite" | "postgres";
|
|
31
|
-
dialect: (connection: ConnectionDef, env:
|
|
35
|
+
dialect: (connection: ConnectionDef, env: HostEnv) => Dialect | Promise<Dialect>;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/** The Better Auth surface consumed here (structural — betterAuth() returns a far larger type). */
|
|
35
39
|
export interface JxAuthInstance {
|
|
36
40
|
handler: (request: Request) => Promise<Response>;
|
|
37
|
-
api: { getSession: (input: { headers: Headers }) => Promise<
|
|
41
|
+
api: { getSession: (input: { headers: Headers }) => Promise<AuthSessionPayload | null> };
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export interface CreateJxAuthOptions {
|
|
@@ -48,9 +52,9 @@ export interface CreateJxAuthOptions {
|
|
|
48
52
|
async function resolveAuthDatabase(
|
|
49
53
|
section: AuthSection,
|
|
50
54
|
projectConfig: AuthProjectConfig,
|
|
51
|
-
env:
|
|
55
|
+
env: HostEnv,
|
|
52
56
|
connectors?: Record<string, AuthConnectorProvider>,
|
|
53
|
-
): Promise<
|
|
57
|
+
): Promise<AuthDatabaseConfig & { connection: string }> {
|
|
54
58
|
const connection = resolveAuthConnectionName(section, projectConfig);
|
|
55
59
|
const def = projectConfig.connections?.[connection];
|
|
56
60
|
if (!def) {
|
|
@@ -58,7 +62,7 @@ async function resolveAuthDatabase(
|
|
|
58
62
|
}
|
|
59
63
|
const provider = connectors?.[def.provider];
|
|
60
64
|
if (provider) {
|
|
61
|
-
const dialect = await provider.dialect({ ...def, $name: connection }
|
|
65
|
+
const dialect = await provider.dialect({ ...def, $name: connection }, env);
|
|
62
66
|
return { connection, dialect, type: provider.kind };
|
|
63
67
|
}
|
|
64
68
|
const resolved = await resolveDialect(connection, projectConfig, env);
|
|
@@ -70,14 +74,14 @@ async function resolveAuthDatabase(
|
|
|
70
74
|
*
|
|
71
75
|
* @param {AuthSection} section - The project.json `auth` section
|
|
72
76
|
* @param {AuthProjectConfig} projectConfig - Project config (connections lookup)
|
|
73
|
-
* @param {
|
|
77
|
+
* @param {HostEnv} env - Host environment (bindings + env vars; the secret lives here)
|
|
74
78
|
* @param {CreateJxAuthOptions} [options]
|
|
75
79
|
* @returns {Promise<JxAuthInstance>}
|
|
76
80
|
*/
|
|
77
81
|
export async function createJxAuth(
|
|
78
82
|
section: AuthSection,
|
|
79
83
|
projectConfig: AuthProjectConfig,
|
|
80
|
-
env:
|
|
84
|
+
env: HostEnv,
|
|
81
85
|
options: CreateJxAuthOptions = {},
|
|
82
86
|
): Promise<JxAuthInstance> {
|
|
83
87
|
const secret = resolveAuthSecret(section, env);
|
|
@@ -92,10 +96,10 @@ export async function createJxAuth(
|
|
|
92
96
|
secret,
|
|
93
97
|
});
|
|
94
98
|
if (options.autoSync) {
|
|
95
|
-
const migrations = await getMigrations(authOptions
|
|
99
|
+
const migrations = await getMigrations(authOptions);
|
|
96
100
|
await migrations.runMigrations();
|
|
97
101
|
}
|
|
98
|
-
return betterAuth(authOptions
|
|
102
|
+
return betterAuth(authOptions);
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
|
@@ -182,7 +186,7 @@ export function classifyAuthStatement(statement: string, connection: string): Au
|
|
|
182
186
|
*
|
|
183
187
|
* @param {AuthSection} section
|
|
184
188
|
* @param {AuthProjectConfig} projectConfig
|
|
185
|
-
* @param {
|
|
189
|
+
* @param {HostEnv} env
|
|
186
190
|
* @param {object} [options]
|
|
187
191
|
* @param {Record<string, AuthConnectorProvider>} [options.connectors] - Provider stand-ins
|
|
188
192
|
* @returns {Promise<AuthMigrationsResult>}
|
|
@@ -190,7 +194,7 @@ export function classifyAuthStatement(statement: string, connection: string): Au
|
|
|
190
194
|
export async function getAuthMigrations(
|
|
191
195
|
section: AuthSection,
|
|
192
196
|
projectConfig: AuthProjectConfig,
|
|
193
|
-
env:
|
|
197
|
+
env: HostEnv,
|
|
194
198
|
options: { connectors?: Record<string, AuthConnectorProvider> | undefined } = {},
|
|
195
199
|
): Promise<AuthMigrationsResult> {
|
|
196
200
|
const { dialect, type, connection } = await resolveAuthDatabase(
|
|
@@ -200,7 +204,7 @@ export async function getAuthMigrations(
|
|
|
200
204
|
options.connectors,
|
|
201
205
|
);
|
|
202
206
|
const authOptions = buildAuthOptions(section, env, { database: { dialect, type } });
|
|
203
|
-
const migrations = await getMigrations(authOptions
|
|
207
|
+
const migrations = await getMigrations(authOptions);
|
|
204
208
|
const sqlBlob = await migrations.compileMigrations();
|
|
205
209
|
const steps = splitSqlStatements(sqlBlob).map((statement) =>
|
|
206
210
|
classifyAuthStatement(statement, connection),
|
package/src/session.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* returns null, keeping statically generated pages on the signed-out state.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { isFunction } from "@jxsuite/schema/guards";
|
|
11
12
|
import { getAuthClient } from "./client.ts";
|
|
12
13
|
import { toSessionInfo } from "./config.ts";
|
|
13
14
|
import type { SessionInfo } from "@jxsuite/connector/types";
|
|
@@ -20,7 +21,7 @@ const listeners = new Set<SessionListener>();
|
|
|
20
21
|
|
|
21
22
|
/** True when running in a browser (session fetching is a client-only concern). */
|
|
22
23
|
export function inBrowser(): boolean {
|
|
23
|
-
return typeof document !== "undefined" &&
|
|
24
|
+
return typeof document !== "undefined" && isFunction(globalThis.fetch);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function notify(): void {
|
|
@@ -89,7 +90,6 @@ export function resetSessionStore(): void {
|
|
|
89
90
|
/** Config of the Session state class. */
|
|
90
91
|
export interface SessionConfig {
|
|
91
92
|
baseUrl?: string;
|
|
92
|
-
[key: string]: unknown;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
export class Session {
|
package/src/worker.ts
CHANGED
|
@@ -12,25 +12,24 @@
|
|
|
12
12
|
|
|
13
13
|
import { evaluatePermission } from "./authorize.ts";
|
|
14
14
|
import { createJxAuth, getAuthMigrations, getSessionContext } from "./server.ts";
|
|
15
|
+
import type { HostEnv } from "@jxsuite/connector/provider-utils";
|
|
15
16
|
import type {
|
|
16
17
|
AuthorizeDecision,
|
|
17
18
|
AuthorizeInput,
|
|
19
|
+
ConnectionDef,
|
|
18
20
|
JxServerContext,
|
|
19
21
|
SessionInfo,
|
|
20
22
|
} from "@jxsuite/connector/types";
|
|
21
23
|
import type { AuthConnectorProvider, JxAuthInstance } from "./server.ts";
|
|
22
24
|
import type { AuthProjectConfig, AuthSection } from "./config.ts";
|
|
23
25
|
|
|
24
|
-
type Env = Record<string, unknown>;
|
|
25
|
-
|
|
26
26
|
export interface AuthMountOptions {
|
|
27
27
|
/** Route subtree this mount owns. Defaults to "/_jx/auth". */
|
|
28
28
|
basePath?: string;
|
|
29
29
|
/** Extension-contributed project sections (needs `auth` and `connections`). */
|
|
30
30
|
sections: {
|
|
31
31
|
auth?: AuthSection;
|
|
32
|
-
connections?: Record<string,
|
|
33
|
-
[key: string]: unknown;
|
|
32
|
+
connections?: Record<string, ConnectionDef>;
|
|
34
33
|
};
|
|
35
34
|
/** Provider implementations keyed by `connector.provider` id (dev stand-ins applied by hosts). */
|
|
36
35
|
connectors?: Record<string, AuthConnectorProvider>;
|
|
@@ -40,7 +39,7 @@ export interface AuthMountOptions {
|
|
|
40
39
|
|
|
41
40
|
/** Per-isolate mount state: Better Auth instances memoized per env identity. */
|
|
42
41
|
export interface AuthMountState {
|
|
43
|
-
auths: WeakMap<
|
|
42
|
+
auths: WeakMap<HostEnv, Promise<JxAuthInstance>>;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
/** Create fresh mount state (exported for hosts that dispatch without `Auth.mount`). */
|
|
@@ -48,11 +47,14 @@ export function createAuthMountState(): AuthMountState {
|
|
|
48
47
|
return { auths: new WeakMap() };
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
/** The project-config slice the auth modules
|
|
52
|
-
|
|
50
|
+
/** The auth section plus the project-config slice the auth modules resolve it against. */
|
|
51
|
+
interface AuthMountConfig {
|
|
53
52
|
section: AuthSection;
|
|
54
53
|
projectConfig: AuthProjectConfig;
|
|
55
|
-
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The project-config slice the auth modules need, rebuilt from the mount's section manifest. */
|
|
57
|
+
function configFromSections(options: AuthMountOptions): AuthMountConfig {
|
|
56
58
|
const section = options.sections.auth ?? {};
|
|
57
59
|
return {
|
|
58
60
|
projectConfig: { auth: section, connections: options.sections.connections ?? {} },
|
|
@@ -64,7 +66,7 @@ function configFromSections(options: AuthMountOptions): {
|
|
|
64
66
|
export function getAuthForEnv(
|
|
65
67
|
state: AuthMountState,
|
|
66
68
|
options: AuthMountOptions,
|
|
67
|
-
env:
|
|
69
|
+
env: HostEnv,
|
|
68
70
|
): Promise<JxAuthInstance> {
|
|
69
71
|
const memo = state.auths.get(env);
|
|
70
72
|
if (memo) {
|
|
@@ -84,14 +86,14 @@ export function getAuthForEnv(
|
|
|
84
86
|
* Handle one /_jx/auth request by delegating to Better Auth's fetch handler.
|
|
85
87
|
*
|
|
86
88
|
* @param {Request} request
|
|
87
|
-
* @param {
|
|
89
|
+
* @param {HostEnv} env - Worker env (bindings + vars; the signing secret lives here)
|
|
88
90
|
* @param {AuthMountOptions} options
|
|
89
91
|
* @param {AuthMountState} [state] - Memoized instances; omit for a throwaway state
|
|
90
92
|
* @returns {Promise<Response>}
|
|
91
93
|
*/
|
|
92
94
|
export async function handleAuthRequest(
|
|
93
95
|
request: Request,
|
|
94
|
-
env:
|
|
96
|
+
env: HostEnv,
|
|
95
97
|
options: AuthMountOptions,
|
|
96
98
|
state: AuthMountState = createAuthMountState(),
|
|
97
99
|
): Promise<Response> {
|
|
@@ -115,7 +117,7 @@ export const Auth = {
|
|
|
115
117
|
ctx.auth = {
|
|
116
118
|
authorize: (input: AuthorizeInput): Promise<AuthorizeDecision> =>
|
|
117
119
|
Promise.resolve(evaluatePermission(input)),
|
|
118
|
-
getSession: async (request: Request, env:
|
|
120
|
+
getSession: async (request: Request, env: HostEnv): Promise<SessionInfo | null> => {
|
|
119
121
|
try {
|
|
120
122
|
const auth = await getAuthForEnv(state, options, env);
|
|
121
123
|
return await getSessionContext(auth, request);
|
|
@@ -128,37 +130,37 @@ export const Auth = {
|
|
|
128
130
|
}
|
|
129
131
|
},
|
|
130
132
|
};
|
|
131
|
-
return (request: Request, env:
|
|
133
|
+
return (request: Request, env: HostEnv): Promise<Response> =>
|
|
132
134
|
handleAuthRequest(request, env, options, state);
|
|
133
135
|
},
|
|
134
136
|
|
|
135
137
|
/** Static `projectData` capability: expose the auth section as `_project.auth`. */
|
|
136
|
-
projectData(sectionValue:
|
|
137
|
-
|
|
138
|
-
return { ...section };
|
|
138
|
+
projectData(sectionValue: AuthSection | null | undefined): AuthSection {
|
|
139
|
+
return { ...sectionValue };
|
|
139
140
|
},
|
|
140
141
|
|
|
141
142
|
/**
|
|
142
143
|
* Static `deploySchema` capability (section-owner variant): compile Better Auth's additive
|
|
143
144
|
* system-table migration into push steps the host composes after the connector plan.
|
|
144
145
|
*
|
|
145
|
-
* @param {
|
|
146
|
-
* @param {
|
|
146
|
+
* @param {AuthSection | null | undefined} sectionValue - The project.json `auth` section value
|
|
147
|
+
* @param {AuthProjectConfig | null | undefined} projectConfig - The full project config
|
|
148
|
+
* (connection lookup)
|
|
147
149
|
* @param {object} options - { env, dryRun?, connection?, connectors? }
|
|
148
150
|
* @returns {Promise<object>} { steps, applied, warnings, connection }
|
|
149
151
|
*/
|
|
150
152
|
async deploySchema(
|
|
151
|
-
sectionValue:
|
|
152
|
-
projectConfig:
|
|
153
|
+
sectionValue: AuthSection | null | undefined,
|
|
154
|
+
projectConfig: AuthProjectConfig | null | undefined,
|
|
153
155
|
options: {
|
|
154
|
-
env:
|
|
156
|
+
env: HostEnv;
|
|
155
157
|
dryRun?: boolean;
|
|
156
158
|
connection?: string;
|
|
157
159
|
connectors?: Record<string, AuthConnectorProvider>;
|
|
158
160
|
},
|
|
159
161
|
) {
|
|
160
|
-
const section =
|
|
161
|
-
const config =
|
|
162
|
+
const section = sectionValue ?? {};
|
|
163
|
+
const config = projectConfig ?? {};
|
|
162
164
|
const dryRun = options.dryRun === true;
|
|
163
165
|
const migrations = await getAuthMigrations(section, config, options.env, {
|
|
164
166
|
connectors: options.connectors,
|