11import { createChecker , type supportedConnectors } from "drizzle-schema-checker" ;
2- import { getOAuthAccountsTableSchema , getSessionsTableSchema , getUsersTableSchema , getEmailVerificationCodesTableSchema } from "../database/lib/sqlite/schema.sqlite" ;
2+ import { getOAuthAccountsTableSchema , getSessionsTableSchema , getUsersTableSchema , getEmailVerificationCodesTableSchema , getPasswordResetTokensTableSchema } from "../database/lib/sqlite/schema.sqlite" ;
33import { drizzle as drizzleIntegration } from "db0/integrations/drizzle/index" ;
44import type { ICreateOrLoginParams , ICreateUserParams , ILoginUserParams , IPasswordHashingMethods , ISlipAuthCoreOptions , SchemasMockValue , SlipAuthUser , tableNames } from "./types" ;
55import { createSlipHooks } from "./hooks" ;
66import { UsersRepository } from "./repositories/UsersRepository" ;
77import { SessionsRepository } from "./repositories/SessionsRepository" ;
88import { OAuthAccountsRepository } from "./repositories/OAuthAccountsRepository" ;
99import { EmailVerificationCodesRepository } from "./repositories/EmailVerificationCodesRepository" ;
10+ import { ResetPasswordTokensRepository } from "./repositories/ResetPasswordTokensRepository" ;
1011import type { SlipAuthPublicSession } from "../types" ;
11- import { defaultIdGenerationMethod , isValidEmail , defaultEmailVerificationCodeGenerationMethod , defaultHashPasswordMethod , defaultVerifyPasswordMethod } from "./email-and-password-utils" ;
12- import { InvalidEmailOrPasswordError , UnhandledError } from "./errors/SlipAuthError.js" ;
12+ import { defaultIdGenerationMethod , isValidEmail , defaultEmailVerificationCodeGenerationMethod , defaultHashPasswordMethod , defaultVerifyPasswordMethod , defaultResetPasswordTokenIdMethod , defaultResetPasswordTokenHashMethod } from "./email-and-password-utils" ;
13+ import { InvalidEmailOrPasswordError , InvalidEmailToResetPasswordError , InvalidPasswordToResetError , InvalidUserIdToResetPasswordError , ResetPasswordTokenExpiredError , UnhandledError } from "./errors/SlipAuthError.js" ;
1314import type { Database } from "db0" ;
14- import { isWithinExpirationDate } from "oslo" ;
15+ import { createDate , isWithinExpirationDate , TimeSpan } from "oslo" ;
1516
1617export class SlipAuthCore {
1718 readonly #db: Database ;
@@ -23,11 +24,13 @@ export class SlipAuthCore {
2324 sessions : SessionsRepository
2425 oAuthAccounts : OAuthAccountsRepository
2526 emailVerificationCodes : EmailVerificationCodesRepository
27+ resetPasswordTokens : ResetPasswordTokensRepository
2628 } ;
2729
2830 #createRandomUserId: ( ) => string ;
2931 #createRandomSessionId: ( ) => string ;
3032 #createRandomEmailVerificationCode: ( ) => string ;
33+ #createResetPasswordTokenHashMethod: ( tokenId : string ) => Promise < string > ;
3134
3235 #passwordHashingMethods: IPasswordHashingMethods ;
3336
@@ -50,19 +53,22 @@ export class SlipAuthCore {
5053 sessions : getSessionsTableSchema ( tableNames ) ,
5154 oauthAccounts : getOAuthAccountsTableSchema ( tableNames ) ,
5255 emailVerificationCodes : getEmailVerificationCodesTableSchema ( tableNames ) ,
56+ resetPasswordTokens : getPasswordResetTokensTableSchema ( tableNames ) ,
5357 } ;
5458
5559 this . #repos = {
5660 users : new UsersRepository ( this . #orm, this . schemas , this . hooks , "users" ) ,
5761 sessions : new SessionsRepository ( this . #orm, this . schemas , this . hooks , "sessions" ) ,
5862 oAuthAccounts : new OAuthAccountsRepository ( this . #orm, this . schemas , this . hooks , "oauthAccounts" ) ,
5963 emailVerificationCodes : new EmailVerificationCodesRepository ( this . #orm, this . schemas , this . hooks , "emailVerificationCodes" ) ,
64+ resetPasswordTokens : new ResetPasswordTokensRepository ( this . #orm, this . schemas , this . hooks , "resetPasswordTokens" ) ,
6065 } ;
6166
6267 this . #createRandomSessionId = defaultIdGenerationMethod ;
6368 this . #createRandomUserId = defaultIdGenerationMethod ;
6469
6570 this . #createRandomEmailVerificationCode = defaultEmailVerificationCodeGenerationMethod ;
71+ this . #createResetPasswordTokenHashMethod = defaultResetPasswordTokenHashMethod ;
6672
6773 this . #passwordHashingMethods = {
6874 hash : defaultHashPasswordMethod ,
@@ -87,7 +93,7 @@ export class SlipAuthCore {
8793 throw new InvalidEmailOrPasswordError ( "invalid email" ) ;
8894 }
8995 const password = values . password ;
90- if ( ! password || typeof password !== "string" || password . length < 6 ) {
96+ if ( ! password || typeof password !== "string" ) {
9197 throw new InvalidEmailOrPasswordError ( "invalid password" ) ;
9298 }
9399
@@ -132,7 +138,7 @@ export class SlipAuthCore {
132138 throw new InvalidEmailOrPasswordError ( "invalid email" ) ;
133139 }
134140 const password = values . password ;
135- if ( ! password || typeof password !== "string" || password . length < 6 ) {
141+ if ( ! password || typeof password !== "string" ) {
136142 throw new InvalidEmailOrPasswordError ( "invalid password" ) ;
137143 }
138144
@@ -154,7 +160,7 @@ export class SlipAuthCore {
154160 }
155161 catch ( error ) {
156162 if ( error instanceof Error ) {
157- if ( error . stack ?. startsWith ( `SqliteError: UNIQUE constraint failed: ${ this . #tableNames. users } .email`) ) {
163+ if ( error . stack ?. includes ( ` UNIQUE constraint failed: ${ this . #tableNames. users } .email`) ) {
158164 throw new InvalidEmailOrPasswordError ( `email already taken: ${ values . email } ` ) ;
159165 }
160166 throw new UnhandledError ( ) ;
@@ -248,6 +254,78 @@ export class SlipAuthCore {
248254 return true ;
249255 }
250256
257+ /**
258+ * The token should be valid for at most few hours. The token should be hashed before storage as it essentially is a password.
259+ * SHA-256 can be used here since the token is long and random, unlike user passwords.
260+ */
261+ public async askPasswordReset ( userId : string ) {
262+ // optionally invalidate all existing tokens
263+ // this.#repos.resetPasswordTokens.deleteAllByUserId(userId);
264+ const tokenId = defaultResetPasswordTokenIdMethod ( ) ;
265+ const tokenHash = await this . #createResetPasswordTokenHashMethod( tokenId ) ;
266+ try {
267+ await this . #repos. resetPasswordTokens . insert ( {
268+ token_hash : tokenHash ,
269+ user_id : userId ,
270+ expires_at : createDate ( new TimeSpan ( 2 , "h" ) ) ,
271+ } ) ;
272+
273+ return tokenId ;
274+ }
275+ catch ( error ) {
276+ if ( error instanceof Error && error . message . includes ( "FOREIGN KEY constraint failed" ) ) {
277+ throw new InvalidUserIdToResetPasswordError ( ) ;
278+ }
279+
280+ throw new UnhandledError ( ) ;
281+ }
282+ }
283+
284+ // TODO: rate limit
285+ public async askForgotPasswordReset ( emailAddress : string ) : Promise < string > {
286+ const user = await this . #repos. users . findByEmail ( emailAddress ) ;
287+ if ( ! user ) {
288+ // If you want to avoid disclosing valid emails,
289+ // this can be a normal 200 response.
290+ throw new InvalidEmailToResetPasswordError ( ) ;
291+ }
292+ return this . askPasswordReset ( user . id ) ;
293+ }
294+
295+ /**
296+ * Make sure to set the Referrer-Policy header of the password reset page to strict-origin to protect the token from referrer leakage.
297+ * WARNING: WILL UN-LOG THE USER
298+ */
299+ public async resetPasswordWithResetToken ( verificationToken : string , newPassword : string ) : Promise < true > {
300+ if ( typeof newPassword !== "string" ) {
301+ throw new InvalidPasswordToResetError ( ) ;
302+ }
303+
304+ const tokenHash = await this . #createResetPasswordTokenHashMethod( verificationToken ) ;
305+ const token = await this . #repos. resetPasswordTokens . findByTokenHash ( tokenHash ) ;
306+
307+ if ( ! token ) {
308+ throw new ResetPasswordTokenExpiredError ( ) ;
309+ }
310+
311+ if ( token ) {
312+ await this . #repos. resetPasswordTokens . deleteByTokenHash ( tokenHash ) ;
313+ }
314+
315+ const expirationDate = token . expires_at instanceof Date ? token . expires_at : new Date ( token . expires_at ) ;
316+ const offset = expirationDate . getTimezoneOffset ( ) * 60000 ; // Get local time zone offset in milliseconds
317+ const localExpirationDate = new Date ( expirationDate . getTime ( ) - offset ) ; // Adjust for local time zone
318+ if ( ! isWithinExpirationDate ( localExpirationDate ) ) {
319+ throw new ResetPasswordTokenExpiredError ( ) ;
320+ }
321+
322+ await this . #repos. sessions . deleteAllByUserId ( token . user_id ) ;
323+ const passwordHash = await this . #passwordHashingMethods. hash ( newPassword ) ;
324+ await this . #repos. users . updatePasswordByUserId ( token . user_id , passwordHash ) ;
325+
326+ return true ;
327+ }
328+
251329 public setCreateRandomUserId ( fn : ( ) => string ) {
252330 this . #createRandomUserId = fn ;
253331 }
@@ -260,6 +338,10 @@ export class SlipAuthCore {
260338 this . #createRandomEmailVerificationCode = fn ;
261339 }
262340
341+ public setCreateResetPasswordTokenHashMethod ( fn : ( tokenId : string ) => Promise < string > ) {
342+ this . #createResetPasswordTokenHashMethod = fn ;
343+ }
344+
263345 public setPasswordHashingMethods ( fn : ( ) => IPasswordHashingMethods ) {
264346 const methods = fn ( ) ;
265347 this . #passwordHashingMethods = methods ;
0 commit comments