npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@saasmakers/adonis-auth

v0.12.1

Published

Shared AdonisJS authentication building blocks for SaaS Makers projects

Readme

@saasmakers/adonis-auth

Shared AdonisJS authentication building blocks for the SaaS Makers APIs (SaaS Makers, Resilience Club, Companies).

The package only holds code that is identical across apps. App-specific parts stay in each app: config (config/auth.ts), routes, validators, Edge templates, translations and the User model's own relations.

Installation

pnpm add @saasmakers/adonis-auth

In the SaaS Makers monorepo, use "@saasmakers/adonis-auth": "workspace:*". In other monorepos, pin the version in the pnpm catalog and reference it with "catalog:".

@adonisjs/core is a required peer dependency. @adonisjs/auth, @adonisjs/i18n, @adonisjs/limiter, @adonisjs/lucid, @adonisjs/mail and luxon are optional and only needed by the subpaths that use them.

Entry points

| Import | Content | | --- | --- | | @saasmakers/adonis-auth | HttpError (re-exported from @saasmakers/adonis-core), token verifiers, fakeAppleIdTokens() and all types | | @saasmakers/adonis-auth/limiters | loginThrottle | | @saasmakers/adonis-auth/mails | EmailVerificationMail, PasswordResetMail | | @saasmakers/adonis-auth/middlewares/{admin,resolve_auth,user,visitor} | Auth middlewares, lazy-loaded by the router | | @saasmakers/adonis-auth/mixins | withLastActiveAt, withOpaqueTokens | | @saasmakers/adonis-auth/models | BaseAuthToken, BasePasswordResetToken | | @saasmakers/adonis-auth/services | defineAuthService |

Limiters, mails, middlewares, mixins, models and services live on subpaths because they load Adonis services, which must not run when an app only imports HttpError.

HttpError

HttpError lives in @saasmakers/adonis-core, which also holds the exception handler that renders it. This package throws that same class and re-exports it with HttpErrorOptions and HttpErrorStatus, so existing imports keep working. New code imports it from @saasmakers/adonis-core (see its README).

Middlewares

// start/kernel.ts
router.use([
  () => import('@adonisjs/auth/initialize_auth_middleware'),
  () => import('@saasmakers/adonis-auth/middlewares/resolve_auth'),
])

export const middleware = router.named({
  admin: () => import('@saasmakers/adonis-auth/middlewares/admin'),
  user: () => import('@saasmakers/adonis-auth/middlewares/user'),
  visitor: () => import('@saasmakers/adonis-auth/middlewares/visitor'),
})

They rely on ctx.currentUser and ctx.opaqueToken, which the app declares on HttpContext, and on a User model satisfying AuthUser: isAdmin, loadDependencies() and updateLastActiveAt().

User model

Two mixins, configured like withAuthFinder and composed after it:

| Mixin | Adds | Options (defaults) | | --- | --- | --- | | withOpaqueTokens | generateOpaqueToken() | ipColumnName (ip), tokensProvider (accessTokens) | | withLastActiveAt | updateLastActiveAt(), saved at most once per minute | lastActiveAtColumnName (lastActiveAt) |

The columns stay declared on the model, like password with withAuthFinder, and must be nullable:

import { withLastActiveAt, withOpaqueTokens } from '@saasmakers/adonis-auth/mixins'

const AuthFinder = withAuthFinder(() => hash.use('bcrypt'), {
  passwordColumnName: 'password',
  uids: ['email'],
})

const OpaqueTokens = withOpaqueTokens({
  ipColumnName: 'ip',
  tokensProvider: 'accessTokens',
})

const LastActiveAt = withLastActiveAt({ lastActiveAtColumnName: 'lastActiveAt' })

export default class User extends compose(TheBaseModel, AuthFinder, OpaqueTokens, LastActiveAt) {
  public static readonly accessTokens = DbAccessTokensProvider.forModel(User, { /* ... */ })

  @column()
  public ip: null | string

  @column.dateTime()
  public lastActiveAt: DateTime | null

  // Loads the relations the app needs on every authenticated request
  public async loadDependencies() {}
}

Token models

BaseAuthToken (AuthTokens, read by DbAccessTokensProvider) and BasePasswordResetToken (PasswordResetTokens) declare the token columns on top of TheBaseModel from @saasmakers/adonis-core. The app extends them and declares the relation to its own User model, which the package cannot import without a circular type:

// app/models/AuthToken.ts
export default class AuthToken extends BaseAuthToken {
  @belongsTo(() => User, { foreignKey: 'tokenableId' })
  public tokenable: BelongsTo<typeof User>
}

// app/models/PasswordResetToken.ts
export default class PasswordResetToken extends BasePasswordResetToken {
  @belongsTo(() => User)
  public user: BelongsTo<typeof User>
}

Subclasses keep the package's table names. createdAt, updatedAt and userId are nullable, as in the APIs' migrations.

Auth service

defineAuthService holds the auth flows shared by the APIs. Controllers keep validation, Turnstile, Apple and Google checks, and build their own typed responses:

// app/services/AuthService.ts
export const authService = defineAuthService<typeof User, typeof PasswordResetToken, {
  platform?: UserPlatform
}>({
  hooks: {
    afterEmailVerified: user => User.broadcast(/* ... */),
    afterUserCreate: async (user, { platform }) => { /* Slack, analytics... */ },
    beforeUserCreate: (attributes, { platform }) => ({
      ...attributes,
      platformIos: platform === 'ios'
    }),
  },
  PasswordResetToken,
  User,
})

| Function | Returns | | --- | --- | | createUser(params) | The created user. Rejects disposable emails with UserDisposableEmail (400) and sends the verification email when needed | | forgotPassword(email) | A LoginResult whose user is null for unknown emails | | loginOrRegister(params) | A LoginResult without opaque token: finds the user by email or creates them, closed accounts are returned as is | | loginWithEmail(params) | loginOrRegister, then an opaque token once the password is verified (AuthLoginBadCredentials otherwise) | | loginWithProvider(params) | loginOrRegister, then an opaque token, for identities already verified by Apple or Google | | resendVerificationEmail(user) | { message }, the email is resent at most once an hour | | resetPassword({ password, token }) | A LoginResult with a fresh opaque token | | updatePassword(user, { currentPassword, newPassword }) | The user. Checks the current password only when the user already has one (UserCurrentPasswordInvalid otherwise) | | verifyEmailToken(token) | A LoginResult with a fresh opaque token |

LoginResult has the same shape as each app's LoginResponse (see createLoginResponseSchema in @saasmakers/shared), with the User model the controller serializes. The third generic types app-specific params of createUser: they reach the hooks but never User.create. What the flows need from the User model is described by AuthServiceUser.

Disposable emails are detected with the community-maintained mailchecker list, on the domain and its parents.

Token verifiers

Each verifier throws an HttpError with the code the APIs already use:

| Function | Error | | --- | --- | | verifyAppleIdToken({ audiences, idToken }) | AuthAppleInvalidToken (400) | | verifyGoogleIdToken({ audiences, idToken }) | AuthGoogleInvalidToken (400) | | verifyTurnstileToken({ ip, secret, token }) | AuthLoginCaptchaFailed (400) |

Apple tokens are verified against Apple's public keys (signature, issuer, audience and expiry). Without a configured audience, both ID token verifiers reject every token.

await verifyAppleIdToken({
  audiences: (Env.get('APPLE_AUTH_CLIENT_IDS') ?? '').split(',').map(clientId => clientId.trim()),
  idToken,
})

APPLE_AUTH_CLIENT_IDS lists the Services ID used on the web and the iOS bundle ID, for example club.resilience.web,club.resilience.

Mails and login throttle

The mails only request their view (emails/auth/EmailVerification, emails/auth/ResetPassword), so each app keeps its own Edge templates and translations. They need a user with email, locale and, for the verification mail, emailVerifiedToken.

loginThrottle allows 10 login attempts per 5 minutes for each IP and email pair, then blocks for 5 minutes.

Testing

fakeAppleIdTokens() swaps Apple's public keys for a local key, so tests can sign their own tokens:

const apple = await fakeAppleIdTokens()

cleanup(() => apple.restore())

const idToken = await apple.sign({
  aud: 'club.example.web',
  email: '[email protected]',
})

Package tests run with pnpm test (Vitest). Behaviour that depends on an app (templates, translations, routes, database) is tested in each app.

Releasing

Versions are managed with changesets: add a changeset, run changeset version, then publish this package only with pnpm publish from its folder.