@theokit/auth-magic-link
v0.7.1
Published
Email magic-link provider for @theokit/sdk/server/auth. Pluggable MagicLinkStore + email-callback for any transport (Resend/SendGrid/SMTP).
Downloads
322
Maintainers
Readme
@theokit/auth-magic-link
Email magic-link (passwordless) provider for @theokit/sdk auth orchestrator (Auth.create).
Pluggable token storage (MagicLinkStore — in-memory for dev, ORM-backed for production) + consumer-supplied email transport callback (any provider).
Install
pnpm add @theokit/auth-magic-link @theokit/sdk theokitPeer dependencies: @theokit/sdk >= 1.5.0, theokit >= 0.2.4. Zero runtime dependencies.
Quick start (dev)
// server/auth/index.ts
import { Auth } from '@theokit/sdk/server/auth'
import { magicLink, createMemoryStore } from '@theokit/auth-magic-link'
import { sessionManager } from './session.js'
// `Auth.create`, not `Auth.create`. The function existed in `@theokit/sdk` 2.x and is gone
// from 4.x, which is what npm serves; the options are unchanged, only the entry point moved.
export const auth = Auth.create({
session: sessionManager,
providers: [
magicLink({
store: createMemoryStore(), // dev only — see "Production" below
callbackBaseUrl: 'https://myapp.com',
sendEmail: async ({ to, magicLinkUrl, expiresAt }) => {
console.log(`Magic link for ${to}: ${magicLinkUrl} (expires ${expiresAt.toISOString()})`)
},
}),
],
onSignIn: async ({ profile }) => {
// `onSignIn` is typed `<TProfile>(args: { profile: TProfile; … })` — TProfile is unbound, so
// the callback cannot annotate it. The magic-link profile is `{ email }`.
const { email } = profile as { email: string }
return { userId: email, email }
},
})Required in production: THEOKIT_OAUTH_TX_SECRET
Set this, or the OAuth transaction cookie is encrypted with a constant published inside
@theokit/sdk.
That cookie carries state and pkceVerifier — the two values that make an authorization-code flow
safe against CSRF and against an intercepted code. Measured 2026-08-24 in @theokit/[email protected], its
encryption key is resolved as:
opts.session.secret— unreachable:DefineAuthOptions.sessionis typedSessionManager<TSession>, which declares four methods and nosecret.process.env.THEOKIT_OAUTH_TX_SECRET- a literal that ships in the package.
So without the environment variable, step 3 is what you get. The length guard does not help: the constant is 48 characters, and the check is on length rather than provenance.
# 32 random bytes, base64url. Rotate it like any other signing key.
export THEOKIT_OAUTH_TX_SECRET="$(node -e 'console.log(require("node:crypto").randomBytes(32).toString("base64url"))')"This package cannot fix it: it implements a type contract and never constructs the orchestrator, so
there is no seam here to guard. The defect is tracked against @theokit/sdk and pinned by
integration/tests/seam/sdk-tx-cookie-defects.offline.test.ts, which goes red when it is fixed.
Related, and worth knowing: in that same version the transaction cookie is written as
theo_oauth_tx while its store reads __Host-theo_oauth_tx. The missing prefix drops the
__Host- guarantee — a sibling subdomain can set the cookie — and it is also why the callback
currently cannot complete. Fixing the name makes the secret defect reachable, so the two want fixing
in that order.
Wiring
Magic-link does NOT use the OAuth authorization flow — call provider.startSignIn(req, body?) directly:
Both methods accept a Web Request as well as Node's IncomingMessage, so they drop
straight into a TheoKit route:
// server/routes/auth/magic-link/start.ts
import { route } from 'theokit/server'
import { magicLinkProvider } from '../../../auth/providers.js' // your magicLink() instance
export const POST = route()
// Public because the visitor arrives WITHOUT a session — starting or finishing a
// sign-in is what gives them one. Writing it is a decision, not a formality: it is
// what tells a reader this route is open on purpose rather than by omission.
.policy('public')
.handler(async ({ request, body }) => {
// Pass `body` through. TheoKit parses the request body and hands the handler a `Request`
// built WITHOUT one, so `startSignIn(request)` alone finds nothing to read and throws
// `invalid_email` while the address sits in `body` (#101). `?email=` still wins when present.
const redirect = await magicLinkProvider.startSignIn(request, body)
// `startSignIn` resolves to a URL object, not a string.
return new Response(null, { status: 303, headers: { location: redirect.href } })
})
.build()// server/routes/auth/magic-link/callback.ts
import { route } from 'theokit/server'
import { magicLinkProvider, sessions } from '../../../auth/providers.js'
const IGNORED_TX = { state: '', createdAt: 0, expiresAt: 0 }
export const GET = route()
// Public because the visitor arrives WITHOUT a session — starting or finishing a
// sign-in is what gives them one. Writing it is a decision, not a formality: it is
// what tells a reader this route is open on purpose rather than by omission.
.policy('public')
.handler(async ({ request }) => {
// The transaction argument exists to satisfy the AuthProvider interface and is
// ignored: a magic link is cross-device by design, so there is no initiating-browser
// state to bind to. Security rests on token entropy, the TTL, and single-use
// consumption — a second click on the same link resolves to nothing.
const { profile } = await magicLinkProvider.handleCallback(request, IGNORED_TX)
const headers = new Headers()
await sessions.createSession(headers, { userId: profile.email, email: profile.email })
headers.set('location', '/')
return new Response(null, { status: 302, headers })
})
.build()sessions is a createSessionManagerWeb(...) from theokit/server/auth: it writes the
session cookie into a Headers you own, which keeps the whole flow on the Web shapes
TheoKit hands you. The Auth.create orchestrator is the other way in, and it is Node-shaped
(IncomingMessage / ServerResponse), so it needs a Node server rather than a route.
The default resolveEmail reads ?email= from the URL OR the email field from a JSON / form-encoded body. Override via opts.resolveEmail for custom shapes.
Production stores
Drizzle adapter
createOrmStore takes a small repository you write over your own database. @theokit/orm's Repository has no atomic conditional update, so the consume step goes through your Drizzle db. This adapter is compiled and run against SQLite by tests/fixtures/drizzle-adapter.ts.
import { and, eq, isNull, lte } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createOrmStore } from '@theokit/auth-magic-link'
const magicLinkTokens = sqliteTable('magic_link_tokens', {
token: text('token').primaryKey(),
email: text('email').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
consumedAt: integer('consumed_at', { mode: 'timestamp_ms' }),
})
// `db` is your Drizzle database.
const store = createOrmStore({
async insert(row) {
await db.insert(magicLinkTokens).values(row)
},
async consumeAtomically(token, now) {
// One UPDATE ... WHERE consumed_at IS NULL RETURNING guarantees single use
const rows = await db
.update(magicLinkTokens)
.set({ consumedAt: now })
.where(and(eq(magicLinkTokens.token, token), isNull(magicLinkTokens.consumedAt)))
.returning({ email: magicLinkTokens.email, expiresAt: magicLinkTokens.expiresAt })
return rows[0] ?? null
},
async delete(token) {
await db.delete(magicLinkTokens).where(eq(magicLinkTokens.token, token))
},
async deleteExpired(now) {
const rows = await db
.delete(magicLinkTokens)
.where(lte(magicLinkTokens.expiresAt, now))
.returning()
return rows.length
},
})Custom store
Implement the MagicLinkStore interface and pass it in. The contract requires atomic single-use semantics for consumeToken — under concurrent reads of the same token, exactly one call wins and the rest return null. The in-memory adapter satisfies this via the JS event loop; SQL adapters use row-level locking.
Email transports
The sendEmail callback is intentionally unopinionated. Examples for popular transports:
Resend
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY!)
sendEmail: async ({ to, magicLinkUrl, expiresAt }) => {
const { error } = await resend.emails.send({
from: '[email protected]',
to,
subject: 'Your sign-in link',
html: `<p>Click to sign in (expires ${expiresAt.toUTCString()}): <a href="${magicLinkUrl}">${magicLinkUrl}</a></p>`,
})
if (error) throw new Error(`Resend failed: ${error.message}`)
}SendGrid
import sgMail from '@sendgrid/mail'
sgMail.setApiKey(process.env.SENDGRID_API_KEY!)
sendEmail: async ({ to, magicLinkUrl, expiresAt }) => {
await sgMail.send({
from: '[email protected]',
to,
subject: 'Your sign-in link',
html: `<p>Click to sign in (expires ${expiresAt.toUTCString()}): <a href="${magicLinkUrl}">${magicLinkUrl}</a></p>`,
})
}Nodemailer (SMTP)
import nodemailer from 'nodemailer'
const transport = nodemailer.createTransport({
/* SMTP config */
})
sendEmail: async ({ to, magicLinkUrl, expiresAt }) => {
await transport.sendMail({
from: '[email protected]',
to,
subject: 'Your sign-in link',
html: `<p>Click to sign in (expires ${expiresAt.toUTCString()}): <a href="${magicLinkUrl}">${magicLinkUrl}</a></p>`,
})
}Profile shape
interface MagicLinkProfile {
email: string // verified by token possession
verifiedAt: Date // when handleCallback completed
}Per plan v1.1 EC-12 (SHOULD TEST absorbed): email is validated at the start-sign-in boundary (regex + non-empty). Malformed emails throw MagicLinkAuthError(code: 'invalid_email') BEFORE any token is created or stored.
Token lifecycle
- 32 bytes from
crypto.randomBytes→ 43 base64url characters - Default lifetime: 15 minutes (
opts.tokenLifetimeMs) - Single-use:
handleCallbackconsumes atomically; second call with the same token throwsinvalid_or_expired_token - Expired tokens reject at consume time AND can be batch-deleted via
store.cleanupExpired()(run periodically as a cron)
Troubleshooting
| Error | Code | Likely cause |
| ---------------------- | -------------------------- | --------------------------------------------------------------------------------------------- |
| MagicLinkAuthError | invalid_email | Empty / malformed email field in start-sign-in request |
| MagicLinkAuthError | missing_token | Callback URL lacks ?token= |
| MagicLinkAuthError | invalid_or_expired_token | Token unknown, expired (>15min), or already consumed |
| MagicLinkConfigError | use_start_sign_in | App accidentally called provider.createAuthorizationURL (magic-link doesn't use OAuth flow) |
License
MIT — see LICENSE.
