@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-authIn 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.
