@neoma/garmr
v0.7.0
Published
Passwordless authentication and permission-based authorization for NestJS. Magic links, cookie/bearer sessions, and wildcard permissions.
Maintainers
Readme
@neoma/garmr
Passwordless authentication for NestJS applications. Garmr provides magic link authentication, JWT session management, cookie-based sessions, and route protection out of the box.
Why Passwordless?
Password authentication requires secure hashing, strength validation, reset flows, and breach checking. Magic links eliminate all of this complexity. The email IS the verification - simpler for developers, fewer security footguns.
Features
- Magic link authentication (send & verify)
- JWT session tokens with HS256 algorithm enforcement and audience validation
- Cookie-based sessions (httpOnly, secure, sameSite) with configurable options
- Dual transport: Bearer token and cookie authentication middlewares
- Route protection with guards and decorators
- Permission-based authorization with wildcard support (
@RequiresPermission,@RequiresAnyPermission) - Email normalization (case-insensitive)
- Event emission for registration and authentication
Installation
npm install @neoma/garmrPeer Dependencies
npm install @nestjs/common @nestjs/core @nestjs/platform-express @nestjs/event-emitter @nestjs/typeorm rxjs reflect-metadata class-validator typeormGetting Started
1. Create your User entity
Your user entity must implement the Authenticatable interface:
import { Authenticatable } from "@neoma/garmr"
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
@Entity()
export class User implements Authenticatable {
@PrimaryGeneratedColumn("uuid")
public id: string
@Column({ unique: true })
public email: string
@Column("simple-array", { default: "" })
public permissions: string[]
}2. Configure GarmrModule
Import and configure GarmrModule in your application module:
import { GarmrModule } from "@neoma/garmr"
import { Module } from "@nestjs/common"
import { TypeOrmModule } from "@nestjs/typeorm"
import { User } from "./user.entity"
@Module({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
// ... your database config
entities: [User],
}),
GarmrModule.forRoot({
secret: process.env.JWT_SECRET,
expiresIn: "1h",
entity: User,
mailer: {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
from: "[email protected]",
welcome: {
subject: "Welcome to YourApp",
html: '<a href="https://yourapp.com/auth/verify?token={{token}}">Sign up</a>',
},
welcomeBack: {
subject: "Sign in to YourApp",
html: '<a href="https://yourapp.com/auth/verify?token={{token}}">Sign in</a>',
},
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
cookie: {
name: "garmr.sid", // default
secure: true, // default
sameSite: "lax", // default
path: "/", // default
// domain: ".yourapp.com", // optional
},
}),
],
})
export class AppModule {}3. Enable validation
Garmr exports EmailDto with class-validator decorators. For validation to work, enable ValidationPipe in your application.
See the NestJS Validation documentation for setup instructions.
4. Create authentication endpoints
Use the provided services to build your authentication endpoints:
import { EmailDto, MagicLinkService, SessionService } from "@neoma/garmr"
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Res,
} from "@nestjs/common"
import { Response } from "express"
import { User } from "./user.entity"
@Controller("auth")
export class AuthController {
public constructor(
private readonly magicLinkService: MagicLinkService,
private readonly sessionService: SessionService,
) {}
@Post("magic-link")
@HttpCode(HttpStatus.ACCEPTED)
public async sendMagicLink(@Body() dto: EmailDto): Promise<void> {
await this.magicLinkService.send(dto.email)
}
@Get("verify")
public async verify(
@Query("token") token: string,
@Res({ passthrough: true }) res: Response,
): Promise<{ token: string; user: User; isNewUser: boolean }> {
const { entity, isNewUser } = await this.magicLinkService.verify<User>(token)
const { token: sessionToken } = this.sessionService.create(res, entity)
return { token: sessionToken, user: entity, isNewUser }
}
}SessionService.create() issues a session JWT and sets it as an httpOnly cookie on the response. The cookie's Max-Age is automatically aligned with the JWT's expiry.
5. Protect routes
Use the Authenticated guard and Principal decorator to protect routes:
import { Authenticated, Principal } from "@neoma/garmr"
import { Controller, Get, UseGuards } from "@nestjs/common"
import { User } from "./user.entity"
@Controller("me")
@UseGuards(Authenticated)
export class ProfileController {
@Get()
public get(@Principal() user: User): { id: string; email: string } {
return {
id: user.id,
email: user.email,
}
}
}GarmrModule automatically applies BearerAuthenticationMiddleware and CookieAuthenticationMiddleware to all routes. They extract the JWT from the Authorization: Bearer <token> header or the garmr.sid cookie respectively, and attach the authenticated user to req.principal. Bearer takes priority when both are present.
Magic Link Flow
- User submits email ->
POST /auth/magic-link - Server generates JWT with
aud: "magic-link"and emails verification link - User clicks link ->
GET /auth/verify?token=... - Server validates token, creates user (if new) or finds existing user
- Server issues session JWT with
aud: "session"and sets httpOnly cookie - Subsequent requests authenticate via cookie or
Authorization: Bearer <token>header
Example Requests
Request Magic Link
curl -X POST http://localhost:3000/auth/magic-link \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'Success (202 Accepted): Empty response, email sent.
Validation error (400 Bad Request):
{
"statusCode": 400,
"message": ["Please enter a valid email address."],
"error": "Bad Request"
}Verify Magic Link
curl "http://localhost:3000/auth/verify?token=eyJhbGciOiJIUzI1NiIs..."Success (200 OK):
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]"
},
"isNewUser": true
}The response also includes a Set-Cookie header with the session token.
Invalid token (401 Unauthorized):
{
"statusCode": 401,
"message": "Invalid magic link token: invalid audience",
"reason": "invalid audience",
"error": "Unauthorized"
}Accessing Protected Routes
Via Bearer token:
curl http://localhost:3000/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Via cookie (set automatically by the verify endpoint):
curl http://localhost:3000/me \
-b "garmr.sid=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Success (200 OK):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]"
}Not authenticated (401 Unauthorized):
{
"statusCode": 401,
"message": "Unable to authenticate a principal. Please check the documentation for accepted authentication methods",
"error": "Unauthorized"
}API Reference
GarmrModule
GarmrModule.forRoot(options)
Configures the authentication module. The module is global — import it once in your root module.
| Option | Type | Description |
|--------|------|-------------|
| secret | string | JWT signing secret |
| expiresIn | string \| number | Session token expiration (e.g., "1h", "7d") |
| entity | Type<Authenticatable> | Your user entity class |
| mailer | MailerOptions | Email configuration (see below) |
| cookie | CookieOptions | Session cookie configuration (optional) |
MailerOptions
| Option | Type | Description |
|--------|------|-------------|
| host | string | SMTP host |
| port | number | SMTP port |
| from | string | Sender email address |
| welcome | MailerTemplate | Template sent to new users (registration) |
| welcomeBack | MailerTemplate | Template sent to existing users (login) |
| auth.user | string | SMTP username |
| auth.pass | string | SMTP password |
MailerTemplate
| Option | Type | Description |
|--------|------|-------------|
| subject | string | Email subject line |
| html | string | Email HTML body (use {{token}} placeholder) |
CookieOptions
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| name | string | "garmr.sid" | Cookie name |
| domain | string | — | Cookie domain |
| path | string | "/" | Cookie path |
| secure | boolean | true | Only send over HTTPS |
| sameSite | "strict" \| "lax" \| "none" | "lax" | SameSite attribute |
Services
MagicLinkService
send(email: string): Promise<void>- Sends a magic link email (useswelcometemplate for new users,welcomeBackfor existing)verify<T>(token: string): Promise<{ entity: T; isNewUser: boolean }>- Validates token and returns/creates user
SessionService
create(res: Response, entity: Authenticatable): { token: string; payload: JwtPayload }- Issues a session JWT and sets it as an httpOnly cookieclear(res: Response): void- Clears the session cookie
AuthenticationService
authenticate<T>(token: string): Promise<T>- Validates a raw session JWT and returns the user
TokenService
issue(payload, options?): { token: string; payload: JwtPayload }- Issues a JWT (HS256)verify(token: string): JwtPayload- Verifies a token (HS256 only)
Middlewares
BearerAuthenticationMiddleware
Extracts JWT from the Authorization: Bearer <token> header. Throws InvalidCredentialsException for malformed headers. Logs and continues for authentication failures.
CookieAuthenticationMiddleware
Extracts JWT from the configured cookie (default garmr.sid). Logs and continues for authentication failures.
Both middlewares are automatically applied by GarmrModule. Bearer runs first; if it sets req.principal, the cookie middleware skips.
PermissionService
hasPermission(principal, permission): boolean- Check if a principal has a specific permissionhasAllPermissions(principal, permissions): boolean- Check if a principal has ALL permissions (AND logic)hasAnyPermission(principal, permissions): boolean- Check if a principal has at least one permission (OR logic)requirePermission(principal, permission): void- ThrowsPermissionDeniedExceptionif missingrequireAllPermissions(principal, permissions): void- Throws if any are missing (AND)requireAnyPermission(principal, permissions): void- Throws if all are missing (OR)
Valid permission formats:
*— matches all permissionsname— single-segment, exact match only (e.g.,admin)action:resource— exact match (e.g.,read:users)*:resource— matches any action on that resource (e.g.,*:articlesmatchesread:articles)action:*— matches an action on any resource (e.g.,read:*matchesread:users)
Invalid formats (e.g., read:users:admin, empty strings) throw at decoration time or at runtime.
Constants
MAGIC_LINK_AUDIENCE- Value:"magic-link"- Used for magic link tokensSESSION_AUDIENCE- Value:"session"- Used for session tokens
Guards
Authenticated
A guard that ensures req.principal exists. Throws UnauthorizedException if not authenticated.
@UseGuards(Authenticated)
@Controller("protected")
export class ProtectedController {}For server-rendered apps, pass a redirect URL. The guard throws UnauthorizedRedirectException — still a 401, but carrying redirect metadata via getRedirect(). An exception filter may use this to issue an HTTP redirect for browser-based requests instead of returning 401 JSON.
@UseGuards(new Authenticated("/auth/magic-link"))
@Controller("dashboard")
export class DashboardController {}Decorators
Principal
Extracts the authenticated user from the request.
@Get()
public getProfile(@Principal() user: User): User {
return user
}RequiresPermission
Enforces that the authenticated user has all of the specified permissions (AND logic). Automatically applies authentication and the permission guard.
// Single permission
@Get("articles")
@RequiresPermission("read:articles")
public getArticles() {}
// Multiple permissions (AND — user must have both)
@Get("articles/edit")
@RequiresPermission("read:articles", "write:articles")
public editArticles() {}
// Class-level — applies to all methods
@Controller("admin")
@RequiresPermission("read:admin")
export class AdminController {}RequiresAnyPermission
Enforces that the authenticated user has at least one of the specified permissions (OR logic).
// User must have admin OR delete:articles
@Get("articles/delete")
@RequiresAnyPermission("admin", "delete:articles")
public deleteArticles() {}Both decorators can be combined on a single method:
// User must have read:reports AND (admin OR write:reports)
@Get("reports")
@RequiresPermission("read:reports")
@RequiresAnyPermission("admin", "write:reports")
public getReports() {}DTOs
EmailDto
email- Required, must be valid email format
Exceptions
| Exception | Status | When |
|-----------|--------|------|
| InvalidMagicLinkTokenException | 401 | Magic link token invalid or wrong audience |
| TokenFailedVerificationException | 401 | JWT verification failed (expired, invalid signature) |
| IncorrectCredentialsException | 401 | User not found for valid token |
| InvalidCredentialsException | 401 | Token invalid, wrong audience, or malformed header |
| UnauthorizedRedirectException | 401 | Unauthenticated request on a route with a redirect URL. Carries redirect metadata via getRedirect() for filters to handle |
| PermissionDeniedException | 403 | User lacks required permission(s) |
Events
GarmrRegisteredEvent
Emitted when a new user is created via magic link verification.
import { GarmrRegisteredEvent } from "@neoma/garmr"
import { OnEvent } from "@nestjs/event-emitter"
@Injectable()
export class NotificationService {
@OnEvent(GarmrRegisteredEvent.EVENT_NAME)
public async onRegistered(event: GarmrRegisteredEvent): Promise<void> {
// Send welcome email, etc.
}
}GarmrAuthenticatedEvent
Emitted when an existing user verifies a magic link or authenticates via session token.
Interfaces
Authenticatable
interface Authenticatable {
id: any
email: string
permissions?: string[]
}Implement this on any entity you want to authenticate. The permissions field is optional — only needed if you use the permission decorators.
Security
- JWTs are signed and verified with HS256 only — other algorithms are rejected
- Magic link tokens use
aud: "magic-link", session tokens useaud: "session"— cross-use is prevented - Session cookies are httpOnly (not accessible to JavaScript), secure (HTTPS only), and SameSite=Lax by default
- Cookie
Max-Ageis automatically aligned with JWT expiry - Error responses use a generic message — internal details are logged server-side only
- Email lookups are case-insensitive (normalized to lowercase)
- Magic links expire after 15 minutes
License
MIT
