{"id":48105373,"url":"https://github.com/odysseon/auth","last_synced_at":"2026-04-04T15:53:26.656Z","repository":{"id":251293654,"uuid":"836975068","full_name":"odysseon/auth","owner":"odysseon","description":"A plug-and-play, identity-only NestJS authentication module.","archived":false,"fork":false,"pushed_at":"2026-03-20T08:14:52.000Z","size":460,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-03-20T11:52:22.178Z","etag":null,"topics":["authentication","nestjs","nodejs","typescript"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/odysseon.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2024-08-02T00:33:52.000Z","updated_at":"2026-03-20T08:13:46.000Z","dependencies_parsed_at":"2024-11-29T21:03:07.604Z","dependency_job_id":null,"html_url":"https://github.com/odysseon/auth","commit_stats":null,"previous_names":["phastboy/auth","odysseon/auth"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/odysseon/auth","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/odysseon%2Fauth","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/odysseon%2Fauth/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/odysseon%2Fauth/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/odysseon%2Fauth/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/odysseon","download_url":"https://codeload.github.com/odysseon/auth/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/odysseon%2Fauth/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31404380,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-04T10:20:44.708Z","status":"ssl_error","status_checked_at":"2026-04-04T10:20:06.846Z","response_time":60,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["authentication","nestjs","nodejs","typescript"],"created_at":"2026-04-04T15:53:26.021Z","updated_at":"2026-04-04T15:53:26.648Z","avatar_url":"https://github.com/odysseon.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @odysseon/auth\n\nA plug-and-play, identity-only NestJS authentication module built on\n**hexagonal architecture**. It handles **who you are** — not what you're\nallowed to do. Authorization is your application's concern.\n\n## Requirements\n\n- **Node.js \u003e= 22**\n- **pnpm \u003e= 9**\n\n## Design goals\n\n- **Identity only.** No roles, no permissions, no RBAC.\n- **Hexagonal architecture.** Ports define what the module needs; your app provides adapters.\n- **ORM / DB agnostic.** Bring your own repository implementations.\n- **Library agnostic.** Every external npm dependency sits behind a port.\n  Swap `jose` → `jsonwebtoken`, `argon2` → `bcrypt`, `node:crypto` → a KMS,\n  or `Bearer header` → a cookie by passing one class. Zero changes to core logic.\n- **Capability flags.** Enable only the auth methods you need.\n- **True refresh-token rotation.** One-time-use tokens, atomically consumed, SHA-256 hashed.\n- **Framework-agnostic core.** `AuthService` is a plain class — use it with NestJS,\n  plain Fastify, Express, Lambda, or any other runtime. The NestJS adapter layer\n  (strategies, guards, decorators, module) is the only part that requires NestJS.\n\n## Architecture\n\n```\ninterfaces/ports/   ← contracts (zero deps — the inversion anchor)\n      ↑\n  adapters/         ← default implementations of the five internal ports\n      ↑\n    core/           ← AuthService + AuthModule (use-cases, wiring)\n  strategies/       ← Passport strategies\n  filters/          ← AuthExceptionFilter (NestJS HTTP adapter)\n    guards/         ← JwtAuthGuard, GoogleOAuthGuard\n decorators/        ← @CurrentUser(), @Public()\n```\n\n### Swappable adapters\n\n| Port | Interface | Default | Swap option |\n|---|---|---|---|\n| JWT signing/verification | `IJwtSigner` | `JoseJwtSigner` (jose) | `jwtSigner:` |\n| Password hashing | `IPasswordHasher` | `Argon2PasswordHasher` (argon2id) | `passwordHasher:` |\n| Token hashing / generation | `ITokenHasher` | `CryptoTokenHasher` (node:crypto) | `tokenHasher:` |\n| JWT extraction from request | `ITokenExtractor` | `BearerTokenExtractor` (Authorization header) | `tokenExtractor:` |\n| Logging | `ILogger` | `ConsoleLogger` (console.log / console.error) | `logger:` |\n\n## Error handling\n\n`AuthService` throws `AuthError` with a typed `AuthErrorCode` — never HTTP-specific\nexceptions. This keeps the core framework-agnostic and gives consumers full control\nover how errors are surfaced.\n\n**NestJS users:** register `AuthExceptionFilter` to map error codes to HTTP responses:\n\n```ts\n// app.module.ts\nproviders: [\n  { provide: APP_GUARD,  useClass: JwtAuthGuard },\n  { provide: APP_FILTER, useClass: AuthExceptionFilter },\n]\n```\n\n**Non-NestJS users:** catch `AuthError` and map `err.code` yourself:\n\n```ts\nimport { AuthError, AuthErrorCode } from '@odysseon/auth';\n\ntry {\n  await authService.loginWithCredentials(input);\n} catch (err) {\n  if (err instanceof AuthError) {\n    switch (err.code) {\n      case AuthErrorCode.INVALID_CREDENTIALS: return reply.status(401).send();\n      case AuthErrorCode.EMAIL_ALREADY_EXISTS: return reply.status(409).send();\n    }\n  }\n  throw err;\n}\n```\n\n### Error code → HTTP status map\n\n| `AuthErrorCode` | Default HTTP status | Thrown by |\n|---|---|---|\n| `INVALID_CREDENTIALS` | 401 | `loginWithCredentials`, `changePassword` (wrong current password) |\n| `EMAIL_ALREADY_EXISTS` | 409 | `register` |\n| `OAUTH_ACCOUNT_NO_PASSWORD` | 400 | `changePassword`, `setPassword` (OAuth-only account) |\n| `PASSWORD_SAME_AS_OLD` | 400 | `changePassword` |\n| `USER_NOT_FOUND` | 404 | `changePassword`, `setPassword`, `rotateRefreshToken` (deleted user) |\n| `OAUTH_USER_NOT_FOUND` | 401 | `handleGoogleCallback` (user vanished after OAuth) |\n| `ACCESS_TOKEN_INVALID` | 401 | `verifyAccessToken` (invalid, expired, malformed, or wrong token type) |\n| `REFRESH_TOKEN_INVALID` | 401 | `rotateRefreshToken` (bad or already-used token) |\n| `REFRESH_TOKEN_EXPIRED` | 401 | `rotateRefreshToken` |\n| `REFRESH_NOT_ENABLED` | 501 | `rotateRefreshToken` (misconfiguration) |\n\n## Quick start\n\n### 1. Install\n\n```bash\npnpm add @odysseon/auth\n# Peer deps\npnpm add @nestjs/passport passport passport-jwt\n# Default adapter deps (install only what you use)\npnpm add jose                    # JWT — always needed\npnpm add argon2                  # passwords — needed for 'credentials' capability\npnpm add passport-google-oauth20 # needed for 'google' capability\n```\n\n### 2. Implement your repository ports\n\n```ts\n// user.repository.ts\n@Injectable()\nexport class UserRepository implements IGoogleUserRepository\u003cUser\u003e {\n  findById(id: string)          { return this.db.users.findOne({ id }); }\n  findByEmail(email: string)    { return this.db.users.findOne({ email }); }\n  findByGoogleId(id: string)    { return this.db.users.findOne({ googleId: id }); }\n  create(data: Partial\u003cUser\u003e)   { return this.db.users.create(data); }\n  update(id, data)              { return this.db.users.update(id, data); }\n}\n\n// refresh-token.repository.ts\n@Injectable()\nexport class RefreshTokenRepository implements IRefreshTokenRepository {\n  create(data)                           { ... }\n  consumeByTokenHash(hash: string)       { ... } // atomic find-and-delete\n  deleteAllForUser(userId: string)       { ... }\n}\n```\n\n### 3. Register the module\n\n```ts\n// app.module.ts\nAuthModule.forRootAsync({\n  imports:  [ConfigModule],\n  inject:   [ConfigService],\n  useFactory: (cfg: ConfigService) =\u003e ({\n    jwt: {\n      type:         'asymmetric',\n      privateKey:   cfg.get('JWT_PRIVATE_KEY'),\n      publicKey:    cfg.get('JWT_PUBLIC_KEY'),\n      accessToken:  { expiresIn: '15m', algorithm: 'ES256' },\n      refreshToken: { expiresIn: '7d' },\n    },\n    google: {\n      clientID:     cfg.get('GOOGLE_CLIENT_ID'),\n      clientSecret: cfg.get('GOOGLE_CLIENT_SECRET'),\n      callbackURL:  cfg.get('GOOGLE_CALLBACK_URL'),\n    },\n  }),\n  userRepository:         UserRepository,\n  refreshTokenRepository: RefreshTokenRepository,\n  enabledCapabilities:    ['credentials', 'google'],\n})\n```\n\n\u003e **`enabledCapabilities`:** `'credentials'` (email/password) is always active.\n\u003e `'google'` is opt-in — omit it and `GoogleStrategy`/`GoogleOAuthGuard` are never\n\u003e registered and `passport-google-oauth20` is never loaded.\n```\n\n### 4. Use in controllers\n\n```ts\n@Controller('auth')\nexport class AuthController {\n  constructor(private readonly authService: AuthService) {}\n\n  @Public()\n  @Post('register')\n  register(@Body() dto: RegisterDto) {\n    return this.authService.register(dto);\n  }\n\n  @Public()\n  @Post('login')\n  login(@Body() dto: LoginDto) {\n    return this.authService.loginWithCredentials(dto);\n  }\n\n  @Public()\n  @Get('google')\n  @UseGuards(GoogleOAuthGuard)\n  googleLogin() {}\n\n  @Public()\n  @Get('google/callback')\n  @UseGuards(GoogleOAuthGuard)\n  googleCallback(@Req() req: AuthenticatedRequest) {\n    return this.authService.handleGoogleCallback(req.user);\n  }\n\n  @Public()\n  @Post('refresh')\n  refresh(@Body('refreshToken') token: string) {\n    return this.authService.rotateRefreshToken(token);\n  }\n\n  @Post('logout')\n  logout(@CurrentUser() user: RequestUser) {\n    return this.authService.logout(user.userId);\n  }\n\n  @Get('me')\n  me(@CurrentUser() user: RequestUser) {\n    return user;\n  }\n}\n```\n\n### 5. Apply guard and filter globally (recommended)\n\n```ts\n// app.module.ts\nproviders: [\n  { provide: APP_GUARD,  useClass: JwtAuthGuard },\n  { provide: APP_FILTER, useClass: AuthExceptionFilter },\n]\n// Then use @Public() on open endpoints instead of @UseGuards everywhere.\n```\n\n## Swapping an adapter\n\nNo changes to any core file — only your module registration changes:\n\n```ts\n// swap-bcrypt.example.ts\nimport * as bcrypt from 'bcrypt';\n\n@Injectable()\nexport class BcryptPasswordHasher implements IPasswordHasher {\n  async hash(password: string)                 { return bcrypt.hash(password, 12); }\n  async verify(password: string, hash: string) { return bcrypt.compare(password, hash); }\n}\n\n// In AuthModule.forRootAsync():\npasswordHasher: BcryptPasswordHasher\n```\n\n### Swapping the token extractor\n\nBy default tokens are read from `Authorization: Bearer \u003ctoken\u003e`. To read from\na cookie instead:\n\n```ts\nimport { CookieTokenExtractor } from '@odysseon/auth';\n\n// In AuthModule.forRootAsync():\ntokenExtractor: new CookieTokenExtractor('access_token')\n```\n\nRequires `cookie-parser` middleware in your application:\n\n```ts\n// main.ts\nimport * as cookieParser from 'cookie-parser';\napp.use(cookieParser());\n```\n\n### Swapping the logger\n\nBy default informational messages are written to `console.log`. To use NestJS\nstructured logging:\n\n```ts\nimport { Logger } from '@nestjs/common';\nimport type { ILogger } from '@odysseon/auth';\n\n@Injectable()\nexport class NestJsLogger implements ILogger {\n  private readonly l = new Logger('AuthService');\n  log(message: string)                     { this.l.log(message); }\n  error(message: string, ctx?: unknown)    { this.l.error(message, ctx); }\n}\n\n// In AuthModule.forRootAsync():\nlogger: NestJsLogger\n```\n\n## Testing\n\nEvery external dependency is behind a port — mock the token, not the library:\n\n```ts\nconst module = await Test.createTestingModule({ ... })\n  .overrideProvider(PORTS.PASSWORD_HASHER)\n  .useValue({ hash: jest.fn().mockResolvedValue('hash'), verify: jest.fn().mockResolvedValue(true) })\n  .overrideProvider(PORTS.JWT_SIGNER)\n  .useValue({ init: jest.fn(), sign: jest.fn().mockResolvedValue('token'), verify: jest.fn() })\n  .overrideProvider(PORTS.TOKEN_EXTRACTOR)\n  .useValue({ extract: jest.fn().mockReturnValue('mock-token') })\n  .overrideProvider(PORTS.LOGGER)\n  .useValue({ log: jest.fn(), error: jest.fn() })\n  .compile();\n```\n\nNo real crypto runs in tests. Blazing fast, zero flakiness.\n\n## Using `AuthService` without NestJS\n\n`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.\n\n```ts\n// fastify-main.ts\nimport Fastify from 'fastify';\nimport {\n  AuthService, AuthError, AuthErrorCode,\n  JoseJwtSigner, Argon2PasswordHasher, CryptoTokenHasher, ConsoleLogger,\n} from '@odysseon/auth';\n\nconst authService = new AuthService(\n  {\n    type: 'symmetric',\n    secret: process.env.JWT_SECRET!,\n    accessToken: { expiresIn: '15m' },\n    refreshToken: { expiresIn: '7d' },\n  },\n  new JoseJwtSigner(),\n  new Argon2PasswordHasher(),\n  new CryptoTokenHasher(),\n  new ConsoleLogger(),\n  new MyUserRepository(),         // implements IUserRepository\n  new MyRefreshTokenRepository(), // implements IRefreshTokenRepository\n);\n\n// Call once at startup — validates config and imports JWT keys.\nawait authService.init();\n\nconst fastify = Fastify();\n\n// Map AuthError codes to HTTP responses manually (no AuthExceptionFilter needed)\nconst STATUS: Record\u003cstring, number\u003e = {\n  [AuthErrorCode.INVALID_CREDENTIALS]:      401,\n  [AuthErrorCode.EMAIL_ALREADY_EXISTS]:     409,\n  [AuthErrorCode.ACCESS_TOKEN_INVALID]:     401,\n  [AuthErrorCode.REFRESH_TOKEN_INVALID]:    401,\n  [AuthErrorCode.REFRESH_TOKEN_EXPIRED]:    401,\n  [AuthErrorCode.USER_NOT_FOUND]:           404,\n  [AuthErrorCode.REFRESH_NOT_ENABLED]:      501,\n};\n\nfastify.post('/auth/register', async (req, reply) =\u003e {\n  try {\n    return await authService.register(req.body as any);\n  } catch (err) {\n    if (err instanceof AuthError) return reply.status(STATUS[err.code] ?? 500).send({ error: err.code });\n    throw err;\n  }\n});\n\nfastify.post('/auth/login', async (req, reply) =\u003e {\n  try {\n    return await authService.loginWithCredentials(req.body as any);\n  } catch (err) {\n    if (err instanceof AuthError) return reply.status(STATUS[err.code] ?? 500).send({ error: err.code });\n    throw err;\n  }\n});\n\n// Protect routes with a preHandler hook — no Passport, no guards\nfastify.addHook('preHandler', async (req, reply) =\u003e {\n  const open = ['/auth/register', '/auth/login', '/auth/refresh'];\n  if (open.includes(req.url)) return;\n  const token = (req.headers.authorization ?? '').slice(7);\n  if (!token) return reply.status(401).send({ error: 'Missing token' });\n  try {\n    (req as any).user = await authService.verifyAccessToken(token);\n  } catch {\n    return reply.status(401).send({ error: 'Invalid token' });\n  }\n});\n\nawait fastify.listen({ port: 3000 });\n```\n\nOnly `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.\n\n## Exported API\n\n| Export | Description |\n|---|---|\n| `AuthModule` | Root module — `forRootAsync()` |\n| `AuthModuleAsyncOptions` | NestJS wiring type for `forRootAsync()` |\n| `AuthService` | All use-case methods |\n| `AuthError` | Domain error class thrown by `AuthService` |\n| `AuthErrorCode` | Typed error code constants |\n| `AuthExceptionFilter` | NestJS filter — maps `AuthError` codes to HTTP responses |\n| `JwtAuthGuard` | Protect routes; respects `@Public()` |\n| `GoogleOAuthGuard` | Initiate / handle Google OAuth |\n| `@CurrentUser()` | Extract `RequestUser` from request |\n| `@Public()` | Opt out of global `JwtAuthGuard` |\n| `JoseJwtSigner` | Default JWT adapter (jose) |\n| `Argon2PasswordHasher` | Default password adapter (argon2id) |\n| `CryptoTokenHasher` | Default token adapter (node:crypto) |\n| `BearerTokenExtractor` | Default extractor — `Authorization: Bearer` header |\n| `CookieTokenExtractor` | Extractor — named HTTP cookie |\n| `QueryParamTokenExtractor` | Extractor — URL query parameter |\n| `ConsoleLogger` | Default logger adapter (console.log / console.error, zero deps) |\n| `IJwtSigner` | Port — implement to swap JWT library |\n| `IPasswordHasher` | Port — implement to swap password hasher |\n| `ITokenHasher` | Port — implement to swap token hasher |\n| `ITokenExtractor` | Port — implement to swap token extraction |\n| `ILogger` | Port — implement to swap logger |\n| `IUserRepository` | Port — implement in your infra layer |\n| `IRefreshTokenRepository` | Port — implement in your infra layer |\n| `PORTS` | DI tokens for testing overrides (`AUTH_CAPABILITIES` is internal — not exported) |\n| `parseDurationToSeconds` | Utility — converts `'15m'`/`'7d'` duration strings to seconds |\n| `JwtConfig`, `RefreshTokenConfig` | Config types for JWT setup |\n| `GoogleOAuthConfig` | Config type for Google OAuth setup |\n| `AuthModuleConfig` | Top-level config object shape |\n| `AuthUser`, `BaseUser`, `CredentialsUser`, `GoogleUser` | User entity contracts |\n| `RequestUser` | Shape of `req.user` after JWT validation |\n| `AuthenticatedRequest` | Request object extended with `user: RequestUser` |\n| `AuthResponse`, `TokenPair` | Token response shapes |\n| `JwtPayload` | Claims embedded in every access token |\n| `LoginInput`, `RegistrationInput`, `PasswordChangeInput`, `PasswordSetInput` | Operation input shapes |\n\n## Environment variables\n\n| Variable | Required | Description |\n|---|---|---|\n| `JWT_PRIVATE_KEY` | Asymmetric only | PEM-encoded EC/RSA private key |\n| `JWT_PUBLIC_KEY` | Asymmetric only | PEM-encoded EC/RSA public key |\n| `GOOGLE_CLIENT_ID` | `google` capability | OAuth 2.0 client ID |\n| `GOOGLE_CLIENT_SECRET` | `google` capability | OAuth 2.0 client secret |\n| `GOOGLE_CALLBACK_URL` | `google` capability | OAuth 2.0 callback URL |\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fodysseon%2Fauth","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fodysseon%2Fauth","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fodysseon%2Fauth/lists"}