https://github.com/odysseon/auth
A plug-and-play, identity-only NestJS authentication module.
https://github.com/odysseon/auth
authentication nestjs nodejs typescript
Last synced: 3 months ago
JSON representation
A plug-and-play, identity-only NestJS authentication module.
- Host: GitHub
- URL: https://github.com/odysseon/auth
- Owner: odysseon
- License: mit
- Created: 2024-08-02T00:33:52.000Z (almost 2 years ago)
- Default Branch: main
- Last Pushed: 2026-03-20T08:14:52.000Z (3 months ago)
- Last Synced: 2026-03-20T11:52:22.178Z (3 months ago)
- Topics: authentication, nestjs, nodejs, typescript
- Language: TypeScript
- Homepage:
- Size: 449 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Security: SECURITY.md
Awesome Lists containing this project
README
# @odysseon/auth
A plug-and-play, identity-only NestJS authentication module built on
**hexagonal architecture**. It handles **who you are** — not what you're
allowed to do. Authorization is your application's concern.
## Requirements
- **Node.js >= 22**
- **pnpm >= 9**
## Design goals
- **Identity only.** No roles, no permissions, no RBAC.
- **Hexagonal architecture.** Ports define what the module needs; your app provides adapters.
- **ORM / DB agnostic.** Bring your own repository implementations.
- **Library agnostic.** Every external npm dependency sits behind a port.
Swap `jose` → `jsonwebtoken`, `argon2` → `bcrypt`, `node:crypto` → a KMS,
or `Bearer header` → a cookie by passing one class. Zero changes to core logic.
- **Capability flags.** Enable only the auth methods you need.
- **True refresh-token rotation.** One-time-use tokens, atomically consumed, SHA-256 hashed.
- **Framework-agnostic core.** `AuthService` is a plain class — use it with NestJS,
plain Fastify, Express, Lambda, or any other runtime. The NestJS adapter layer
(strategies, guards, decorators, module) is the only part that requires NestJS.
## Architecture
```
interfaces/ports/ ← contracts (zero deps — the inversion anchor)
↑
adapters/ ← default implementations of the five internal ports
↑
core/ ← AuthService + AuthModule (use-cases, wiring)
strategies/ ← Passport strategies
filters/ ← AuthExceptionFilter (NestJS HTTP adapter)
guards/ ← JwtAuthGuard, GoogleOAuthGuard
decorators/ ← @CurrentUser(), @Public()
```
### Swappable adapters
| Port | Interface | Default | Swap option |
|---|---|---|---|
| JWT signing/verification | `IJwtSigner` | `JoseJwtSigner` (jose) | `jwtSigner:` |
| Password hashing | `IPasswordHasher` | `Argon2PasswordHasher` (argon2id) | `passwordHasher:` |
| Token hashing / generation | `ITokenHasher` | `CryptoTokenHasher` (node:crypto) | `tokenHasher:` |
| JWT extraction from request | `ITokenExtractor` | `BearerTokenExtractor` (Authorization header) | `tokenExtractor:` |
| Logging | `ILogger` | `ConsoleLogger` (console.log / console.error) | `logger:` |
## Error handling
`AuthService` throws `AuthError` with a typed `AuthErrorCode` — never HTTP-specific
exceptions. This keeps the core framework-agnostic and gives consumers full control
over how errors are surfaced.
**NestJS users:** register `AuthExceptionFilter` to map error codes to HTTP responses:
```ts
// app.module.ts
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_FILTER, useClass: AuthExceptionFilter },
]
```
**Non-NestJS users:** catch `AuthError` and map `err.code` yourself:
```ts
import { AuthError, AuthErrorCode } from '@odysseon/auth';
try {
await authService.loginWithCredentials(input);
} catch (err) {
if (err instanceof AuthError) {
switch (err.code) {
case AuthErrorCode.INVALID_CREDENTIALS: return reply.status(401).send();
case AuthErrorCode.EMAIL_ALREADY_EXISTS: return reply.status(409).send();
}
}
throw err;
}
```
### Error code → HTTP status map
| `AuthErrorCode` | Default HTTP status | Thrown by |
|---|---|---|
| `INVALID_CREDENTIALS` | 401 | `loginWithCredentials`, `changePassword` (wrong current password) |
| `EMAIL_ALREADY_EXISTS` | 409 | `register` |
| `OAUTH_ACCOUNT_NO_PASSWORD` | 400 | `changePassword`, `setPassword` (OAuth-only account) |
| `PASSWORD_SAME_AS_OLD` | 400 | `changePassword` |
| `USER_NOT_FOUND` | 404 | `changePassword`, `setPassword`, `rotateRefreshToken` (deleted user) |
| `OAUTH_USER_NOT_FOUND` | 401 | `handleGoogleCallback` (user vanished after OAuth) |
| `ACCESS_TOKEN_INVALID` | 401 | `verifyAccessToken` (invalid, expired, malformed, or wrong token type) |
| `REFRESH_TOKEN_INVALID` | 401 | `rotateRefreshToken` (bad or already-used token) |
| `REFRESH_TOKEN_EXPIRED` | 401 | `rotateRefreshToken` |
| `REFRESH_NOT_ENABLED` | 501 | `rotateRefreshToken` (misconfiguration) |
## Quick start
### 1. Install
```bash
pnpm add @odysseon/auth
# Peer deps
pnpm add @nestjs/passport passport passport-jwt
# Default adapter deps (install only what you use)
pnpm add jose # JWT — always needed
pnpm add argon2 # passwords — needed for 'credentials' capability
pnpm add passport-google-oauth20 # needed for 'google' capability
```
### 2. Implement your repository ports
```ts
// user.repository.ts
@Injectable()
export class UserRepository implements IGoogleUserRepository {
findById(id: string) { return this.db.users.findOne({ id }); }
findByEmail(email: string) { return this.db.users.findOne({ email }); }
findByGoogleId(id: string) { return this.db.users.findOne({ googleId: id }); }
create(data: Partial) { return this.db.users.create(data); }
update(id, data) { return this.db.users.update(id, data); }
}
// refresh-token.repository.ts
@Injectable()
export class RefreshTokenRepository implements IRefreshTokenRepository {
create(data) { ... }
consumeByTokenHash(hash: string) { ... } // atomic find-and-delete
deleteAllForUser(userId: string) { ... }
}
```
### 3. Register the module
```ts
// app.module.ts
AuthModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
jwt: {
type: 'asymmetric',
privateKey: cfg.get('JWT_PRIVATE_KEY'),
publicKey: cfg.get('JWT_PUBLIC_KEY'),
accessToken: { expiresIn: '15m', algorithm: 'ES256' },
refreshToken: { expiresIn: '7d' },
},
google: {
clientID: cfg.get('GOOGLE_CLIENT_ID'),
clientSecret: cfg.get('GOOGLE_CLIENT_SECRET'),
callbackURL: cfg.get('GOOGLE_CALLBACK_URL'),
},
}),
userRepository: UserRepository,
refreshTokenRepository: RefreshTokenRepository,
enabledCapabilities: ['credentials', 'google'],
})
```
> **`enabledCapabilities`:** `'credentials'` (email/password) is always active.
> `'google'` is opt-in — omit it and `GoogleStrategy`/`GoogleOAuthGuard` are never
> registered and `passport-google-oauth20` is never loaded.
```
### 4. Use in controllers
```ts
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Public()
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.loginWithCredentials(dto);
}
@Public()
@Get('google')
@UseGuards(GoogleOAuthGuard)
googleLogin() {}
@Public()
@Get('google/callback')
@UseGuards(GoogleOAuthGuard)
googleCallback(@Req() req: AuthenticatedRequest) {
return this.authService.handleGoogleCallback(req.user);
}
@Public()
@Post('refresh')
refresh(@Body('refreshToken') token: string) {
return this.authService.rotateRefreshToken(token);
}
@Post('logout')
logout(@CurrentUser() user: RequestUser) {
return this.authService.logout(user.userId);
}
@Get('me')
me(@CurrentUser() user: RequestUser) {
return user;
}
}
```
### 5. Apply guard and filter globally (recommended)
```ts
// app.module.ts
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_FILTER, useClass: AuthExceptionFilter },
]
// Then use @Public() on open endpoints instead of @UseGuards everywhere.
```
## Swapping an adapter
No changes to any core file — only your module registration changes:
```ts
// swap-bcrypt.example.ts
import * as bcrypt from 'bcrypt';
@Injectable()
export class BcryptPasswordHasher implements IPasswordHasher {
async hash(password: string) { return bcrypt.hash(password, 12); }
async verify(password: string, hash: string) { return bcrypt.compare(password, hash); }
}
// In AuthModule.forRootAsync():
passwordHasher: BcryptPasswordHasher
```
### Swapping the token extractor
By default tokens are read from `Authorization: Bearer `. To read from
a cookie instead:
```ts
import { CookieTokenExtractor } from '@odysseon/auth';
// In AuthModule.forRootAsync():
tokenExtractor: new CookieTokenExtractor('access_token')
```
Requires `cookie-parser` middleware in your application:
```ts
// main.ts
import * as cookieParser from 'cookie-parser';
app.use(cookieParser());
```
### Swapping the logger
By default informational messages are written to `console.log`. To use NestJS
structured logging:
```ts
import { Logger } from '@nestjs/common';
import type { ILogger } from '@odysseon/auth';
@Injectable()
export class NestJsLogger implements ILogger {
private readonly l = new Logger('AuthService');
log(message: string) { this.l.log(message); }
error(message: string, ctx?: unknown) { this.l.error(message, ctx); }
}
// In AuthModule.forRootAsync():
logger: NestJsLogger
```
## Testing
Every external dependency is behind a port — mock the token, not the library:
```ts
const module = await Test.createTestingModule({ ... })
.overrideProvider(PORTS.PASSWORD_HASHER)
.useValue({ hash: jest.fn().mockResolvedValue('hash'), verify: jest.fn().mockResolvedValue(true) })
.overrideProvider(PORTS.JWT_SIGNER)
.useValue({ init: jest.fn(), sign: jest.fn().mockResolvedValue('token'), verify: jest.fn() })
.overrideProvider(PORTS.TOKEN_EXTRACTOR)
.useValue({ extract: jest.fn().mockReturnValue('mock-token') })
.overrideProvider(PORTS.LOGGER)
.useValue({ log: jest.fn(), error: jest.fn() })
.compile();
```
No real crypto runs in tests. Blazing fast, zero flakiness.
## Using `AuthService` without NestJS
`AuthService` is a plain class. Every dependency is injected through its constructor — no framework lifecycle, no decorators at runtime. You can instantiate it directly in Fastify, Express, Lambda, or any other Node.js context.
```ts
// fastify-main.ts
import Fastify from 'fastify';
import {
AuthService, AuthError, AuthErrorCode,
JoseJwtSigner, Argon2PasswordHasher, CryptoTokenHasher, ConsoleLogger,
} from '@odysseon/auth';
const authService = new AuthService(
{
type: 'symmetric',
secret: process.env.JWT_SECRET!,
accessToken: { expiresIn: '15m' },
refreshToken: { expiresIn: '7d' },
},
new JoseJwtSigner(),
new Argon2PasswordHasher(),
new CryptoTokenHasher(),
new ConsoleLogger(),
new MyUserRepository(), // implements IUserRepository
new MyRefreshTokenRepository(), // implements IRefreshTokenRepository
);
// Call once at startup — validates config and imports JWT keys.
await authService.init();
const fastify = Fastify();
// Map AuthError codes to HTTP responses manually (no AuthExceptionFilter needed)
const STATUS: Record = {
[AuthErrorCode.INVALID_CREDENTIALS]: 401,
[AuthErrorCode.EMAIL_ALREADY_EXISTS]: 409,
[AuthErrorCode.ACCESS_TOKEN_INVALID]: 401,
[AuthErrorCode.REFRESH_TOKEN_INVALID]: 401,
[AuthErrorCode.REFRESH_TOKEN_EXPIRED]: 401,
[AuthErrorCode.USER_NOT_FOUND]: 404,
[AuthErrorCode.REFRESH_NOT_ENABLED]: 501,
};
fastify.post('/auth/register', async (req, reply) => {
try {
return await authService.register(req.body as any);
} catch (err) {
if (err instanceof AuthError) return reply.status(STATUS[err.code] ?? 500).send({ error: err.code });
throw err;
}
});
fastify.post('/auth/login', async (req, reply) => {
try {
return await authService.loginWithCredentials(req.body as any);
} catch (err) {
if (err instanceof AuthError) return reply.status(STATUS[err.code] ?? 500).send({ error: err.code });
throw err;
}
});
// Protect routes with a preHandler hook — no Passport, no guards
fastify.addHook('preHandler', async (req, reply) => {
const open = ['/auth/register', '/auth/login', '/auth/refresh'];
if (open.includes(req.url)) return;
const token = (req.headers.authorization ?? '').slice(7);
if (!token) return reply.status(401).send({ error: 'Missing token' });
try {
(req as any).user = await authService.verifyAccessToken(token);
} catch {
return reply.status(401).send({ error: 'Invalid token' });
}
});
await fastify.listen({ port: 3000 });
```
Only `AuthService` and the default adapter classes are needed outside NestJS. `AuthModule`, `JwtAuthGuard`, `GoogleOAuthGuard`, `AuthExceptionFilter`, `@CurrentUser()`, `@Public()`, and `GoogleStrategy` all require NestJS and are irrelevant in this context.
## Exported API
| Export | Description |
|---|---|
| `AuthModule` | Root module — `forRootAsync()` |
| `AuthModuleAsyncOptions` | NestJS wiring type for `forRootAsync()` |
| `AuthService` | All use-case methods |
| `AuthError` | Domain error class thrown by `AuthService` |
| `AuthErrorCode` | Typed error code constants |
| `AuthExceptionFilter` | NestJS filter — maps `AuthError` codes to HTTP responses |
| `JwtAuthGuard` | Protect routes; respects `@Public()` |
| `GoogleOAuthGuard` | Initiate / handle Google OAuth |
| `@CurrentUser()` | Extract `RequestUser` from request |
| `@Public()` | Opt out of global `JwtAuthGuard` |
| `JoseJwtSigner` | Default JWT adapter (jose) |
| `Argon2PasswordHasher` | Default password adapter (argon2id) |
| `CryptoTokenHasher` | Default token adapter (node:crypto) |
| `BearerTokenExtractor` | Default extractor — `Authorization: Bearer` header |
| `CookieTokenExtractor` | Extractor — named HTTP cookie |
| `QueryParamTokenExtractor` | Extractor — URL query parameter |
| `ConsoleLogger` | Default logger adapter (console.log / console.error, zero deps) |
| `IJwtSigner` | Port — implement to swap JWT library |
| `IPasswordHasher` | Port — implement to swap password hasher |
| `ITokenHasher` | Port — implement to swap token hasher |
| `ITokenExtractor` | Port — implement to swap token extraction |
| `ILogger` | Port — implement to swap logger |
| `IUserRepository` | Port — implement in your infra layer |
| `IRefreshTokenRepository` | Port — implement in your infra layer |
| `PORTS` | DI tokens for testing overrides (`AUTH_CAPABILITIES` is internal — not exported) |
| `parseDurationToSeconds` | Utility — converts `'15m'`/`'7d'` duration strings to seconds |
| `JwtConfig`, `RefreshTokenConfig` | Config types for JWT setup |
| `GoogleOAuthConfig` | Config type for Google OAuth setup |
| `AuthModuleConfig` | Top-level config object shape |
| `AuthUser`, `BaseUser`, `CredentialsUser`, `GoogleUser` | User entity contracts |
| `RequestUser` | Shape of `req.user` after JWT validation |
| `AuthenticatedRequest` | Request object extended with `user: RequestUser` |
| `AuthResponse`, `TokenPair` | Token response shapes |
| `JwtPayload` | Claims embedded in every access token |
| `LoginInput`, `RegistrationInput`, `PasswordChangeInput`, `PasswordSetInput` | Operation input shapes |
## Environment variables
| Variable | Required | Description |
|---|---|---|
| `JWT_PRIVATE_KEY` | Asymmetric only | PEM-encoded EC/RSA private key |
| `JWT_PUBLIC_KEY` | Asymmetric only | PEM-encoded EC/RSA public key |
| `GOOGLE_CLIENT_ID` | `google` capability | OAuth 2.0 client ID |
| `GOOGLE_CLIENT_SECRET` | `google` capability | OAuth 2.0 client secret |
| `GOOGLE_CALLBACK_URL` | `google` capability | OAuth 2.0 callback URL |