@stacksjs/auth 0.75.447 → 0.75.450

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
@@ -76,4 +76,5 @@ export * from './team';
76
76
  export * from './referrals';
77
77
  export * from './oauth-dynamic-registration';
78
78
  export * from './oauth-protected-resource';
79
+ export * from './oauth-userinfo';
79
80
  export * from './oauth-server';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./authentication";export*from"./impersonation";export{requestImpersonation}from"./impersonation-auth";export*from"./impersonation-store";export*from"./authenticator";export*from"./client";export*from"./credential-user";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-introspection";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./passkeys";export*from"./password/reset";export*from"./password/reset-token";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"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export*from"./session-handoff";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-server";
1
+ export*from"./authentication";export*from"./impersonation";export{requestImpersonation}from"./impersonation-auth";export*from"./impersonation-store";export*from"./authenticator";export*from"./client";export*from"./credential-user";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-introspection";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./passkeys";export*from"./password/reset";export*from"./password/reset-token";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"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export*from"./session-handoff";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-userinfo";export*from"./oauth-server";
@@ -0,0 +1,27 @@
1
+ import type { OAuthDelegatedAccessRequirement, OAuthDelegatedAccessResult, OAuthDelegatedSubject } from './oauth-delegated-access';
2
+ /**
3
+ * A subject identifier unique to one client: the same person reads as a
4
+ * different `sub` to every relying party, so two clients that compare notes
5
+ * cannot join their records on it (OpenID Connect Core §8.1, pairwise).
6
+ * Stable for a client as long as `secret` is; rotating the secret re-keys
7
+ * every relying party, so keep it apart from rotating application keys.
8
+ */
9
+ export declare function pairwiseOAuthSubject(secret: string, clientId: string | number, subject: Pick<OAuthDelegatedSubject, 'type' | 'id'>): string;
10
+ /**
11
+ * A userinfo endpoint for this provider's delegated tokens: the same
12
+ * resource, audience and eligibility checks as any protected resource, then
13
+ * a pairwise `sub` plus the claims the granted scopes allow. The provider has
14
+ * no id_token, so a relying party signs a person in with this answer.
15
+ */
16
+ export declare function oauthUserInfoResponse(request: Request, options: OAuthUserInfoOptions): Promise<Response>;
17
+ export declare interface OAuthUserInfoContext {
18
+ sub: string
19
+ subject: OAuthDelegatedSubject
20
+ clientId: number
21
+ scopes: readonly string[]
22
+ }
23
+ export declare interface OAuthUserInfoOptions extends OAuthDelegatedAccessRequirement {
24
+ pairwiseSecret: string
25
+ claims: (context: OAuthUserInfoContext) => Promise<Record<string, unknown> | null>
26
+ authorize?: (bearer: string, requirement: OAuthDelegatedAccessRequirement) => Promise<OAuthDelegatedAccessResult>
27
+ }
@@ -0,0 +1 @@
1
+ import{createHmac}from"node:crypto";import{authorizeOAuthDelegatedToken,oauthBearerAuthorizationErrorResponse}from"./oauth-delegated-access";export function pairwiseOAuthSubject(secret,clientId,subject){if(secret.length<32)throw TypeError("A pairwise subject secret must be at least 32 characters.");if(clientId===""||clientId==null||!subject.type||!Number.isSafeInteger(subject.id))throw TypeError("A pairwise subject needs a client, a subject type and a numeric subject id.");return createHmac("sha256",secret).update(`${clientId}\x00${subject.type}\x00${subject.id}`).digest("base64url")}function bearerOf(request){const header=request.headers.get("authorization")||"",match=/^Bearer ([\x21-\x7E]{1,4096})$/.exec(header);return match?match[1]:null}export async function oauthUserInfoResponse(request,options){const bearer=bearerOf(request);if(!bearer)return oauthBearerAuthorizationErrorResponse({ok:!1,reason:"invalid_token"});const{pairwiseSecret,claims,authorize=authorizeOAuthDelegatedToken,...requirement}=options;let subject=null;const result=await authorize(bearer,{...requirement,isSubjectEligible:async(candidate)=>{subject=candidate;return requirement.isSubjectEligible(candidate)}});if(!result.ok)return oauthBearerAuthorizationErrorResponse(result);const resolved=subject;if(!resolved)return oauthBearerAuthorizationErrorResponse({ok:!1,reason:"invalid_token"});const sub=pairwiseOAuthSubject(pairwiseSecret,resolved.clientId,resolved),released=await claims({sub,subject:resolved,clientId:resolved.clientId,scopes:[...result.token.scopes]});if(!released)return oauthBearerAuthorizationErrorResponse({ok:!1,reason:"invalid_token"});return new Response(JSON.stringify({...released,sub}),{headers:{"Cache-Control":"no-store",Pragma:"no-cache","Content-Type":"application/json; charset=utf-8"}})}
@@ -27,7 +27,7 @@ export declare function sendPasswordChangedNotification(userEmail: string): Prom
27
27
  * - stamps `users.password_changed_at`, which the token paths read to refuse
28
28
  * any token issued before it, so even a token this sweep could not see is
29
29
  * dead;
30
- * - revokes pending two-factor login challenges, magic links, OAuth grants
30
+ * - revokes pending password resets, two-factor login challenges, magic links, OAuth grants
31
31
  * and authorization codes, access and refresh tokens, and sessions;
32
32
  * - all in one transaction: a failure anywhere rolls the whole sweep back
33
33
  * rather than reporting a half-revoked account.
@@ -1,8 +1,8 @@
1
- import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,isInTransaction,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{makeHash,verifyHash}from"@stacksjs/security";import{withVerifiedPassword}from"../credential-version";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";import{sessionDestroyAll}from"../session-auth";import{revokeTokenPairs}from"../token-revocation";import{DEFAULT_TOKENABLE_TYPE}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";async function mysqlPasswordStampMatches(userId,stamp){const columns=await db.primary.unsafe(`
1
+ import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,isInTransaction,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{makeHash,verifyHash}from"@stacksjs/security";import{withVerifiedPassword}from"../credential-version";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";import{sessionDestroyAll}from"../session-auth";import{revokeTokenPairs}from"../token-revocation";import{DEFAULT_TOKENABLE_TYPE}from"../tokens";import{deletePasswordResetCredentials}from"./reset-store";import{revokeTwoFactorChallenges}from"../two-factor";async function mysqlPasswordStampMatches(userId,stamp){const columns=await db.primary.unsafe(`
2
2
  SELECT DATA_TYPE AS kind, DATETIME_PRECISION AS stamp_precision
3
3
  FROM information_schema.COLUMNS
4
4
  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'password_changed_at'
5
5
  `).execute(),column=columns[0];if(columns.length!==1||!column||!["datetime","timestamp"].includes(String(column.kind))||!/^[0-6]$/.test(String(column.stamp_precision)))throw Error("[auth] Cannot verify password_changed_at temporal precision.");const precision=Number(column.stamp_precision),rows=await db.primary.unsafe(`
6
6
  SELECT (password_changed_at = CAST(? AS DATETIME(${precision}))) AS stamp_matches
7
7
  FROM users WHERE id = ?
8
- `,[stamp,userId]).execute();return rows.length===1&&(rows[0]?.stamp_matches===!0||rows[0]?.stamp_matches===1)}export async function revokeAccountCredentials(userId,options={}){if(!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] revokeAccountCredentials needs a positive integer user id.");if(options.exceptTokenId!==void 0&&(!Number.isSafeInteger(options.exceptTokenId)||options.exceptTokenId<=0))throw TypeError("[auth] exceptTokenId must be a positive integer.");if(options.tx!==void 0&&!isInTransaction())throw TypeError("[auth] revokeAccountCredentials was given a tx but is not running inside that transaction.");return db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)throw Error("[auth] revokeAccountCredentials: the user does not exist.");let passwordChangedAt=null;const stampValue=sqlDateTime(new Date);if(Object.hasOwn(owner,"password_changed_at")){const stamped=await db.updateTable("users").set({password_changed_at:stampValue}).where("id","=",userId).execute(),changed=mutationCount(stamped);if(changed!==1&&!(changed===0&&getDatabaseDialect()==="mysql"&&await mysqlPasswordStampMatches(userId,stampValue)))throw Error("[auth] revokeAccountCredentials could not stamp password_changed_at.");passwordChangedAt=parseSqlDateTime(stampValue)}else log.warn("[auth] password_changed_at column missing - run `buddy migrate`; credentials are revoked without the credential-version stamp");await revokeTwoFactorChallenges(userId);await revokeMagicLinks(userId);await revokeOAuthSubjectAuthorizationState("users",userId);await revokeTokenPairs(userId,DEFAULT_TOKENABLE_TYPE,options.exceptTokenId);await sessionDestroyAll(userId,options.exceptSessionId!==void 0?{exceptId:options.exceptSessionId}:{});let keptTokenId=null;if(options.exceptTokenId!==void 0&&passwordChangedAt){const rebound=await db.updateTable("oauth_access_tokens").set({created_at:stampValue}).where("id","=",options.exceptTokenId).where("tokenable_id","=",userId).execute();if(mutationCount(rebound)===1){await db.updateTable("oauth_refresh_tokens").set({created_at:stampValue}).where("access_token_id","=",options.exceptTokenId).execute();keptTokenId=options.exceptTokenId}}else if(options.exceptTokenId!==void 0)keptTokenId=options.exceptTokenId;return{passwordChangedAt,keptTokenId}})}export async function changePassword(userId,currentPassword,newPassword,options={}){if(typeof newPassword!=="string"||newPassword.length===0)return{success:!1,reason:"invalid-new-password"};const user=await db.primary.selectFrom("users").where("id","=",userId).select(["id","email","name","password"]).executeTakeFirst();if(!user)return{success:!1,reason:"user-not-found"};const storedHash=typeof user.password==="string"?user.password:"";if(typeof currentPassword!=="string"||!storedHash||!await verifyHash(currentPassword,storedHash))return{success:!1,reason:"invalid-current-password"};const hashed=await makeHash(newPassword,{algorithm:"bcrypt"}),swept=await withVerifiedPassword(userId,storedHash,async()=>{const updated=await db.updateTable("users").set({password:hashed}).where("id","=",userId).execute();if(mutationCount(updated)!==1)throw Error("[auth] changePassword could not update the password.");return revokeAccountCredentials(userId,{...options.keepTokenId!==void 0&&{exceptTokenId:options.keepTokenId},...options.keepSessionId!==void 0&&{exceptSessionId:options.keepSessionId}})});if(!swept)return{success:!1,reason:"invalid-current-password"};const notify=options.notify??!0;if(notify!==!1&&user.email){const recipient={id:Number(user.id),email:String(user.email),...user.name?{name:String(user.name)}:{}},send=async()=>{try{if(typeof notify==="function")await notify(recipient);else await sendPasswordChangedNotification(recipient.email)}catch(error){log.error(`[auth] password-changed notification failed: ${error instanceof Error?error.message:String(error)}`)}};if(!enqueueAfterCommit(send))send()}return{success:!0,keptTokenId:swept.keptTokenId}}export async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}
8
+ `,[stamp,userId]).execute();return rows.length===1&&(rows[0]?.stamp_matches===!0||rows[0]?.stamp_matches===1)}async function revokePendingPasswordResets(email){if(typeof email!=="string"||!email)return;try{await db.transaction(async()=>{await deletePasswordResetCredentials(email)})}catch(error){const message=error&&typeof error==="object"&&"message"in error&&typeof error.message==="string"?error.message:String(error);if(/^(?:no such table: (?:main\.)?password_resets|relation "password_resets" does not exist|Table '(?:[^'.]+\.)?password_resets' doesn't exist)$/i.test(message))return;throw error}}export async function revokeAccountCredentials(userId,options={}){if(!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] revokeAccountCredentials needs a positive integer user id.");if(options.exceptTokenId!==void 0&&(!Number.isSafeInteger(options.exceptTokenId)||options.exceptTokenId<=0))throw TypeError("[auth] exceptTokenId must be a positive integer.");if(options.tx!==void 0&&!isInTransaction())throw TypeError("[auth] revokeAccountCredentials was given a tx but is not running inside that transaction.");return db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)throw Error("[auth] revokeAccountCredentials: the user does not exist.");let passwordChangedAt=null;const stampValue=sqlDateTime(new Date);if(Object.hasOwn(owner,"password_changed_at")){const stamped=await db.updateTable("users").set({password_changed_at:stampValue}).where("id","=",userId).execute(),changed=mutationCount(stamped);if(changed!==1&&!(changed===0&&getDatabaseDialect()==="mysql"&&await mysqlPasswordStampMatches(userId,stampValue)))throw Error("[auth] revokeAccountCredentials could not stamp password_changed_at.");passwordChangedAt=parseSqlDateTime(stampValue)}else log.warn("[auth] password_changed_at column missing - run `buddy migrate`; credentials are revoked without the credential-version stamp");await revokePendingPasswordResets(owner.email);await revokeTwoFactorChallenges(userId);await revokeMagicLinks(userId);await revokeOAuthSubjectAuthorizationState("users",userId);await revokeTokenPairs(userId,DEFAULT_TOKENABLE_TYPE,options.exceptTokenId);await sessionDestroyAll(userId,options.exceptSessionId!==void 0?{exceptId:options.exceptSessionId}:{});let keptTokenId=null;if(options.exceptTokenId!==void 0&&passwordChangedAt){const rebound=await db.updateTable("oauth_access_tokens").set({created_at:stampValue}).where("id","=",options.exceptTokenId).where("tokenable_id","=",userId).execute();if(mutationCount(rebound)===1){await db.updateTable("oauth_refresh_tokens").set({created_at:stampValue}).where("access_token_id","=",options.exceptTokenId).execute();keptTokenId=options.exceptTokenId}}else if(options.exceptTokenId!==void 0)keptTokenId=options.exceptTokenId;return{passwordChangedAt,keptTokenId}})}export async function changePassword(userId,currentPassword,newPassword,options={}){if(typeof newPassword!=="string"||newPassword.length===0)return{success:!1,reason:"invalid-new-password"};const user=await db.primary.selectFrom("users").where("id","=",userId).selectAll().executeTakeFirst();if(!user)return{success:!1,reason:"user-not-found"};const storedHash=typeof user.password==="string"?user.password:"";if(typeof currentPassword!=="string"||!storedHash||!await verifyHash(currentPassword,storedHash))return{success:!1,reason:"invalid-current-password"};const hashed=await makeHash(newPassword,{algorithm:"bcrypt"}),swept=await withVerifiedPassword(userId,storedHash,async()=>{const updated=await db.updateTable("users").set({password:hashed,...Object.hasOwn(user,"updated_at")?{updated_at:sqlDateTime()}:{}}).where("id","=",userId).execute();if(mutationCount(updated)!==1)throw Error("[auth] changePassword could not update the password.");return revokeAccountCredentials(userId,{...options.keepTokenId!==void 0&&{exceptTokenId:options.keepTokenId},...options.keepSessionId!==void 0&&{exceptSessionId:options.keepSessionId}})});if(!swept)return{success:!1,reason:"invalid-current-password"};const notify=options.notify??!0;if(notify!==!1&&user.email){const recipient={id:Number(user.id),email:String(user.email),...user.name?{name:String(user.name)}:{}},send=async()=>{try{if(typeof notify==="function")await notify(recipient);else await sendPasswordChangedNotification(recipient.email)}catch(error){log.error(`[auth] password-changed notification failed: ${error instanceof Error?error.message:String(error)}`)}};if(!enqueueAfterCommit(send))send()}return{success:!0,keptTokenId:swept.keptTokenId}}export async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}
@@ -0,0 +1,4 @@
1
+ /** Internal: the caller holds the account lock in the active transaction. */
2
+ export declare function pendingPasswordResetCredentials(email: string): Promise<Array<{ email: string, created_at: unknown }>>;
3
+ /** Retire canonical and supported legacy case variants, verifying the write. */
4
+ export declare function deletePasswordResetCredentials(email: string): Promise<void>;
@@ -0,0 +1 @@
1
+ import{db,getDatabaseDialect,sqlHelpers}from"@stacksjs/database/runtime";import{normalizeAuthEmail}from"../credential-user";function ownerAddress(email){return{where:`LOWER(email) = ${sqlHelpers(getDatabaseDialect()).param(1)}`,parameters:[normalizeAuthEmail(email)]}}export async function pendingPasswordResetCredentials(email){const owner=ownerAddress(email);return await db.primary.unsafe(`SELECT email, created_at FROM password_resets WHERE ${owner.where}`,owner.parameters)}export async function deletePasswordResetCredentials(email){const owner=ownerAddress(email);await db.unsafe(`DELETE FROM password_resets WHERE ${owner.where}`,owner.parameters);if((await db.primary.unsafe(`SELECT email FROM password_resets WHERE ${owner.where} LIMIT 1`,owner.parameters)).length)throw Error("Password reset credentials could not be removed")}
@@ -1,6 +1,8 @@
1
1
  /** High-entropy reset credentials support indexed lookup without storing a usable token. */
2
2
  export declare function passwordResetTokenDigest(token: string): string;
3
3
  export declare function mintPasswordResetToken(): { token: string, digest: string };
4
+ /** Issue one indexed recovery credential, serializing eligibility, throttling and replacement. */
5
+ export declare function issuePasswordResetToken(address: string, options?: PasswordResetIssueOptions): Promise<{ issued: false } | { issued: true, token: string, userId: number, email: string }>;
4
6
  /**
5
7
  * Redeem a SHA-256 reset credential and apply account changes in one transaction.
6
8
  * Issuance stores mintPasswordResetToken().digest in password_resets.token.
@@ -13,3 +15,8 @@ export declare interface PasswordResetClaim {
13
15
  email: string
14
16
  hasExplicitExpiry: boolean
15
17
  }
18
+ export declare interface PasswordResetIssueOptions {
19
+ throttleMs?: number
20
+ expiresAt?: Date
21
+ eligible?: (user: Readonly<Record<string, unknown>>) => boolean
22
+ }
@@ -1 +1 @@
1
- import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,lockRow,mutationCount,parseSqlDateTime}from"@stacksjs/database/runtime";export function passwordResetTokenDigest(token){return createHash("sha256").update(token).digest("hex")}export function mintPasswordResetToken(){const token=randomBytes(32).toString("hex");return{token,digest:passwordResetTokenDigest(token)}}export async function consumePasswordResetToken(token,apply,options={}){if(!token||token.length>1024)return{consumed:!1};const digest=passwordResetTokenDigest(token),observed=await db.primary.selectFrom("password_resets").select(["email"]).where("token","=",digest).executeTakeFirst();if(!observed)return{consumed:!1};const email=String(observed.email),owner=await db.primary.selectFrom("users").select("id").where("email","=",email).executeTakeFirst();if(!owner)return{consumed:!1};const ttlMs=options.ttlMs??Number(config.auth.passwordReset?.expire??60)*60000;if(!Number.isFinite(ttlMs)||ttlMs<=0)throw RangeError("Reset lifetime must be positive");return db.transaction(async(tx)=>{const user=await lockRow(tx,"users",{id:Number(owner.id)});if(!user||String(user.email)!==email)return{consumed:!1};const reset=await lockRow(tx,"password_resets",{email});if(!reset||reset.token!==digest)return{consumed:!1};if(!((reset.expires_at==null?(parseSqlDateTime(reset.created_at)?.getTime()??0)+ttlMs:parseSqlDateTime(reset.expires_at)?.getTime()??0)>Date.now())){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",digest).execute();return{consumed:!1}}const value=await apply({userId:Number(owner.id),email,hasExplicitExpiry:reset.expires_at!=null}),deleted=await db.deleteFrom("password_resets").where("email","=",email).where("token","=",digest).execute();if(mutationCount(deleted)!==1)throw Error("Password reset token could not be consumed");return{consumed:!0,value}})}
1
+ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,lockRow,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{findAuthUserRowByEmail,normalizeAuthEmail}from"../credential-user";import{deletePasswordResetCredentials,pendingPasswordResetCredentials}from"./reset-store";export function passwordResetTokenDigest(token){return createHash("sha256").update(token).digest("hex")}export function mintPasswordResetToken(){const token=randomBytes(32).toString("hex");return{token,digest:passwordResetTokenDigest(token)}}export async function issuePasswordResetToken(address,options={}){const email=normalizeAuthEmail(address),throttleMs=options.throttleMs??Number(config.auth.passwordReset?.throttle??60)*1000;if(!Number.isFinite(throttleMs)||throttleMs<0)throw RangeError("Reset throttle must be nonnegative");if(options.expiresAt!==void 0&&(!(options.expiresAt instanceof Date)||!(options.expiresAt.getTime()>Date.now())))throw RangeError("Reset expiry must be a future date");const observed=await findAuthUserRowByEmail(email);if(!observed)return{issued:!1};return db.transaction(async(tx)=>{const user=await lockRow(tx,"users",{id:Number(observed.id)});if(!user||normalizeAuthEmail(String(user.email))!==email||options.eligible&&options.eligible(user)!==!0)return{issued:!1};const now=Date.now(),createdAt=(await pendingPasswordResetCredentials(email)).reduce((latest,credential)=>Math.max(latest,parseSqlDateTime(credential.created_at)?.getTime()??Number.NEGATIVE_INFINITY),Number.NEGATIVE_INFINITY);if(throttleMs>0&&now<createdAt+throttleMs)return{issued:!1};if(options.expiresAt!==void 0&&!(options.expiresAt.getTime()>now))throw RangeError("Reset expiry must be a future date");const token=`${Number(observed.id)}.${mintPasswordResetToken().token}`;await deletePasswordResetCredentials(email);const saved=await db.insertInto("password_resets").values({email,token:passwordResetTokenDigest(token),created_at:sqlDateTime(new Date(now)),expires_at:options.expiresAt?sqlDateTime(options.expiresAt):null}).execute();if(mutationCount(saved)!==1)throw Error("Password reset token could not be issued");return{issued:!0,token,userId:Number(observed.id),email}})}export async function consumePasswordResetToken(token,apply,options={}){if(!token||token.length>1024)return{consumed:!1};const digest=passwordResetTokenDigest(token),observed=await db.primary.selectFrom("password_resets").select(["email"]).where("token","=",digest).executeTakeFirst();if(!observed)return{consumed:!1};const email=String(observed.email),owner=await findAuthUserRowByEmail(email);if(!owner)return{consumed:!1};const boundAccount=/^([1-9]\d*)\.[a-f0-9]{64}$/.exec(token)?.[1];if(boundAccount&&boundAccount!==String(owner.id))return{consumed:!1};const ttlMs=options.ttlMs??Number(config.auth.passwordReset?.expire??60)*60000;if(!Number.isFinite(ttlMs)||ttlMs<=0)throw RangeError("Reset lifetime must be positive");return db.transaction(async(tx)=>{const user=await lockRow(tx,"users",{id:Number(owner.id)});if(!user||normalizeAuthEmail(String(user.email))!==normalizeAuthEmail(email))return{consumed:!1};const reset=await lockRow(tx,"password_resets",{email});if(!reset||reset.token!==digest)return{consumed:!1};if(!((reset.expires_at==null?(parseSqlDateTime(reset.created_at)?.getTime()??0)+ttlMs:parseSqlDateTime(reset.expires_at)?.getTime()??0)>Date.now())){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",digest).execute();return{consumed:!1}}const deleted=await db.deleteFrom("password_resets").where("email","=",email).where("token","=",digest).execute();if(mutationCount(deleted)!==1)throw Error("Password reset token could not be consumed");return{consumed:!0,value:await apply({userId:Number(owner.id),email,hasExplicitExpiry:reset.expires_at!=null})}})}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.447",
5
+ "version": "0.75.450",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -59,19 +59,19 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@stacksjs/bun-router": "^0.1.24",
62
- "@stacksjs/cache": "0.75.447",
63
- "@stacksjs/config": "0.75.447",
64
- "@stacksjs/database": "0.75.447",
65
- "@stacksjs/email": "0.75.447",
66
- "@stacksjs/env": "0.75.447",
67
- "@stacksjs/error-handling": "0.75.447",
68
- "@stacksjs/logging": "0.75.447",
69
- "@stacksjs/orm": "0.75.447",
70
- "@stacksjs/path": "0.75.447",
71
- "@stacksjs/router": "0.75.447",
72
- "@stacksjs/security": "0.75.447",
73
- "@stacksjs/socials": "0.75.447",
74
- "@stacksjs/storage": "0.75.447",
62
+ "@stacksjs/cache": "0.75.450",
63
+ "@stacksjs/config": "0.75.450",
64
+ "@stacksjs/database": "0.75.450",
65
+ "@stacksjs/email": "0.75.450",
66
+ "@stacksjs/env": "0.75.450",
67
+ "@stacksjs/error-handling": "0.75.450",
68
+ "@stacksjs/logging": "0.75.450",
69
+ "@stacksjs/orm": "0.75.450",
70
+ "@stacksjs/path": "0.75.450",
71
+ "@stacksjs/router": "0.75.450",
72
+ "@stacksjs/security": "0.75.450",
73
+ "@stacksjs/socials": "0.75.450",
74
+ "@stacksjs/storage": "0.75.450",
75
75
  "@stacksjs/ts-auth": "^0.4.7",
76
76
  "ts-qr-codes": "^0.1.8"
77
77
  },