Skip to content

matteolobello/auth.ts

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Auth library

A small, self-contained library for authentication and authorization, designed around simplicity, ease of use, and security.

This library is purposely not published to any npm registry. Instead, copy auth.ts into your project, read it, and extend it to your needs. It's a single file with one dependency (jsonwebtoken), everything else is Node.js built-ins.

The library does not force you to use any specific database or ORM: bring your own way to store and retrieve data by passing an adapter to the constructor.

Features

  • Email + password authentication, register, login, logout
  • JWT access tokens, short-lived, used for authenticated requests
  • Refresh tokens, long-lived, stored hashed (SHA-256) in your database as sessions, used to mint new access tokens
  • Password reset flow, single-use, short-lived reset tokens; resetting the password invalidates all previous sessions
  • Session management, sessions live in your database, so you can list a user's devices and remotely close sessions on other devices
  • Custom token payloads, enrich tokens with your own claims (e.g. roles), fully typed
  • Pluggable rate limiting and email validation, via optional adapter hooks
  • Errors as values, every method returns a [result, error] tuple with typed string error codes; no try/catch needed
  • Secure password hashing, scrypt with a per-user random salt and timing-safe comparison
  • Debug logging, opt-in, tagged per operation
  • Tested, see auth.test.ts, runs with the built-in Node test runner

Quick start

import { Auth } from "./auth"

// Optional: add additional payload to the tokens.
type AdditionalTokenPayload = {
  role: "user" | "admin"
}

const auth = new Auth<AdditionalTokenPayload>({
  jwtSecret: process.env.JWT_SECRET!,
  adapter: {
    findUser: async ({ email }) => {
      return await db.user.findUnique({ where: { email } })
    },
    createUser: async ({ email, password }) => {
      return await db.user.create({ data: { email, password } })
    },
    updateUser: async (userId, data) => {
      await db.user.update({ where: { id: userId }, data })
    },
    createSession: async (data) => {
      return await db.authSession.create({ data })
    },
    findSession: async ({ refreshTokenHash }) => {
      return await db.authSession.findUnique({ where: { refreshTokenHash } })
    },
    deleteSession: async ({ userId, refreshTokenHash }) => {
      await db.authSession.delete({ where: { userId, refreshTokenHash } })
    },
    deleteSessions: async ({ userId }) => {
      await db.authSession.deleteMany({ where: { userId } })
    },
    // Required only when an additional token payload type is provided.
    enrichTokenPayload: async ({ userId }) => {
      const user = await db.user.findUniqueOrThrow({ where: { id: userId } })
      return { role: user.role }
    }
  },
  // Optional fields below (defaults shown).
  accessTokenExpiresInMs: 1000 * 60 * 60, // 1 hour
  refreshTokenExpiresInMs: 1000 * 60 * 60 * 24 * 14, // 14 days
  passwordResetTokenExpiresInMs: 1000 * 60 * 10, // 10 minutes
  isDebugLoggingEnabled: false
})

// ... in your endpoint handler
async function handleLogin(request: Request) {
  const { email, password } = await request.json()

  const [result, error] = await auth.login({ email, password })

  if (error) {
    return new Response(JSON.stringify({ error }), { status: 400 })
  }

  return new Response(JSON.stringify(result), { status: 200 })
}

How it works

  1. A user registers (or logs in) with their email and password.
  2. A new session is created, returning two JWTs:
    • an access token (short-lived), sent with authenticated requests and verified with getTokenPayload
    • a refresh token (long-lived), used to mint new access tokens via refreshAccessToken
  3. Only the SHA-256 hash of the refresh token is stored in your database, so a leaked database doesn't leak usable tokens. Deleting the session row revokes the refresh token.
  4. When the user forgets their password, forgotPassword generates a short-lived reset token to send to the user's email. resetPassword consumes it and, by default, closes all of the user's previous sessions.

Error handling

Every method returns a tuple instead of throwing:

const [result, error] = await auth.register({ email, password })

if (error) {
  // `error` is a typed string union, e.g.
  // "INVALID_EMAIL" | "TOO_MANY_REQUESTS" | "USER_ALREADY_EXISTS" | ...
}

Exactly one of the two is defined, and the error codes are inferred per method, so you can exhaustively handle them with a switch.

API

Method Purpose Notable errors
register({ email, password, ip? }) Create a user and a first session; returns { userId, session, accessToken, refreshToken } INVALID_EMAIL, TOO_MANY_REQUESTS, USER_ALREADY_EXISTS
login({ email, password, ip? }) Verify credentials and create a session; same return shape as register INVALID_CREDENTIALS, TOO_MANY_REQUESTS
refreshAccessToken({ refreshToken }) Mint a new access token from a valid, non-revoked refresh token INVALID_TOKEN, SESSION_NOT_FOUND, SESSION_EXPIRED
closeSession({ refreshToken, refreshTokenHash? }) Logout. Pass refreshTokenHash to close a different session owned by the same user (e.g. "logout other devices") INVALID_TOKEN, SESSION_NOT_FOUND
forgotPassword({ email }) Generate a password reset token to send to the user INVALID_EMAIL, USER_NOT_FOUND
resetPassword({ passwordResetToken, newPassword, closePreviousSessions? }) Set a new password; closes all previous sessions unless closePreviousSessions: false INVALID_TOKEN, FAILED_TO_UPDATE_USER
getTokenPayload(token, typeOrTypes) Verify a JWT and check its type ("access", "refresh", or "password-reset"); use this to authenticate requests INVALID_TOKEN, INVALID_TOKEN_TYPE

Note

forgotPassword returns USER_NOT_FOUND, but your API should respond identically whether or not the email exists, otherwise an attacker can probe which emails are registered.

Managing sessions across devices

Each login creates a session row in your database, keyed by the refresh token hash. That makes a "manage my devices" feature straightforward:

  • List devices, query your session table by userId (the optional agent field is there to label each device).

  • Close a session on another device, call closeSession with the current device's refresh token plus the refreshTokenHash of the session to close. The refresh token proves the caller owns the account; the hash selects which session to revoke:

    const [, error] = await auth.closeSession({
      refreshToken: currentDeviceRefreshToken,
      refreshTokenHash: otherDeviceSessionHash // from your session list
    })
  • Close all sessions, call your adapter's deleteSessions({ userId }) directly, or reset the password (which does it by default).

Once a session row is deleted, its refresh token can no longer mint access tokens, so the device is logged out as soon as its current access token expires.

The adapter

The adapter is how the library talks to your storage layer. Required methods:

Method Used for
findUser({ email }) Login, registration duplicate check, password reset
createUser({ email, password }) Registration (password arrives already hashed)
updateUser(userId, data) Password reset
createSession(session) Login and registration
findSession({ refreshTokenHash }) Access token refresh
deleteSession({ userId, refreshTokenHash }) Logout
deleteSessions({ userId }) Invalidating all sessions after a password reset

Optional hooks:

  • validateEmail(email), custom email validation, runs in addition to the built-in regex check
  • isLoginRateLimited({ ip?, email }) / isRegisterRateLimited({ ip?, email }), return true to reject the attempt with TOO_MANY_REQUESTS
  • enrichTokenPayload({ userId }), required when you pass an additional token payload type to Auth<...>; its return value is merged into every token (the reserved type and userId claims cannot be overridden)

Example schema (Prisma)

The library has no Prisma dependency, this is just the shape your storage needs:

model User {
  id           String        @id @default(cuid())
  email        String        @unique
  password     String
  verified     Boolean       @default(false)
  authSessions AuthSession[]
}

model AuthSession {
  refreshTokenHash String   @id
  userId           String
  user             User     @relation(fields: [userId], references: [id])
  agent            String?
  expiresAt        DateTime
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt
}

Security notes

  • Passwords are hashed with scrypt and a random 16-byte salt, stored as scrypt$<salt>$<hash>; comparisons use crypto.timingSafeEqual.
  • Refresh tokens are never stored in plaintext, only their SHA-256 hash.
  • Login failures return the same INVALID_CREDENTIALS error whether the user doesn't exist or the password is wrong.
  • Resetting a password closes all existing sessions by default.
  • Token expirations are enforced both by JWT verification and, for sessions, by the expiresAt stored in your database.

Running the tests

npm test

Tests live in auth.test.ts and use the built-in Node.js test runner with an in-memory adapter, a useful reference for writing your own adapter.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors