haunt-oauth2
v1.0.1
Published
Framework-agnostic OAuth2/OIDC client for haunt.gg login (Authorization Code + PKCE)
Maintainers
Readme
haunt-oauth2
Framework-agnostic OAuth2 / OIDC client for haunt.gg login. Works in any Node.js backend — Next.js, NestJS, Express, plain Node — since it has zero framework dependencies and just wraps fetch.
Implements the Authorization Code flow with mandatory PKCE (S256), token refresh, and revocation, exactly as haunt.gg's API requires.
⚠️ This runs server-side only — it needs your
client_secret, which must never reach the browser.
Install
npm install haunt-oauth2Quick start
import { HauntAuth } from 'haunt-oauth2';
const auth = new HauntAuth({
clientId: process.env.HAUNT_CLIENT_ID!,
clientSecret: process.env.HAUNT_CLIENT_SECRET!,
redirectUri: process.env.HAUNT_REDIRECT_URI!,
scope: 'openid identify offline_access', // optional, defaults to "openid identify"
});1. Redirect the user to log in
const pkce = auth.generatePKCE();
// Store pkce.verifier and pkce.state (e.g. in signed cookies) —
// you need them again in step 2.
const url = auth.getAuthorizationUrl(pkce);
// redirect the browser to `url`2. Handle the callback
// code, state, iss come from the query string haunt.gg redirected back with
// savedState/verifier come from the cookies you stored in step 1
auth.verifyCallback({ state, savedState, iss }); // throws on mismatch
const tokens = await auth.exchangeCode(code, verifier);
// tokens.access_token, tokens.refresh_token, tokens.expires_at, ...
const user = await auth.getUserInfo(tokens.access_token);
// user.username, user.email, user.avatar_url, ...3. Refresh an expired access token
if (auth.isExpiringSoon(storedExpiresAt)) {
const tokens = await auth.refreshToken(storedRefreshToken);
// tokens.refresh_token is rotated — always persist the new one
}4. Log out / revoke
await auth.revokeToken(accessToken, 'access_token');
await auth.revokeToken(refreshToken, 'refresh_token');API
| Method | Description |
|---|---|
| generatePKCE() | Returns { verifier, challenge, state } |
| getAuthorizationUrl(pkce, options?) | Builds the haunt.gg login URL |
| verifyCallback({ state, savedState, iss }) | Throws if state/issuer don't match |
| exchangeCode(code, verifier) | Exchanges an auth code for tokens |
| refreshToken(refreshToken, scope?) | Rotates the refresh token, returns new tokens |
| getUserInfo(accessToken) | Fetches OIDC user claims |
| revokeToken(token, tokenTypeHint?) | Revokes an access or refresh token |
| isExpiringSoon(expiresAt, bufferSeconds?) | true if the token expires within the buffer (default 60s) |
Scopes
openid and identify are enabled by default for any registered app. offline_access gives you a refresh token. email and connections require manual approval — request them via a support ticket at haunt.gg/dashboard/support/new.
Notes
- All requests include a browser-like
User-Agentheader — haunt.gg's Cloudflare setup can otherwise intercept server-to-server requests with a challenge page. - This package does not touch cookies, sessions, or HTTP routing — that's on you, since it differs by framework. See the example below for Next.js.
Example: Next.js Route Handlers
The package gives you the building blocks — you still write three small route handlers to wire up the full flow: login → callback → fetch user data.
1. /api/auth — start the login
Redirects the browser to haunt.gg and stores the PKCE verifier + state in cookies (you'll need them back in step 2).
// app/api/auth/route.ts
import { NextResponse } from 'next/server';
import { HauntAuth } from 'haunt-oauth2';
const auth = new HauntAuth({
clientId: process.env.HAUNT_CLIENT_ID!,
clientSecret: process.env.HAUNT_CLIENT_SECRET!,
redirectUri: process.env.HAUNT_REDIRECT_URI!,
scope: 'openid identify offline_access',
});
export async function GET() {
const pkce = auth.generatePKCE();
const url = auth.getAuthorizationUrl(pkce);
const res = NextResponse.redirect(url);
res.cookies.set('oauth_verifier', pkce.verifier, { httpOnly: true, maxAge: 600, path: '/' });
res.cookies.set('oauth_state', pkce.state, { httpOnly: true, maxAge: 600, path: '/' });
return res;
}2. /api/auth/callback — exchange the code for tokens
haunt.gg redirects here after the user logs in. This is where the actual token exchange happens — generatePKCE() / getAuthorizationUrl() alone don't log anyone in, this step does.
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { HauntAuth } from 'haunt-oauth2';
const auth = new HauntAuth({
clientId: process.env.HAUNT_CLIENT_ID!,
clientSecret: process.env.HAUNT_CLIENT_SECRET!,
redirectUri: process.env.HAUNT_REDIRECT_URI!,
});
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get('code');
const state = req.nextUrl.searchParams.get('state');
const iss = req.nextUrl.searchParams.get('iss');
const savedState = req.cookies.get('oauth_state')?.value ?? null;
const verifier = req.cookies.get('oauth_verifier')?.value;
try {
auth.verifyCallback({ state, savedState, iss });
} catch {
return NextResponse.json({ error: 'invalid_state_or_issuer' }, { status: 400 });
}
const tokens = await auth.exchangeCode(code as string, verifier as string);
const res = NextResponse.redirect(new URL('/dashboard', req.url));
res.cookies.delete('oauth_verifier');
res.cookies.delete('oauth_state');
res.cookies.set('access_token', tokens.access_token, {
httpOnly: true,
maxAge: tokens.expires_in,
path: '/',
});
res.cookies.set('expires_at', tokens.expires_at.toString(), {
httpOnly: true,
maxAge: tokens.expires_in,
path: '/',
});
if (tokens.refresh_token) {
res.cookies.set('refresh_token', tokens.refresh_token, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
}
return res;
}3. /api/user/me — fetch the logged-in user's data
Called by your dashboard/frontend after login. Also refreshes the access token automatically if it's about to expire, so the caller never sees a stale token.
// app/api/user/me/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { HauntAuth } from 'haunt-oauth2';
const auth = new HauntAuth({
clientId: process.env.HAUNT_CLIENT_ID!,
clientSecret: process.env.HAUNT_CLIENT_SECRET!,
redirectUri: process.env.HAUNT_REDIRECT_URI!,
});
export async function GET(req: NextRequest) {
let accessToken = req.cookies.get('access_token')?.value;
const refreshToken = req.cookies.get('refresh_token')?.value;
const expiresAt = Number(req.cookies.get('expires_at')?.value);
if (!accessToken) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const res = NextResponse.next();
if (auth.isExpiringSoon(expiresAt)) {
if (!refreshToken) {
return NextResponse.json({ error: 'session_expired' }, { status: 401 });
}
try {
const tokens = await auth.refreshToken(refreshToken);
accessToken = tokens.access_token;
res.cookies.set('access_token', tokens.access_token, {
httpOnly: true,
maxAge: tokens.expires_in,
path: '/',
});
res.cookies.set('expires_at', tokens.expires_at.toString(), {
httpOnly: true,
maxAge: tokens.expires_in,
path: '/',
});
if (tokens.refresh_token) {
res.cookies.set('refresh_token', tokens.refresh_token, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
}
} catch {
const failRes = NextResponse.json({ error: 'session_expired' }, { status: 401 });
failRes.cookies.delete('access_token');
failRes.cookies.delete('refresh_token');
failRes.cookies.delete('expires_at');
return failRes;
}
}
const user = await auth.getUserInfo(accessToken as string);
return NextResponse.json(user);
}How the three files connect
Browser opens /api/auth
↓ (redirect with PKCE challenge + state)
haunt.gg login page
↓ (redirect back with code + state)
/api/auth/callback
↓ (exchanges code for tokens, saves them in cookies)
↓ (redirect)
/dashboard → fetches /api/user/me → gets the user's profileCredits
Built against the haunt.gg OAuth2/OIDC API.
License
MIT
