Skip to content

Discord Auth

The Discord Auth module (discord-auth) provides a configurable, reusable DiscordAuthService that handles the full Discord OAuth2 authentication flow. Both the Admin and User Panel modules use this shared service, eliminating code duplication while keeping their configurations isolated (different cookies, token purposes, and user data granularity).

Before discord-auth, the Admin and User Panel modules each had their own copy of the OAuth2 logic — JWT creation, verification, cookie handling, and Discord API calls — duplicated across AdminLoginCallback, UserPanelLoginCallback, AdminAuthService, and UserPanelAuthService. This meant:

  • Bug fixes had to be applied in two places.
  • Token cross-use was possible because both modules shared the same SECRET_KEY and lacked purpose validation.
  • No logout route existed in Admin.
  • Error handling was inconsistent (Admin silently ignored fetch failures; User Panel validated env vars).

discord-auth solves all of this.

Terminal window
npm install @zumito-team/discord-auth

This module is a dependency of both @zumito-team/admin-module and @zumito-team/user-panel-module — you don’t need to add it to your bundles directly.

discord-auth
├── DiscordAuthService ← Core class: login, callback, JWT, cookies
├── AdminAuthService extends DiscordAuthService
│ config: cookie=admin_token, purpose='admin', fetchFullUser=false
│ + isSuperAdmin()
└── UserPanelAuthService extends DiscordAuthService
config: cookie=panel_token, purpose='panel', fetchFullUser=true

Each consumer extends DiscordAuthService with its own config. The base class handles all the heavy lifting; subclasses only add module-specific logic (like isSuperAdmin() in Admin).

When extending DiscordAuthService, pass a config object to super():

OptionTypeDefaultDescription
cookieNamestringrequiredCookie name (e.g., 'admin_token', 'panel_token').
routePrefixstringrequiredRoute prefix for login/callback URLs (e.g., '/admin', '/panel').
loginRedirectPathstringrequiredWhere to redirect after successful login.
cookieHttpOnlybooleanrequiredWhether the cookie is httpOnly (server-readable only).
fetchFullUserbooleanrequiredStore full Discord user object (true) or just the ID (false).
purposestringrequiredClaim embedded in JWT to prevent cross-module token reuse.
scopestring'identify'Discord OAuth2 scope.
jwtExpirationTimestring'2h'JWT expiration time.
cookieMaxAgenumber30 daysCookie max age in milliseconds.
cookiePathstring'/'Cookie path.
cookieSecurebooleanfalseCookie secure flag.
cookieSameSite'lax' | 'strict' | 'none''lax'Cookie SameSite attribute.
super({
cookieName: 'admin_token',
routePrefix: '/admin',
loginRedirectPath: '/admin',
cookieHttpOnly: false,
fetchFullUser: false,
purpose: 'admin',
});
super({
cookieName: 'panel_token',
routePrefix: '/panel',
loginRedirectPath: '/panel',
cookieHttpOnly: true,
fetchFullUser: true,
purpose: 'panel',
});
VariableRequiredDescription
DISCORD_CLIENT_IDYesDiscord application client ID.
DISCORD_CLIENT_SECRETYesDiscord application client secret.
SECRET_KEYYesSecret key for JWT signing (shared across modules).
HOSTNoFallback host for redirect URIs (uses req.get('host') if not set).
FRONTEND_URLNoOverride for the OAuth2 redirect URI.

isLoginValid(req): Promise<{ isValid: boolean, data?: any }>

Section titled “isLoginValid(req): Promise<{ isValid: boolean, data?: any }>”

Reads the configured cookie from the request. Verifies the JWT signature and expiration using jose. Validates that the purpose claim matches the service config. Returns the decoded JWT payload in data if valid.

Builds the Discord OAuth2 authorize URL using the configured scope and redirect URI.

Handles the complete OAuth2 callback flow:

  1. Validates DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and SECRET_KEY env vars.
  2. Exchanges the authorization code for Discord tokens.
  3. Fetches the user profile from Discord API (/users/@me).
  4. Creates a signed JWT with the purpose claim and user data.
  5. Sets the authentication cookie.
  6. Redirects to loginRedirectPath.

Returns 500 with a clear error message if any step fails.

Clears the authentication cookie.

Sets the authentication cookie with the configured httpOnly, sameSite, maxAge, path, and secure options.

Creates and signs a JWT using HS256 algorithm with SECRET_KEY.

To add Discord OAuth2 authentication to a new module, create an auth service that extends DiscordAuthService:

services/MyAuthService.ts
import { DiscordAuthService } from '@zumito-team/discord-auth';
export class MyAuthService extends DiscordAuthService {
constructor() {
super({
cookieName: 'my_token',
routePrefix: '/myapp',
loginRedirectPath: '/myapp',
cookieHttpOnly: true,
fetchFullUser: false,
purpose: 'myapp',
});
}
}

Then create three thin route files in your routes/ directory:

routes/MyLogin.ts
import { Route, RouteMethod, ServiceContainer } from 'zumito-framework';
import { MyAuthService } from '../services/MyAuthService';
export class MyLogin extends Route {
method = RouteMethod.get;
path = '/myapp/login';
constructor(private auth = ServiceContainer.getService(MyAuthService)) {
super();
}
async execute(req, res) {
if (await this.auth.isLoginValid(req).then(r => r.isValid)) return res.redirect('/myapp');
const host = process.env.HOST ?? req.get('host');
res.redirect(this.auth.getDiscordAuthUrl(host));
}
}
routes/MyLoginCallback.ts
import { Route, RouteMethod, ServiceContainer } from 'zumito-framework';
import { MyAuthService } from '../services/MyAuthService';
export class MyLoginCallback extends Route {
method = RouteMethod.get;
path = '/myapp/login/callback';
constructor(private auth = ServiceContainer.getService(MyAuthService)) {
super();
}
async execute(req, res) {
await this.auth.handleCallback(req, res);
}
}
routes/MyLogout.ts
import { Route, RouteMethod, ServiceContainer } from 'zumito-framework';
import { MyAuthService } from '../services/MyAuthService';
export class MyLogout extends Route {
method = RouteMethod.get;
path = '/myapp/logout';
constructor(private auth = ServiceContainer.getService(MyAuthService)) {
super();
}
async execute(req, res) {
this.auth.clearAuthCookie(res);
res.redirect('/myapp');
}
}
ConcernSolution
Cross-module token reusepurpose claim in JWT — verified on every isLoginValid() call.
Token forgeryHMAC-SHA256 signing with SECRET_KEY. jose.jwtVerify validates the signature.
Token expiryexp claim set to jwtExpirationTime (default 2h). Checked by jose.jwtVerify and manually.
Cookie thefthttpOnly: true prevents client-side JS access (used by User Panel). Admin uses httpOnly: false for dashboard JS requirements.
Missing env varsValidated before any external API call. Returns clear 500 errors instead of cryptic failures.
  • jose — JWT signing and verification (HS256).