@iskra-bun/auth-kit 0.1.0 → 0.2.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.
@@ -1,10 +1,11 @@
1
- import { betterAuth, type Auth as BetterAuthInstance } from "better-auth";
2
- import { drizzleAdapter, type DB as DrizzleAdapterDb } from "better-auth/adapters/drizzle";
3
- import { genericOAuth } from "better-auth/plugins/generic-oauth";
4
- import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
5
- import type { MySqlDatabase, MySqlQueryResultHKT, PreparedQueryHKTBase } from "drizzle-orm/mysql-core";
6
- import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
7
- import { pgSchema, mysqlSchema, sqliteSchema } from "./schema";
1
+ import { betterAuth, type Auth as BetterAuthInstance, type BetterAuthOptions } from 'better-auth';
2
+ import { drizzleAdapter, type DB as DrizzleAdapterDb } from 'better-auth/adapters/drizzle';
3
+ import { genericOAuth, type GenericOAuthUserInfo } from 'better-auth/plugins/generic-oauth';
4
+ import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
5
+ import type { MySqlDatabase, MySqlQueryResultHKT, PreparedQueryHKTBase } from 'drizzle-orm/mysql-core';
6
+ import type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
7
+ import { pgSchema, mysqlSchema, sqliteSchema } from './schema';
8
+ import { isProductionEnv } from '@iskra-bun/core';
8
9
 
9
10
  /**
10
11
  * The Drizzle database handle auth-kit accepts. A union of the supported dialect
@@ -15,19 +16,85 @@ import { pgSchema, mysqlSchema, sqliteSchema } from "./schema";
15
16
  export type AuthKitDrizzleDb =
16
17
  | PgDatabase<PgQueryResultHKT, Record<string, unknown>>
17
18
  | MySqlDatabase<MySqlQueryResultHKT, PreparedQueryHKTBase, Record<string, unknown>>
18
- | BaseSQLiteDatabase<"sync" | "async", unknown, Record<string, unknown>>;
19
+ | BaseSQLiteDatabase<'sync' | 'async', unknown, Record<string, unknown>>;
19
20
 
20
21
  /** Minimum length, in characters, for the session-signing secret. */
21
22
  const MIN_SECRET_LENGTH = 32;
22
23
 
24
+ /**
25
+ * Pieces of the sample secrets docs and `.env.example` files ship. Compared
26
+ * without case, `-`, `_`, `.` or spaces, so `change-me` also matches
27
+ * `changeme` and `CHANGE_ME`.
28
+ */
29
+ const PLACEHOLDER_SECRET_MARKERS = ['change-me', 'dev-secret', 'dev-only', 'your-secret', 'placeholder'];
30
+
31
+ const normalizeSecret = (value: string) => value.toLowerCase().replace(/[-_.\s]/g, '');
32
+
33
+ /** The placeholder marker `secret` contains, if any. */
34
+ function placeholderMarker(secret: string): string | undefined {
35
+ const normalized = normalizeSecret(secret);
36
+ return PLACEHOLDER_SECRET_MARKERS.find((marker) => normalized.includes(normalizeSecret(marker)));
37
+ }
38
+
39
+ /** Hosts a production baseURL may reach over plain http (e.g. docker compose on one machine). */
40
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
41
+
42
+ /**
43
+ * The origin better-auth runs on: `baseURL`, else `BETTER_AUTH_URL`, else
44
+ * `http://localhost:3000` outside production. It decides the cookies'
45
+ * `Secure` flag and is a trusted origin, so production requires one and it
46
+ * must be https (plain http only for localhost, 127.0.0.1 and [::1]). `who`
47
+ * prefixes the error messages.
48
+ */
49
+ export function resolveAuthBaseURL(baseURL?: string, who = 'createBetterAuth'): string {
50
+ const resolved = baseURL || process.env.BETTER_AUTH_URL;
51
+ if (resolved) {
52
+ assertHttpsInProduction(resolved, who);
53
+ return resolved;
54
+ }
55
+ if (isProductionEnv()) {
56
+ throw new Error(
57
+ `${who}: set baseURL (or BETTER_AUTH_URL) to the app's public origin in production, ` +
58
+ 'e.g. "https://app.example.com"; without it cookies are sent without Secure.',
59
+ );
60
+ }
61
+ return 'http://localhost:3000';
62
+ }
63
+
64
+ /**
65
+ * better-auth marks the session cookies Secure only for an https baseURL, so
66
+ * an http:// one in production sent them over plain HTTP as well.
67
+ */
68
+ function assertHttpsInProduction(baseURL: string, who: string): void {
69
+ if (!isProductionEnv()) return;
70
+ let url: URL;
71
+ try {
72
+ url = new URL(baseURL);
73
+ } catch {
74
+ return; // an invalid URL is reported by the caller
75
+ }
76
+ if (url.protocol === 'https:' || LOCAL_HOSTNAMES.has(url.hostname)) return;
77
+ throw new Error(
78
+ `${who}: baseURL "${baseURL}" must use https in production: better-auth marks the session cookies ` +
79
+ 'Secure only for an https baseURL. Plain http is allowed only for localhost, 127.0.0.1 and [::1].',
80
+ );
81
+ }
82
+
23
83
  export interface BetterAuthConfigOptions {
24
84
  db: AuthKitDrizzleDb;
25
- adapterType: "postgres" | "mysql" | "sqlite";
85
+ adapterType: 'postgres' | 'mysql' | 'sqlite';
26
86
  secret: string;
87
+ /**
88
+ * The app's public origin. Defaults to `BETTER_AUTH_URL`, then (outside
89
+ * production only) `http://localhost:3000`; must be https in production
90
+ * unless it is localhost. See `resolveAuthBaseURL`.
91
+ */
27
92
  baseURL?: string;
28
93
  basePath?: string;
29
94
  trustedOrigins?: string[];
30
95
  enableEmailPassword?: boolean;
96
+ /** Reject `/sign-up/email` (accounts are provisioned another way). Default false. */
97
+ disableSignUp?: boolean;
31
98
  disableCSRFCheck?: boolean;
32
99
  /**
33
100
  * Cookie-cache lifetime in seconds. This is the session-revocation lag: a
@@ -36,8 +103,19 @@ export interface BetterAuthConfigOptions {
36
103
  * frequent DB lookups). Defaults to 300 (5 minutes).
37
104
  */
38
105
  cookieCacheMaxAge?: number;
39
- // deno-lint-ignore no-explicit-any
40
- socialProviders?: Record<string, any>;
106
+ /**
107
+ * `false` turns off better-auth's own rate limiter (on by default in
108
+ * production), e.g. when the app limits the auth routes itself.
109
+ */
110
+ rateLimit?: false;
111
+ /**
112
+ * Request headers better-auth reads the client IP from, for its rate
113
+ * limiter and the sessions' `ipAddress` (default `x-forwarded-for`, first
114
+ * entry). Without a usable one, every client shares one rate-limit bucket.
115
+ */
116
+ ipAddressHeaders?: string[];
117
+ /** better-auth's socialProviders option, as is. */
118
+ socialProviders?: BetterAuthOptions['socialProviders'];
41
119
  oidcConfig?: {
42
120
  clientId: string;
43
121
  clientSecret: string;
@@ -46,18 +124,91 @@ export interface BetterAuthConfigOptions {
46
124
  authorizationEndpoint?: string;
47
125
  tokenEndpoint?: string;
48
126
  userinfoEndpoint?: string;
127
+ /**
128
+ * @deprecated Ignored: better-auth takes the JWKS only from the
129
+ * discovery document's `jwks_uri`; point `discoveryEndpoint` at a
130
+ * document that has the right one instead.
131
+ */
49
132
  jwksEndpoint?: string;
50
133
  discoveryEndpoint?: string;
51
134
  scopes?: string[];
52
135
  pkce?: boolean;
53
- mapping?: {
54
- id?: string;
55
- email?: string;
56
- emailVerified?: string;
57
- name?: string;
58
- image?: string;
59
- extraFields?: Record<string, string>;
60
- };
136
+ /** Claim names to read the user's fields from, instead of the standard ones. */
137
+ mapping?: OidcClaimMapping;
138
+ };
139
+ }
140
+
141
+ /** Claim names `mapOidcProfile` reads the user's fields from. */
142
+ export interface OidcClaimMapping {
143
+ /**
144
+ * @deprecated Ignored: better-auth always takes the account's identity
145
+ * from the verified `sub` claim.
146
+ */
147
+ id?: string;
148
+ email?: string;
149
+ /** The claim is read as verified when it is `true` or `"true"`. */
150
+ emailVerified?: string;
151
+ name?: string;
152
+ image?: string;
153
+ /**
154
+ * @deprecated Ignored: extra user fields need better-auth
155
+ * `user.additionalFields`, which `createBetterAuth` does not declare.
156
+ */
157
+ extraFields?: Record<string, string>;
158
+ }
159
+
160
+ /**
161
+ * The local user fields for an OIDC login (standard claims, with the common
162
+ * non-standard fallbacks). A claim named in `mapping` is read instead of the
163
+ * standard one when the profile has it. No `id`: better-auth takes the
164
+ * account's identity from the verified `sub` (accountSubject) and ignores one
165
+ * returned here.
166
+ */
167
+ export function mapOidcProfile(profile: GenericOAuthUserInfo, mapping: OidcClaimMapping = {}) {
168
+ const str = (v: unknown) => (typeof v === 'string' && v !== '' ? v : undefined);
169
+ const claim = (name: string | undefined) => (name ? profile[name] : undefined);
170
+ const isTrue = (v: unknown) => v === true || v === 'true';
171
+ const standardVerified = profile.emailVerified === true || isTrue(profile.email_verified);
172
+ const mappedEmail = str(claim(mapping.email));
173
+ // better-auth links a login to the local user with the same email when it
174
+ // is verified, so a flag must vouch for the address actually returned.
175
+ // A mapped flag pairs with the mapped email (or with the standard one when
176
+ // mapping.email is not set); the standard flag only vouches for the
177
+ // standard email.
178
+ const pairedFlag = mappedEmail !== undefined || !mapping.email ? claim(mapping.emailVerified) : undefined;
179
+ let emailVerified: boolean;
180
+ if (pairedFlag !== undefined && pairedFlag !== null) emailVerified = isTrue(pairedFlag);
181
+ else if (mappedEmail !== undefined) emailVerified = mappedEmail === profile.email && standardVerified;
182
+ else emailVerified = standardVerified;
183
+ return {
184
+ email: mappedEmail ?? profile.email,
185
+ name: str(claim(mapping.name)) ?? (profile.name || str(profile.preferred_username)),
186
+ image: str(claim(mapping.image)) ?? str(profile.picture) ?? profile.image,
187
+ emailVerified,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * The generic-oauth provider config for `oidcConfig`. The endpoints left
193
+ * unset come from the issuer's discovery document: better-auth fills only
194
+ * the ones not given, so a default here (e.g. Keycloak's
195
+ * `/protocol/openid-connect/*` paths) would override discovery for every
196
+ * other provider.
197
+ */
198
+ export function oidcProviderConfig(oidcConfig: NonNullable<BetterAuthConfigOptions['oidcConfig']>) {
199
+ return {
200
+ providerId: oidcConfig.providerId || 'oidc',
201
+ clientId: oidcConfig.clientId,
202
+ clientSecret: oidcConfig.clientSecret,
203
+ authorizationUrl: oidcConfig.authorizationEndpoint || undefined,
204
+ tokenUrl: oidcConfig.tokenEndpoint || undefined,
205
+ userInfoUrl: oidcConfig.userinfoEndpoint || undefined,
206
+ discoveryUrl: oidcConfig.discoveryEndpoint || `${oidcConfig.issuer}/.well-known/openid-configuration`,
207
+ scopes: oidcConfig.scopes || ['openid', 'email', 'profile'],
208
+ // Secure default: PKCE on. Disabling exposes auth-code
209
+ // interception/injection and requires an explicit false.
210
+ pkce: oidcConfig.pkce !== undefined ? oidcConfig.pkce : true,
211
+ mapProfileToUser: (profile: GenericOAuthUserInfo) => mapOidcProfile(profile, oidcConfig.mapping),
61
212
  };
62
213
  }
63
214
 
@@ -66,14 +217,16 @@ export function createBetterAuth(options: BetterAuthConfigOptions): BetterAuthIn
66
217
  db,
67
218
  adapterType,
68
219
  secret,
69
- baseURL = "http://localhost:3000",
70
- basePath = "/api/auth",
220
+ basePath = '/api/auth',
71
221
  trustedOrigins = [],
72
222
  enableEmailPassword = true,
223
+ disableSignUp = false,
73
224
  disableCSRFCheck = false,
74
225
  cookieCacheMaxAge = 5 * 60,
75
226
  socialProviders,
76
227
  oidcConfig,
228
+ rateLimit,
229
+ ipAddressHeaders,
77
230
  } = options;
78
231
 
79
232
  // A weak or empty secret signs forgeable sessions, so reject it before
@@ -83,27 +236,36 @@ export function createBetterAuth(options: BetterAuthConfigOptions): BetterAuthIn
83
236
  `auth secret must be at least ${MIN_SECRET_LENGTH} characters; received ${secret ? secret.length : 0}`,
84
237
  );
85
238
  }
239
+ // A sample secret left in production is public: with it anyone can sign
240
+ // the session cookie cache, which is trusted without a database lookup,
241
+ // and so forge a session for any user.
242
+ const marker = isProductionEnv() ? placeholderMarker(secret) : undefined;
243
+ if (marker) {
244
+ throw new Error(
245
+ `auth secret looks like a placeholder (it contains "${marker}"); in production set a random secret ` +
246
+ `of at least ${MIN_SECRET_LENGTH} characters, e.g. from \`openssl rand -base64 32\``,
247
+ );
248
+ }
86
249
 
250
+ const baseURL = resolveAuthBaseURL(options.baseURL);
87
251
  const baseOrigin = new URL(baseURL).origin;
88
- const allTrustedOrigins = trustedOrigins.includes(baseOrigin)
89
- ? trustedOrigins
90
- : [baseOrigin, ...trustedOrigins];
252
+ const allTrustedOrigins = trustedOrigins.includes(baseOrigin) ? trustedOrigins : [baseOrigin, ...trustedOrigins];
91
253
 
92
254
  let schema;
93
- let provider: "pg" | "mysql" | "sqlite";
255
+ let provider: 'pg' | 'mysql' | 'sqlite';
94
256
 
95
257
  switch (adapterType) {
96
- case "postgres":
258
+ case 'postgres':
97
259
  schema = pgSchema;
98
- provider = "pg";
260
+ provider = 'pg';
99
261
  break;
100
- case "mysql":
262
+ case 'mysql':
101
263
  schema = mysqlSchema;
102
- provider = "mysql";
264
+ provider = 'mysql';
103
265
  break;
104
- case "sqlite":
266
+ case 'sqlite':
105
267
  schema = sqliteSchema;
106
- provider = "sqlite";
268
+ provider = 'sqlite';
107
269
  break;
108
270
  default:
109
271
  throw new Error(`Unsupported adapter type: ${adapterType}`);
@@ -111,47 +273,12 @@ export function createBetterAuth(options: BetterAuthConfigOptions): BetterAuthIn
111
273
 
112
274
  const database = drizzleAdapter(db as unknown as DrizzleAdapterDb, {
113
275
  provider,
114
- schema
276
+ schema,
115
277
  });
116
278
 
117
279
  const plugins = [];
118
280
  if (oidcConfig) {
119
- const authorizationUrl = oidcConfig.authorizationEndpoint ||
120
- `${oidcConfig.issuer}/protocol/openid-connect/auth`;
121
- const tokenUrl = oidcConfig.tokenEndpoint ||
122
- `${oidcConfig.issuer}/protocol/openid-connect/token`;
123
- const userInfoUrl = oidcConfig.userinfoEndpoint ||
124
- `${oidcConfig.issuer}/protocol/openid-connect/userinfo`;
125
-
126
- plugins.push(
127
- genericOAuth({
128
- config: [
129
- {
130
- providerId: oidcConfig.providerId || "oidc",
131
- clientId: oidcConfig.clientId,
132
- clientSecret: oidcConfig.clientSecret,
133
- authorizationUrl,
134
- tokenUrl,
135
- userInfoUrl,
136
- discoveryUrl: oidcConfig.discoveryEndpoint ||
137
- `${oidcConfig.issuer}/.well-known/openid-configuration`,
138
- scopes: oidcConfig.scopes || ["openid", "email", "profile"],
139
- // Secure default: PKCE on. Disabling exposes auth-code
140
- // interception/injection and requires an explicit false.
141
- pkce: oidcConfig.pkce !== undefined ? oidcConfig.pkce : true,
142
- mapProfileToUser: (profile: any) => {
143
- return {
144
- id: profile.sub || profile.id,
145
- email: profile.email,
146
- name: profile.name || profile.preferred_username,
147
- image: profile.picture || profile.image,
148
- emailVerified: profile.email_verified || false,
149
- };
150
- },
151
- },
152
- ],
153
- }),
154
- );
281
+ plugins.push(genericOAuth({ config: [oidcProviderConfig(oidcConfig)] }));
155
282
  }
156
283
 
157
284
  return betterAuth({
@@ -162,9 +289,10 @@ export function createBetterAuth(options: BetterAuthConfigOptions): BetterAuthIn
162
289
  trustedOrigins: allTrustedOrigins,
163
290
  emailAndPassword: enableEmailPassword
164
291
  ? {
165
- enabled: true,
166
- autoSignIn: true,
167
- }
292
+ enabled: true,
293
+ autoSignIn: true,
294
+ disableSignUp,
295
+ }
168
296
  : undefined,
169
297
  socialProviders: Object.keys(socialProviders || {}).length > 0 ? socialProviders : undefined,
170
298
  plugins,
@@ -176,9 +304,16 @@ export function createBetterAuth(options: BetterAuthConfigOptions): BetterAuthIn
176
304
  maxAge: cookieCacheMaxAge,
177
305
  },
178
306
  },
307
+ ...(rateLimit === false ? { rateLimit: { enabled: false } } : {}),
179
308
  advanced: {
180
309
  disableCSRFCheck,
181
- generateId: () => crypto.randomUUID().replace(/-/g, ""),
310
+ // Pinned: left unset, better-auth skips its Origin check (CSRF on
311
+ // cookie requests) and its callbackURL/redirectTo validation (open
312
+ // redirects) whenever it thinks it runs under test: NODE_ENV=test,
313
+ // or any TEST variable other than "false" (TEST=0 included).
314
+ disableOriginCheck: false,
315
+ generateId: () => crypto.randomUUID().replace(/-/g, ''),
316
+ ...(ipAddressHeaders ? { ipAddress: { ipAddressHeaders } } : {}),
182
317
  },
183
318
  }) as unknown as BetterAuthInstance;
184
319
  }
package/src/index.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export {
2
2
  createBetterAuth,
3
+ mapOidcProfile,
4
+ oidcProviderConfig,
5
+ resolveAuthBaseURL,
3
6
  type Auth,
4
7
  type AuthKitDrizzleDb,
5
8
  type BetterAuthConfigOptions,
6
- } from "./better-auth-config";
9
+ type OidcClaimMapping,
10
+ } from './better-auth-config';
7
11
 
8
12
  export {
9
13
  pgUser,
@@ -21,7 +25,7 @@ export {
21
25
  sqliteAccount,
22
26
  sqliteVerification,
23
27
  sqliteSchema,
24
- } from "./schema";
28
+ } from './schema';
25
29
 
26
30
  export type {
27
31
  User,
@@ -36,4 +40,4 @@ export type {
36
40
  PasswordResetRequest,
37
41
  PasswordResetConfirm,
38
42
  EmailVerificationRequest,
39
- } from "./types";
43
+ } from './types';