davepi-plugin-magic-link
v0.2.0
Published
Passwordless email magic-link login for dAvePi. Mounts /auth/magic-link/{request,verify,invite} routes, stores only SHA-256 token hashes in a TTL-indexed collection, sends links via the framework mailer, and issues the framework's standard JWT on verify.
Maintainers
Readme
davepi-plugin-magic-link
Passwordless email magic-link login for dAvePi.
The framework ships JWT + bcrypt password users (/register, /login) but no
magic links. This plugin adds them, reusing davepi's own primitives: it mints a
real davepi session via utils/tokens.issueTokenPair — so the rest of the API
accepts the token unchanged — and emails the link via utils/mailer (which
logs instead of sending outside production, so the dev link is always visible
in your console).
Install
npm install davepi-plugin-magic-link{
"davepi": {
"plugins": ["davepi-plugin-magic-link"]
}
}The plugin stays dormant until MAGIC_LINK_URL is set, so it's safe to
declare before the frontend is wired.
Configuration
| Env var | Default | Meaning |
|---------|---------|---------|
| MAGIC_LINK_URL | — (required) | Frontend URL the emailed link points at, e.g. https://app.example.com/auth/verify. The token is appended as ?token=... / &token=... (or concatenated when the URL ends in =). |
| MAGIC_LINK_PATH | /auth/magic-link | Base path the routes mount under. |
| MAGIC_LINK_TTL_MINUTES | 30 | Link lifetime, clamped to 1–1440. |
| MAGIC_LINK_ALLOW_SIGNUP | true | When false, unknown emails still get a 204 (no enumeration) but no account is created and no mail is sent. |
| MAGIC_LINK_DEFAULT_ROLES | user | Comma/space-separated roles stamped onto users the plugin creates. |
| APP_NAME | loader-provided | Used in email subjects/bodies. |
Email delivery uses the framework mailer, so the usual SMTP_* env vars
apply in production.
Routes
POST /auth/magic-link/request — { email, name? }
Always responds 204. The response never reveals whether the email already
has an account. New emails get a user with an unguessable random password
(sign-in is by link only) unless MAGIC_LINK_ALLOW_SIGNUP=false. Rate-limited
with the framework's authLimiter.
POST /auth/magic-link/verify — { token }
Atomically claims the single-use token (concurrent verifies can't both win),
checks expiry at read time, and responds with the framework's standard
{ accessToken, refreshToken } pair plus:
{
"user": { "_id": "...", "email": "...", "roles": ["user"] },
"purpose": "login",
"meta": null
}POST /auth/magic-link/invite — { email, name?, note?, meta? } (authenticated)
A generic invite flow: arbitrary meta rides on the token and is returned at
verify, so an app can carry its own context (a household id, a team id, a
seat) through the link without the plugin knowing about it.
Because meta is caller-supplied, it is refused with 403 unless the host
app registers an authoriser — the safe default against confused-deputy
injection (a caller smuggling ids they don't own into another user's session):
const magicLink = require('davepi-plugin-magic-link');
magicLink.registerInviteAuthoriser(async (req, { email, meta }) => {
// Throw to refuse. Verify everything in `meta` belongs to the caller.
const household = await mongoose
.model('household')
.findOne({ _id: meta.householdId, userId: req.user.user_id })
.lean();
if (!household) throw new ForbiddenError('household not found for this account');
// Optional: bind the link to a specific account. Returning the
// inviter's own userId implements a shared-account model — the
// invitee logs into the SAME user as the inviter, and your app
// reads `meta` from the verify response to know who they are.
return { userId: req.user.user_id };
});Without a return value (or returning nothing), the invitee gets their own find-or-create account.
Writing the invite email
The default invite email only knows the inviter's name or address and
APP_NAME. An app that knows more (who the inviter is, what the invitee is
joining, what happens after the link expires) can write the email itself, with
registerInviteEmail(fn) or the inviteEmail option to createPlugin. It
runs after the authoriser, so it sees the meta the authoriser filled in:
magicLink.registerInviteEmail(async ({ email, name, note, url, ttlMinutes, appName, inviter, meta, req }) => ({
subject: `${meta.invitedByName} invited you to ${meta.workspaceName}`,
text: `Join ${meta.workspaceName} on ${appName}:\n\n${url}\n\nThis link works once, for ${ttlMinutes} minutes.`,
}));Return any of subject, text and html; anything left out keeps the
default. An empty subject keeps the default, and an empty text is used only
beside a non-empty html (an HTML-only email), so an empty answer can't
drop the link. A hook that writes its own text or html must include url
itself: the plugin doesn't check. The recipient is always the invited address.
Programmatic use
issueMagicLink({ email, userId, purpose, meta }) mints a token row and
returns the raw token, for custom flows (e.g. sending the link through a
different channel):
const raw = await magicLink.issueMagicLink({
email: '[email protected]',
userId: user._id,
purpose: 'invite',
meta: { campaign: 'beta' },
});Security posture
- Only the SHA-256 hash of the emailed token is stored — never the token
itself — in a TTL-indexed
magic_link_tokencollection (the TTL index is a janitor; the verify query enforces expiry at read time). - Tokens are single-use: the claim is an atomic
findOneAndUpdate, so a replay or a concurrent verify loses. - The request route is enumeration-safe:
204for known and unknown emails alike, including when signup is disabled. - New accounts get a random bcrypt-hashed password the user never learns.
Tests
npm testThe suite runs standalone (node --test) with injected stubs — no davepi,
mongoose, or bcryptjs install required.
