dewabuanam-oauth
v0.1.5
Published
OAuth 2.0 client library for Dewabuanam authentication with PKCE support.
Readme
dewabuanam-oauth
OAuth 2.0 client library for Dewabuanam authentication with PKCE support.
Installation
npm install dewabuanam-oauthFeatures
- OAuth 2.0 Authorization Code flow with PKCE
- Client-side helpers to generate PKCE + redirect to authorize
- Server-side helpers to exchange code for tokens, verify JWTs, and fetch userinfo
- TypeScript support
Environment Variables
Required (client + callback):
NEXT_PUBLIC_OAUTH_CLIENT_ID=71c43497-63db-4c56-b6be-f8645a73cc1e
NEXT_PUBLIC_OAUTH_REDIRECT_URI=https://life.dewabuanam.com/api/oauth/callbackOptional (server overrides):
# Local development (override endpoints)
OAUTH_TOKEN_ENDPOINT=http://localhost:3000/api/oauth/token
OAUTH_ISSUER=http://localhost:3000
OAUTH_JWKS_URL=http://localhost:3000/.well-known/jwks.json
OAUTH_USERINFO_ENDPOINT=http://localhost:3000/api/oauth/userinfo
# Client-side (optional helper for your app code)
NEXT_PUBLIC_OAUTH_AUTHORIZE_ENDPOINT=http://localhost:3000/api/oauth/authorizeProvider Endpoints (Local)
- Authorize (GET):
http://localhost:3000/api/oauth/authorize - Token (POST):
http://localhost:3000/api/oauth/token - Userinfo (GET):
http://localhost:3000/api/oauth/userinfo - JWKS (GET):
http://localhost:3000/.well-known/jwks.json
Usage
Client-Side (PKCE + Redirect)
For more control, use the client utilities directly:
import { createPkce, redirectToAuthorize } from "dewabuanam-oauth/client";
async function handleLogin() {
// Generate PKCE challenge
const pkce = await createPkce();
// Store verifier somewhere the callback route can read it (cookie/session/etc).
// Note: SameSite=Lax is required so the cookie is sent on the OAuth provider -> callback redirect.
const isSecureContext = typeof window !== "undefined" && window.location.protocol === "https:";
const secureAttr = isSecureContext ? "; Secure" : "";
document.cookie = `pkce_verifier=${encodeURIComponent(pkce.verifier)}; Path=/; Max-Age=600; SameSite=Lax${secureAttr}`;
// Redirect to authorization endpoint
redirectToAuthorize({
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI!,
codeChallenge: pkce.challenge,
authorizeEndpoint: process.env.NEXT_PUBLIC_OAUTH_AUTHORIZE_ENDPOINT, // optional override
});
}Tip: store the PKCE verifier somewhere the callback route can read it (cookie, session, etc.).
Server-Side (Token Exchange)
Handle the callback and exchange the authorization code for tokens:
import { exchangeCodeForToken } from "dewabuanam-oauth/server";
export async function handleCallback(code: string, codeVerifier: string) {
const tokens = await exchangeCodeForToken({
code,
codeVerifier,
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI!,
// Optional override; otherwise uses OAUTH_TOKEN_ENDPOINT or the default cloud endpoint.
tokenEndpoint: process.env.OAUTH_TOKEN_ENDPOINT,
});
// tokens contains: { access_token, id_token?, token_type?, expires_in?, refresh_token? }
return tokens;
}Server-Side (Refresh Token)
When access tokens expire, you can exchange the refresh token for a new access token.
Important: refresh tokens are rotated — each refresh returns a new refresh_token, and the old one becomes invalid.
import { refreshAccessToken } from "dewabuanam-oauth/server";
export async function handleRefresh(refreshToken: string) {
const tokens = await refreshAccessToken({
refreshToken,
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
tokenEndpoint: process.env.OAUTH_TOKEN_ENDPOINT,
});
// If tokens.refresh_token is present, replace your stored refresh token with it.
return tokens;
}Token Verification
Verify and decode JWT tokens:
import { verifyToken } from "dewabuanam-oauth/server";
export async function getUserFromToken(token: string) {
try {
const payload = await verifyToken(token);
console.log("User ID:", payload.sub);
console.log("Email:", payload.email);
return payload;
} catch (error) {
console.error("Invalid token:", error);
throw error;
}
}API Reference
Client API
createPkce()
Generates a PKCE code verifier and challenge.
Returns:
{
verifier: string;
challenge: string;
method: "S256";
}redirectToAuthorize(options)
Redirects the user to the Dewabuanam authorization page.
Parameters:
clientId: string- Your OAuth client IDredirectUri: string- Callback URL after authorizationcodeChallenge: string- PKCE challenge fromcreatePkce()authorizeEndpoint?: string- Override authorize endpoint (optional)state?: string- Provide your ownstate(optional)
Server API
exchangeCodeForToken(options)
Exchanges an authorization code for access and ID tokens.
Parameters:
code: string- Authorization code from callbackcodeVerifier: string- PKCE verifier stored during authorizationclientId: string- Your OAuth client IDredirectUri: string- Must match the redirect URI used in authorizationtokenEndpoint?: string- Custom token endpoint (optional)
Returns:
{
access_token: string;
id_token?: string;
token_type?: string;
expires_in?: number;
refresh_token?: string;
}refreshAccessToken(options)
Exchanges a refresh token for a new access token (refresh token rotation supported).
Parameters:
refreshToken: string- Refresh token you previously storedclientId: string- Your OAuth client IDtokenEndpoint?: string- Custom token endpoint (optional)
verifyToken(token)
Verifies and decodes a JWT token.
Parameters:
token: string- The JWT token to verify
Returns: JWT payload containing user information
fetchUserInfo(accessToken)
Fetches profile information from the Dewabuanam userinfo endpoint.
Options:
endpoint?: string- Custom userinfo endpoint (optional)
OAuth Flow (Authorization Code + PKCE)
- Generate a PKCE
code_verifierandcode_challenge(S256) in your web app. - Redirect the user to the authorize endpoint.
- Handle the callback on your redirect URL and read the returned
code. - Exchange the
codefor tokens via the token endpoint. - Use the access token as a Bearer token when calling userinfo.
- When the access token expires, exchange the refresh token for a new access token (and rotated refresh token).
Authorize URL example:
http://localhost:3000/api/oauth/authorize?response_type=code&client_id=71c43497-63db-4c56-b6be-f8645a73cc1e&redirect_uri=https%3A%2F%2Flife.dewabuanam.com%2Fapi%2Foauth%2Fcallback&code_challenge=...&code_challenge_method=S256&state=...
Token request example (x-www-form-urlencoded):
POST http://localhost:3000/api/oauth/token
Body:
grant_type=authorization_code&client_id=71c43497-63db-4c56-b6be-f8645a73cc1e&redirect_uri=https%3A%2F%2Flife.dewabuanam.com%2Fapi%2Foauth%2Fcallback&code=...&code_verifier=...
Userinfo example:
GET http://localhost:3000/api/oauth/userinfo with Authorization: Bearer <access_token>
Refresh token request example (x-www-form-urlencoded):
POST http://localhost:3000/api/oauth/token
Body:
grant_type=refresh_token&client_id=71c43497-63db-4c56-b6be-f8645a73cc1e&refresh_token=...
Signature validation (JWKS):
Access tokens are JWTs signed with RS256. Validate the signature using the public keys from the JWKS endpoint.
GET http://localhost:3000/.well-known/jwks.json
Required Validation (Provider / Server)
Authorize (GET):
client_idmust existredirect_urimust exactly match the registered redirect URL- PKCE
code_challenge+code_challenge_method=S256
Token (POST, authorization code):
codemust be valid, not expired, not usedclient_idmust match the authorization code- PKCE
code_verifiermust match storedcode_challenge
Token (POST, refresh):
grant_type=refresh_tokenclient_idmust existrefresh_tokenmust be valid, not expired, not revoked- Refresh tokens are rotated: each refresh returns a new
refresh_token, and the old one becomes invalid
Full Example (Next.js App Router)
1. Login Page (app/login/page.tsx)
"use client";
import { createPkce, redirectToAuthorize } from "dewabuanam-oauth/client";
export default function LoginPage() {
return (
<button
onClick={async () => {
const clientId = process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID;
const redirectUri = process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI;
if (!clientId || !redirectUri) {
console.error("Missing NEXT_PUBLIC_OAUTH_CLIENT_ID or NEXT_PUBLIC_OAUTH_REDIRECT_URI");
return;
}
const pkce = await createPkce();
// SameSite=Lax is required so the cookie is sent on the OAuth provider -> callback redirect.
const isSecureContext = typeof window !== "undefined" && window.location.protocol === "https:";
const secureAttr = isSecureContext ? "; Secure" : "";
document.cookie = `pkce_verifier=${encodeURIComponent(pkce.verifier)}; Path=/; Max-Age=600; SameSite=Lax${secureAttr}`;
redirectToAuthorize({
clientId,
redirectUri,
codeChallenge: pkce.challenge,
authorizeEndpoint: process.env.NEXT_PUBLIC_OAUTH_AUTHORIZE_ENDPOINT, // optional override
});
}}
>
Sign in
</button>
);
}2. Callback Handler (app/auth/callback/route.ts)
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { exchangeCodeForToken, fetchUserInfo, verifyToken } from "dewabuanam-oauth/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get("code");
const cookieStore = await cookies();
const rawCodeVerifier = cookieStore.get("pkce_verifier")?.value;
const codeVerifier = (() => {
if (!rawCodeVerifier) return undefined;
try {
return decodeURIComponent(rawCodeVerifier);
} catch {
return rawCodeVerifier;
}
})();
if (!code || !codeVerifier) {
return NextResponse.redirect("/login?error=missing_params");
}
try {
const tokens = await exchangeCodeForToken({
code,
codeVerifier,
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI!,
tokenEndpoint: process.env.OAUTH_TOKEN_ENDPOINT, // optional override
});
await verifyToken(tokens.id_token ?? tokens.access_token, {
issuer: process.env.OAUTH_ISSUER,
jwksUrl: process.env.OAUTH_JWKS_URL,
});
const userInfo = await fetchUserInfo(tokens.access_token, {
endpoint: process.env.OAUTH_USERINFO_ENDPOINT,
});
console.info("userinfo", userInfo);
// Store tokens securely (e.g., in HTTP-only cookies or session)
cookieStore.delete("pkce_verifier");
cookieStore.set("access_token", tokens.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
if (typeof tokens.refresh_token === "string" && tokens.refresh_token.length > 0) {
cookieStore.set("refresh_token", tokens.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
}
return NextResponse.redirect(new URL("/", request.url));
} catch (error) {
console.error("OAuth error:", error);
return NextResponse.redirect("/login?error=auth_failed");
}
}3. Refresh Route (app/api/oauth/refresh/route.ts)
This endpoint uses your refresh token cookie to refresh the session. If the provider returns a new refresh_token, it overwrites the old cookie (rotation).
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { refreshAccessToken } from "dewabuanam-oauth/server";
function computeSessionExpiresAtMs(tokens: { expires_in?: number }) {
if (typeof tokens.expires_in === "number" && Number.isFinite(tokens.expires_in) && tokens.expires_in > 0) {
return Date.now() + tokens.expires_in * 1000;
}
return undefined;
}
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
const refreshToken = cookieStore.get("refresh_token")?.value;
if (!refreshToken) {
return NextResponse.json({ ok: false, error: "missing_refresh_token" }, { status: 401, headers: { "Cache-Control": "no-store" } });
}
try {
const tokens = await refreshAccessToken({
refreshToken,
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
tokenEndpoint: process.env.OAUTH_TOKEN_ENDPOINT,
});
const secure = request.nextUrl.protocol === "https:" || process.env.NODE_ENV === "production";
const sessionExpiresAtMs = computeSessionExpiresAtMs(tokens);
const response = NextResponse.json(
{
ok: true,
sessionExpiresAt: sessionExpiresAtMs ?? null,
sessionExpiresAtIso: sessionExpiresAtMs ? new Date(sessionExpiresAtMs).toISOString() : null,
},
{ headers: { "Cache-Control": "no-store" } },
);
const accessTokenCookie: Parameters<typeof response.cookies.set>[2] = {
httpOnly: true,
secure,
sameSite: "lax",
path: "/",
};
if (sessionExpiresAtMs) {
accessTokenCookie.expires = new Date(sessionExpiresAtMs);
}
response.cookies.set("access_token", tokens.access_token, accessTokenCookie);
if (typeof tokens.refresh_token === "string" && tokens.refresh_token.length > 0) {
response.cookies.set("refresh_token", tokens.refresh_token, {
httpOnly: true,
secure,
sameSite: "lax",
path: "/",
});
}
if (sessionExpiresAtMs) {
response.cookies.set("session_expires_at", String(sessionExpiresAtMs), {
httpOnly: false,
secure,
sameSite: "lax",
path: "/",
expires: new Date(sessionExpiresAtMs),
});
}
return response;
} catch (error) {
console.error("OAuth refresh error:", error);
return NextResponse.json({ ok: false, error: "refresh_failed" }, { status: 500, headers: { "Cache-Control": "no-store" } });
}
}4. Client Session Refresher (example)
Add a small client component that watches session_expires_at and triggers /api/oauth/refresh shortly before expiry.
"use client";
import { useEffect, useRef } from "react";
const REFRESH_EARLY_MS = 5 * 60 * 1000;
const FALLBACK_RETRY_MS = 30 * 1000;
function getCookieValue(name: string): string | null {
if (typeof document === "undefined") return null;
const cookies = document.cookie ? document.cookie.split(";") : [];
for (const cookie of cookies) {
const [rawKey, ...rest] = cookie.trim().split("=");
if (rawKey === name) return decodeURIComponent(rest.join("="));
}
return null;
}
export function OAuthSessionRefresher() {
const timerRef = useRef<number | null>(null);
useEffect(() => {
const clearTimer = () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
const schedule = () => {
clearTimer();
const raw = getCookieValue("session_expires_at");
if (!raw) return;
const expiresAtMs = Number(raw);
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) return;
const now = Date.now();
const computedDelayMs = expiresAtMs - now - REFRESH_EARLY_MS;
const delayMs = computedDelayMs > 0 ? computedDelayMs : 10_000;
const run = async () => {
try {
const res = await fetch("/api/oauth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok) {
timerRef.current = window.setTimeout(schedule, FALLBACK_RETRY_MS);
return;
}
// Cookies get updated by the response; just reschedule.
schedule();
} catch {
timerRef.current = window.setTimeout(schedule, FALLBACK_RETRY_MS);
}
};
if (delayMs <= 0) {
void run();
return;
}
// setTimeout max is ~24.8 days; clamp to be safe.
const maxDelay = 2_000_000_000;
timerRef.current = window.setTimeout(run, Math.min(delayMs, maxDelay));
};
const onVisibility = () => {
if (document.visibilityState === "visible") schedule();
};
schedule();
document.addEventListener("visibilitychange", onVisibility);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
clearTimer();
};
}, []);
return null;
}Security Considerations
- Always use HTTPS in production
- Store the PKCE verifier securely (HTTP-only cookies recommended)
- Validate the
stateparameter to prevent CSRF attacks - Keep your client ID confidential
- Use HTTP-only cookies for storing tokens
- Implement proper token refresh logic
License
See LICENSE file for details.
Support
For issues and questions, please visit the GitHub repository.
