🪝 Login Hooks
Some logic belongs to the moment a user signs in rather than to a route: recording the last login time and IP, keeping an audit trail, or turning away a suspended account. Put it in hook/auth.js and ODAC runs it on every sign-in path: password logins (including the zero-config login form), magic links, auto-login after register(), and Odac.Auth.login() calls in your own controllers.
project/
└── hook/
└── auth.js
// hook/auth.js
module.exports = {
// Before a session is issued. Return false to refuse the sign-in.
async beforeLogin(Odac, {user, method}) {
if (user.suspended) return false
},
// After the session is issued (token stored, cookies set).
async login(Odac, {user, method}) {
await Odac.DB.users.where('id', user.id).update({
last_login_at: new Date(),
last_login_ip: Odac.Request.ip
})
},
// A sign-in attempt that did not succeed.
async loginFailed(Odac, {identity, method, reason}) {
await Odac.DB.login_audit.insert({email: identity.email, ip: Odac.Request.ip, reason, date: new Date()})
}
}
Every handler gets the request's Odac instance first, the same one controllers and middleware get, so Odac.Request, Odac.DB and your service classes are all available. Define only the hooks you need. The file is reloaded on save in development, like middleware.
Events
| Hook | When it runs | Payload |
|---|---|---|
beforeLogin |
Credentials are verified, nothing has been issued yet | {user, method} |
login |
The session token is stored and the cookies are set | {user, method} |
loginFailed |
The attempt did not produce a session | {identity, method, reason} |
method: how the user is signing in.
| Value | Source |
|---|---|
password |
Odac.Auth.login() with a password field, including the built-in login form |
magic |
A verified magic link, both for existing users and for sign-ups through the link |
register |
The auto-login of Odac.Auth.register() |
direct |
Odac.Auth.login() with identity fields only (e.g. {id}) |
reason (loginFailed only):
| Value | Meaning |
|---|---|
invalid |
Wrong credentials, an unknown account, or an invalid or already used magic link |
expired |
A magic link whose token is correct but past its expiry |
throttled |
The attempt was blocked by login throttling before the credentials were checked |
denied |
beforeLogin refused the sign-in |
Rules
beforeLoginrefuses only with an explicitfalse. Any other return value, including none, lets the user in.beforeLoginfails closed. If it throws, the sign-in is refused and the error is logged. A check that is meant to keep an account out must not let it in because the check itself broke.loginandloginFailednever change the outcome. An error in them is logged, and the sign-in result stays as it was. The session is already issued whenloginruns.usernever contains password hashes. Every configuredauth.passwordFieldis removed from the copy the hooks get.identityholds only the identity fields of the attempt (e.g.email), never the password.- A refusal is not a failed password. It does not count toward login throttling, because the credentials were correct.
- Resuming a session is not a login. Requests authenticated by the auth cookie and token rotation run no hooks. The last activity of a session is already kept in the
activecolumn of the token table (Odac.Auth.token('active')). - Handlers are awaited. Keep them short, or start slow work (an email, a webhook) without awaiting it.
What the user sees when beforeLogin refuses
Odac.Auth.login() returns false, as it does for wrong credentials, so the built-in login form shows "Invalid credentials". A refused magic link returns the person to the form with the denied error (see Magic Links → When a link fails). A refused register() still creates the account and returns {success: true, autoLogin: false}. If you want to show a specific message (for example "Your account is suspended"), check for that state in your own login controller before calling Odac.Auth.login().
Only auth.js
Hook files are a fixed set, not a general event bus. A file other than auth.js in hook/, or an export other than beforeLogin, login and loginFailed, is reported in the log when it loads, so a misspelled name does not silently never run.