@dltech/jwt-auth
v1.0.0
Published
Universal JWT authentication: a client-side Auth singleton for web (httpOnly cookies) and mobile (token persistence, network-outage detection) plus a Passport-free NestJS server module (JwtService via jose, BaseAuthGuard, route decorators)
Downloads
431
Maintainers
Readme
@dltech/jwt-auth
Universal JWT authentication package — generic client singleton and NestJS server module.
This package is intentionally app-agnostic. It handles token lifecycle, session checks, and API communication. Data shapes (DTOs, request bodies) and auth state mapping belong in the consuming application, not here.
Exports
| Path | Use case |
|---------------------------|-------------------------------------------------------|
| @dltech/jwt-auth | Client-side Auth singleton (Next.js, React Native) |
| @dltech/jwt-auth/server | NestJS JwtModule, BaseAuthGuard, decorators |
No shared DTOs.
LoginDto,RegisterDto, and any other request shapes are your app's responsibility. Define them wherever makes sense for your project (e.g.src/lib/dto/auth.dto.ts).
Client setup
1. Configure the singleton
Auth is generic over your session shape — pass whatever your /auth/session endpoint returns and map it to AuthState in sessionToAuthState. The package never assumes what a "profile" or "user" looks like.
import { Auth } from '@dltech/jwt-auth';
// Define your session shape (matches what GET /auth/session returns)
type MySession = { id: string; profile: { id: string } | null };
export const auth = new Auth<MySession>();
auth.configure({
apiBaseUrl: process.env.NEXT_PUBLIC_API_URL,
sessionToAuthState: (session) => ({
authenticated: true,
authProviderId: session.id,
profileId: session.profile?.id ?? null,
}),
});2. Initialize on app boot
Call initialize() once — it hits GET /auth/session and sets the initial auth state.
// e.g. in providers.tsx or _app.tsx
useEffect(() => {
auth.initialize();
}, []);3. Guard layouts
useEffect(() => {
return auth.onAuthStateChanged((state) => {
if (state.backendUnreachable) {
// No response from the server — show a "Service Unavailable" screen.
// Do NOT redirect to login; we can't confirm the session state.
return;
}
if (!state.authenticated) router.replace('/auth/login');
else if (!state.profileId) router.replace('/onboarding');
});
}, []);profileId being null means the identity exists but onboarding isn't complete. The server signals this by omitting userId from the access token — no special token type needed.
backendUnreachable — network failure vs auth failure
state.backendUnreachable is set to true whenever an auth call (session check, token refresh, sign-in, register) receives no HTTP response — connection refused, DNS failure, timeout. It is distinct from a 401, which means the backend responded and rejected the session.
When backendUnreachable is true:
authenticatedis not changed — the previous value is preserved.- Listeners fire so the UI can react immediately.
- The flag clears automatically to
falsethe next time any auth call succeeds.
The flag is set from every internal network path: checkSession, mobile token refresh (refreshTokensFromStorage, _doRefresh), web refresh (_doRefreshWeb), signIn, and register. This means the screen triggers correctly whether the outage is hit during cold launch, a background refresh, or a form submission.
Recovery pattern — call auth.checkSession() (web) or auth.initialize() (mobile) from a retry button or a background poll. On success the flag clears and onAuthStateChanged fires again with the real session state, returning the user to the normal flow without a page reload.
4. Sign in / register / sign out
await auth.signIn(email, password);
await auth.register(email, password);
auth.signOut();signIn and register accept plain email/password strings. Define your own LoginDto / RegisterDto in your app if you need class-validator-backed form validation.
5. Attach to Axios (automatic 401 retry)
auth.attachInterceptors(axiosInstance);Server setup (NestJS)
@dltech/jwt-auth/server ships JwtModule, BaseAuthGuard, and route decorators. No Passport dependency — token signing and verification is handled internally using jose.
Install peer deps
pnpm add @nestjs/common @nestjs/core cookie-parser
pnpm add -D @types/cookie-parser1. Register JwtModule
// app.module.ts
import { JwtModule } from '@dltech/jwt-auth/server';
@Module({
imports: [
JwtModule.forRootAsync({
isGlobal: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
accessSecret: config.get('JWT_ACCESS_SECRET'),
refreshSecret: config.get('JWT_REFRESH_SECRET'),
issuer: config.get('BACKEND_HOST'),
accessExpiresIn: '15m', // optional, default '15m'
refreshExpiresIn: '7d', // optional, default '7d'
}),
}),
],
})
export class AppModule {}2. Implement the guard
Extend BaseAuthGuard and implement findUser(sub). The sub is the subject you pass to signAccessToken — typically your identity provider's primary key.
import { Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { BaseAuthGuard, JwtService } from '@dltech/jwt-auth/server';
@Injectable()
export class AuthGuard extends BaseAuthGuard {
constructor(reflector: Reflector, jwtService: JwtService, private users: UsersRepo) {
super(reflector, jwtService);
}
async findUser(sub: string) {
return this.users.findOne({ where: { id: sub } });
}
}Register globally so every route is protected by default:
import { APP_GUARD } from '@nestjs/core';
providers: [{ provide: APP_GUARD, useClass: AuthGuard }]3. Use the decorators
import { AuthOnly, CurrentUser, Public, Roles } from '@dltech/jwt-auth/server';
export class AuthController {
// Skip auth entirely — login, register, public pages
@Public()
@Post('login')
login(@Body() body: { email: string; password: string }) { ... }
// Valid token required, but no profile yet (mid-onboarding)
@AuthOnly()
@Post('complete-registration')
completeRegistration(@CurrentUser() user: User | null) { ... }
// Fully authenticated — user entity must be present
@Get('me')
getMe(@CurrentUser() user: User) { ... }
// Authenticated + role check
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) { ... }
}4. Sign tokens in your AuthService
JwtService is injectable anywhere once the module is registered globally.
import { JwtService } from '@dltech/jwt-auth/server';
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
/**
* Full session: pass sub + any extra claims (e.g. userId, role).
* Onboarding: pass only sub — the absence of userId signals no profile yet.
* The client reads profileId from AuthState; a null value means onboarding.
*/
async issueTokens(identityId: string, user: User | null, res: Response) {
const extra = user ? { userId: user.id, role: user.role } : {};
const access = await this.jwtService.signAccessToken(identityId, extra);
const refresh = await this.jwtService.signRefreshToken(identityId);
res.cookie('access_token', access, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
res.cookie('refresh_token', refresh, { httpOnly: true, secure: true, sameSite: 'lax', path: '/auth/refresh' });
}
}5. What you write yourself (project-specific)
| Piece | Why it's yours |
|------------------|-------------------------------------------------------------------|
| LoginDto / RegisterDto | Request shapes vary per project; use class-validator as needed |
| LocalStrategy | Needs your user repo + password hashing lib (argon2, bcrypt) |
| AuthController | Routes, cookie names, and response shapes vary per project |
| AuthModule | Wires your AuthService, LocalStrategy, and AuthController |
Scripts
pnpm build— compile CJS + ESM todist/pnpm dev— watch mode
