@luckystack/login
v0.10.1
Published
Authentication for LuckyStack: credentials + OAuth (Google/GitHub/Facebook/Discord/Microsoft/custom), Redis-backed sessions, single-session enforcement, password reset, 2FA, lifecycle hooks, and pluggable user/session adapters. Pairs with @luckystack/core
Maintainers
Readme
@luckystack/login
Authentication for LuckyStack. Credentials + OAuth (Google, GitHub, Facebook, Discord, Microsoft), Redis-backed default sessions with a swappable session adapter, single-session enforcement, and lifecycle hooks (
preLogin,preRegister,preLogout,preSessionCreate,preSessionDeleteand theirpost*counterparts).
Install
npm install @luckystack/login socket.ioThe default user adapter uses Prisma; install optional peer @prisma/client when you use it. Framework-mode email flows additionally need optional peer @luckystack/email. Projects with custom UserAdapter and SessionAdapter implementations can use another data layer and session store.
Quickstart
import { loginWithCredentials, getSession, logout } from '@luckystack/login';
import { registerHook } from '@luckystack/core';
// Block login for unverified users:
registerHook('preLogin', async ({ email }) => {
const user = await prisma.user.findFirst({ where: { email } });
if (user && !user.verified) {
return { stop: true, errorCode: 'login.notVerified' };
}
});
// Inside an /auth route handler:
const result = await loginWithCredentials({ email, password });
// → { status: true, reason, newToken, session } on success
// → { status: false, reason } on failure (including hook-stop with the hook's errorCode)The default redisSessionAdapter stores sessions under ${PROJECT_NAME}-session:<token> and applies sliding expiration (every authenticated read extends the TTL by ProjectConfig.session.expiryDays). Register a custom SessionAdapter when sessions should live in another store.
Hooks
pre* hooks fire before the side-effect and may return a HookStopSignal to abort. post* hooks fire after success. Payload types live in ./hookPayloads.ts and are merged into @luckystack/core's HookPayloads via module augmentation.
| Hook | Aborts | Fires from |
| --- | --- | --- |
| preLogin / postLogin | yes | loginWithCredentials, loginCallback |
| preRegister / postRegister | yes | loginWithCredentials (register branch), loginCallback (new OAuth user) |
| preLogout / postLogout | yes | logout |
| preSessionCreate / postSessionCreate | yes | saveSession({ newUser: true }) |
| preSessionDelete / postSessionDelete | yes | deleteSession |
OAuth provider registry
import {
registerOAuthProviders,
googleProvider,
githubProvider,
credentialsProvider,
} from '@luckystack/login';
registerOAuthProviders([
credentialsProvider(),
googleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackUrl: `http://localhost:80/auth/callback/google`,
}),
githubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackUrl: `http://localhost:80/auth/callback/github`,
}),
]);Built-in helpers: googleProvider, githubProvider, discordProvider, facebookProvider, microsoftProvider, credentialsProvider. Each non-credentials helper takes { clientId, clientSecret, callbackUrl } and returns a fully-typed FullOAuthProvider with the provider's authorize/token/userinfo URLs and standard scopes prefilled. Pass anything that satisfies the OAuthProvider interface to register custom providers — see packages/login/src/oauthProviders.ts for the canonical shape.
Provider options
Every helper accepts the base shape plus optional overrides for self-hosted instances and provider-specific tunables.
| Option | Applies to | Default | When to override |
| --- | --- | --- | --- |
| clientId | all | — | Required. The OAuth app's client identifier. |
| clientSecret | all | — | Required. The OAuth app's client secret. |
| callbackUrl | all | — | Required. Must match the URL registered with the provider — your BACKEND origin + /auth/callback/<name> (dev http://localhost:80/auth/callback/<name>, prod https://your-domain.com/auth/callback/<name>). |
| endpoints?.authorizationURL | all | provider-default | GitHub Enterprise host, Microsoft custom-tenant authorize URL, internal auth proxy, etc. |
| endpoints?.tokenExchangeURL | all | provider-default | Same use cases as authorizationURL. |
| endpoints?.userInfoURL | all | provider-default | Self-hosted GitHub Enterprise / Microsoft Graph mirror, etc. |
| apiVersion? | facebookProvider, microsoftProvider | facebook: v18.0, microsoft: v2.0 | Pin to a known-good Graph API version when the upstream rolls out breaking changes. |
| tenant? | microsoftProvider | 'common' | Restrict logins to a specific Azure AD tenant ID. Use a UUID or 'organizations' / 'consumers'. |
| graphApiVersion? | microsoftProvider | 'v1.0' | Pin the Microsoft Graph version used to fetch the user profile (separate from the OAuth apiVersion). |
Example — self-hosted GitHub Enterprise:
githubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackUrl: `http://localhost:80/auth/callback/github`,
endpoints: {
authorizationURL: 'https://github.acme.example/login/oauth/authorize',
tokenExchangeURL: 'https://github.acme.example/login/oauth/access_token',
userInfoURL: 'https://github.acme.example/api/v3/user',
},
});Example — single-tenant Microsoft Entra ID:
microsoftProvider({
clientId: process.env.MICROSOFT_CLIENT_ID,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
callbackUrl: `http://localhost:80/auth/callback/microsoft`,
tenant: process.env.MICROSOFT_TENANT_ID, // 'common' for any tenant; UUID for a single tenant.
apiVersion: 'v2.0', // OAuth endpoint version
graphApiVersion: 'v1.0', // Graph endpoint version
});User adapter
By default the package reads / writes the Prisma User model directly. To bind auth flows to a different table (multi-tenant, alternative ORM, soft-deleted users, etc.), register your own UserAdapter:
import { registerUserAdapter, defaultPrismaUserAdapter } from '@luckystack/login';
registerUserAdapter({
...defaultPrismaUserAdapter,
findByEmail: async (email) => {
const user = await prisma.user.findFirst({ where: { email, deletedAt: null } });
return user;
},
});Account strategy: per-provider vs unified
auth.providerAccountStrategy (in registerProjectConfig) controls how the same email address is treated across sign-in providers:
| Strategy | Behavior | Schema |
|---|---|---|
| 'per-provider' (default) | [email protected] via Google and via GitHub are two separate User rows. Lookups are scoped to (email, provider). | @@unique([email, provider]) recommended. |
| 'unified' | [email protected] maps to one User row; signing in via a new provider links to the existing account (credentials login, OAuth find-or-create, and register dedupe all resolve by email alone). | email must be @unique. |
registerProjectConfig({ auth: { providerAccountStrategy: 'unified' } });Migrating an existing project to 'unified' (the strategy reads accounts by email irrespective of provider, so the DB must enforce one row per email):
- Dedupe existing rows. If you previously ran
'per-provider', the same email may already exist under multiple providers. Merge or remove duplicates so each email appears once. (Pick the row to keep — usually the credentials account or the earliest — repoint related rows, delete the rest.) - Make
emailunique inprisma/schema.prisma:
Thenmodel User { // ... email String @unique // was: email String (+ optional @@unique([email, provider])) provider String // now records the ORIGINAL signup provider only }prisma migrate(ordb push). The DB constraint closes the registration race that the application-level check alone cannot. - No code change is required beyond the config flag — the default
UserAdapteralready implementsfindByEmailAnyProvider. A customUserAdaptermust addfindByEmailAnyProvider({ email })(resolve by email, ignoring provider); if it doesn't, the framework logs a one-time warning and falls back to provider-scoped lookup so the misconfiguration is visible rather than silent.
Post-login redirect
Compute the OAuth callback destination dynamically (per-user, per-tenant, per-provider):
import { registerPostLoginRedirect } from '@luckystack/login';
registerPostLoginRedirect(async ({ user, provider }) => {
if (user.organizationId) return `/org/${user.organizationId}`;
return '/welcome';
});Password reset primitives
Used by the forgotPassword: 'framework' mode and exported for 'custom' consumers who want to drive their own flow:
import {
createPasswordResetToken,
consumePasswordResetToken,
updatePasswordHash,
verifyPassword,
sendPasswordResetEmail, // requires @luckystack/email registered
} from '@luckystack/login';sendPasswordResetEmail is a no-op when @luckystack/email has not been registered, so you can keep the import unconditionally.
Public API
| Export | Purpose |
| --- | --- |
| loginWithCredentials(params) | Combined login/register dispatcher. Routes to register* or login* based on the body shape (presence of confirmPassword). Used by the HTTP /auth/api/credentials route. |
| registerWithCredentials({ email, password, name, confirmPassword }) | Register-only entry point. Use this when you wire a custom auth surface that bypasses the dispatcher's body-shape branching. |
| loginWithCredentialsCore({ email, password }) | Login-only entry point. Same idea as registerWithCredentials. |
| loginCallback(pathname, req, res) | OAuth state-exchange handler — wired to /auth/callback/<provider> by @luckystack/server. |
| createOAuthState(providerName) | Issue a CSRF state token (Redis, NX, TTL from project config). |
| logout({ token, socket, userId }) | End a single socket's session. |
| saveSession(token, user, newUser?) | Write through the active SessionAdapter + broadcast to existing connections. |
| getSession(token) | Read + slide expiration through the active adapter. Dispatches preSessionRefresh / postSessionRefresh around the adapter TTL refresh. |
| deleteSession(token) | Hard delete + clean up active-tokens set. |
| getAllSessions() | Admin utility — scans all sessions. |
| revokeUserSessions(userId) | Force-logout every active session for a user. |
| sessionKeyFor(token) / activeUsersKeyFor(userId) | Centralized Redis-key builders ({projectName}-session:{token} / {projectName}-activeUsers:{userId}). Use these when you read or scan session data from outside @luckystack/login so the key shape stays in lockstep. |
| registerOAuthProviders(list) / getOAuthProviders() / isFullOAuthProvider(p) | OAuth registry. |
| googleProvider, githubProvider, discordProvider, facebookProvider, microsoftProvider, credentialsProvider | Built-in provider factories. |
| registerUserAdapter(adapter) / getUserAdapter() / isUserAdapterRegistered() / defaultPrismaUserAdapter | Pluggable user store. |
| registerSessionAdapter(adapter) / getSessionAdapter() / redisSessionAdapter | Pluggable session storage; Redis is the default. |
| registerPostLoginRedirect(resolver) / getPostLoginRedirect() | Dynamic redirect resolution. |
| createPasswordResetToken, consumePasswordResetToken, updatePasswordHash, verifyPassword, sendPasswordResetEmail | Password-reset primitives. |
Types: BaseSessionLayout, SessionLocation, AuthProps (re-exported from @luckystack/core); OAuthProvider, CredentialsProvider, FullOAuthProvider, UserAdapter, UserAdapterCreateInput, UserRecord, PostLoginRedirectResolver, PostLoginRedirectInput, plus all Pre*Payload / Post*Payload types.
Stored-XSS warning: OAuth name fields
loginCallback reads the user's display name straight from the OAuth provider's profile response and stores it on the User.name column unsanitized. That is fine for plain text rendering and for the framework's avatar-fallback initials, but becomes stored XSS the moment a consumer renders the name as raw HTML (e.g. dangerouslySetInnerHTML, an HTML email body, a server-rendered widget). Two recommended mitigations on the consumer side:
- Render names with React text nodes (the default; safe).
- Strip or escape any
</>characters before injecting names into HTML emails or non-React surfaces.
If your project has no such surfaces this is informational. The framework intentionally does not silently mutate the field because some apps need exact-match search across providers.
Related architecture docs
docs/ARCHITECTURE_AUTH.md— OAuth + credentials lifecycle, allowed-origin checks, role guards.docs/ARCHITECTURE_SESSION.md— session adapters, default Redis layout, sliding expiration, and single-session enforcement.docs/ARCHITECTURE_EMAIL.md— forgot-password modes (framework/custom/disabled).
Dependencies
- Runtime:
@luckystack/core,bcryptjs,validator,dotenv - Required peer:
socket.io@^4.8.0 - Optional peers:
@prisma/client@^6.19.0(default user adapter) and@luckystack/email(only whenforgotPassword: 'framework'). The package lazy-imports optional peers; every other API works without them.
When using the default Prisma UserAdapter, your schema must include a User model with at least: id, email, provider (enum PROVIDERS), password (nullable), name, avatar, avatarFallback, admin, language. See prisma/schema.prisma for the canonical shape. A custom UserAdapter and SessionAdapter can bind auth to another data layer and session store.
License
MIT — see LICENSE.
