@stacksjs/auth 0.70.380 → 0.71.2

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/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from './client';
5
5
  export * from './middleware';
6
6
  export * from './rate-limiter';
7
7
  // WebAuthn/Passkey support (now using ts-auth - no external dependencies)
8
+ export * from './magic-link';
8
9
  export * from './passkey';
9
10
  export * from './password/reset';
10
11
  export * from './register';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./rate-limiter";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";
1
+ export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Issue and send a magic link. ALWAYS resolves void - unknown emails are a
3
+ * silent no-op (unless `createUser`), so callers can answer a uniform
4
+ * "check your email" without leaking which addresses exist. Callers gate
5
+ * with their route's `.rateLimit()`; this adds a per-email limiter on top.
6
+ */
7
+ export declare function sendMagicLink(email: string, options?: SendMagicLinkOptions): Promise<void>;
8
+ /**
9
+ * Atomically consume a raw token. Exactly one caller can win a given token:
10
+ * the claim is a conditional UPDATE on (unconsumed AND unexpired), so two
11
+ * concurrent consumes resolve to one `ok` and one `used`.
12
+ */
13
+ export declare function consumeMagicLink(raw: string): Promise<ConsumeMagicLinkResult>;
14
+ /** Drop expired and long-consumed rows. For a daily janitor job. */
15
+ export declare function pruneMagicLinkTokens(olderThanDays?: number): Promise<void>;
16
+ /**
17
+ * Magic-link (passwordless) sign-in.
18
+ *
19
+ * Mirrors `password/reset.ts` where the concerns match (anti-enumeration,
20
+ * single outstanding token per email, template-with-plaintext-fallback
21
+ * delivery) and deliberately differs where they don't:
22
+ *
23
+ * - `token` at rest is SHA-256, not bcrypt: consume is one indexed
24
+ * lookup, and the raw token carries 256 bits of entropy, so a fast
25
+ * hash gives an offline attacker nothing.
26
+ * - Consume is ATOMIC single-use: one conditional UPDATE claims the row;
27
+ * concurrent requests race for one winner. GET never consumes - the
28
+ * interstitial page POSTs, because mail scanners prefetch GETs and
29
+ * would burn single-use links.
30
+ */
31
+ export declare interface SendMagicLinkOptions {
32
+ redirectTo?: string
33
+ siteId?: number
34
+ ttlMinutes?: number
35
+ createUser?: boolean
36
+ }
37
+ export type ConsumeMagicLinkResult = | { ok: true, userId: number, email: string, redirectTo: string }
38
+ | { ok: false, reason: 'invalid' | 'expired' | 'used' | 'no-user' }
@@ -0,0 +1,3 @@
1
+ import{createHash,randomBytes}from"node:crypto";import process from"node:process";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/";if(!candidate)return fallback;if(!candidate.startsWith("/")||candidate.startsWith("//"))return fallback;return candidate}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).where("verified_at","is not",null).select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`}export async function sendMagicLink(email,options={}){const normalized=email.trim().toLowerCase();if(await RateLimiter.isRateLimited(normalized))return;await RateLimiter.recordFailedAttempt(normalized);let user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst();if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst()}if(!user)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),redirectTo=safeRedirect(options.redirectTo);await db.deleteFrom("magic_link_tokens").where("email","=",normalized).where("consumed_at","is",null).execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:user.id,token:hashToken(raw),expires_at:sqlDateTime(new Date(Date.now()+ttl*60000)),redirect_to:redirectTo,site_id:options.siteId??null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();const base=await linkBase(options.siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await template("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
2
+
3
+ This link works once and expires in ${ttl} minutes. If you didn't request it, ignore this email.`})}catch(error){log.error(`Magic-link email to ${normalized} failed: ${error.message}`)}}export async function consumeMagicLink(raw){if(!raw||raw.length>255)return{ok:!1,reason:"invalid"};const hashed=hashToken(raw),now=sqlDateTime(new Date),claim=await db.updateTable("magic_link_tokens").set({consumed_at:now,updated_at:now}).where("token","=",hashed).where("consumed_at","is",null).where("expires_at",">",now).execute(),updated=typeof claim==="number"?claim:Number(claim?.numUpdatedRows??claim?.changes??0),row=await db.selectFrom("magic_link_tokens").where("token","=",hashed).select(["user_id","email","expires_at","consumed_at","redirect_to"]).executeTakeFirst();if(!row)return{ok:!1,reason:"invalid"};if(updated<1){if(!row.consumed_at&&new Date(row.expires_at).getTime()<=Date.now())return{ok:!1,reason:"expired"};return{ok:!1,reason:"used"}}if(!row.user_id)return{ok:!1,reason:"no-user"};return{ok:!0,userId:Number(row.user_id),email:row.email,redirectTo:safeRedirect(row.redirect_to)}}export async function pruneMagicLinkTokens(olderThanDays=7){const cutoff=sqlDateTime(new Date(Date.now()-olderThanDays*86400000));await db.deleteFrom("magic_link_tokens").where("expires_at","<",cutoff).execute()}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.380",
5
+ "version": "0.71.2",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -60,8 +60,8 @@
60
60
  "@stacksjs/ts-auth": "^0.4.3"
61
61
  },
62
62
  "devDependencies": {
63
- "better-dx": "^0.2.17",
64
- "@stacksjs/error-handling": "0.70.380",
65
- "@stacksjs/router": "0.70.380"
63
+ "better-dx": "^0.2.23",
64
+ "@stacksjs/error-handling": "0.71.2",
65
+ "@stacksjs/router": "0.71.2"
66
66
  }
67
67
  }