@digitwhale_innovations/digitwhale-auth
v0.10.0
Published
Digitwhale Auth SDK - OAuth 2.0 Authorization Code Flow with PKCE
Maintainers
Readme
Digitwhale Auth — TypeScript SDK
OAuth 2.0 Authorization Code Flow with PKCE for TypeScript/JavaScript applications.
Installation
npm install @digitwhale_innovations/digitwhale-authQuick Start
import { DigitwhaleAuth, MemoryTokenStore } from "@digitwhale_innovations/digitwhale-auth";
const auth = new DigitwhaleAuth(new MemoryTokenStore());
const { url, pkce, state } = await auth.buildAuthorizeUrl(
"your_client_id",
"yourapp://callback",
"read write"
);
console.log("Open this URL:", url);
const tokens = await auth.exchangeCode(
"authorization_code_from_callback",
"yourapp://callback",
"your_client_id",
pkce
);
console.log("Logged in:", tokens.user);Import Paths
| What you need | Import from |
|---|---|
| Core SDK (any runtime) | "@digitwhale_innovations/digitwhale-auth" |
| React components / hooks | "@digitwhale_innovations/digitwhale-auth/react" |
The @digitwhale_innovations/digitwhale-auth entry point has zero React dependency — safe for Node
backends, React Native without Expo, Deno, and browsers without React.
React components live in @digitwhale_innovations/digitwhale-auth/react and require react >= 17 as a
peer dependency.
Extending Types
All types (UserInfo, StoredTokens, TokenResponse) include an index signature
so you can add custom fields from your own system:
import type { UserInfo } from "@digitwhale_innovations/digitwhale-auth";
// Extend with your own fields
interface MyUser extends UserInfo {
department?: string;
employee_id?: number;
roles?: string[];
}
// The server returns extra fields — they're captured automatically
const user: MyUser = await auth.user();
console.log(user.department); // typed as string | undefined
console.log(user.roles); // typed as string[] | undefinedOr access custom fields directly without extending:
const user = await auth.user();
const department = user.department as string | undefined;Scenarios
1. One-Click Button (Recommended)
A React component that handles everything — popup, redirect, code exchange:
import { DigitwhaleAuth, LocalStorageTokenStore } from "@digitwhale_innovations/digitwhale-auth";
import { DigitwhaleAuthButton } from "@digitwhale_innovations/digitwhale-auth/react";
const auth = new DigitwhaleAuth(new LocalStorageTokenStore());
<DigitwhaleAuthButton
auth={auth}
clientId="my_web_app"
redirectUri="https://myapp.com/auth/callback"
scope="read write"
onSuccess={(tokens) => {
console.log("Logged in:", tokens.user?.email);
router.push("/dashboard");
}}
onError={(error) => {
toast.error(error.message);
}}
/>2. React Hook
Full control with useDigitwhaleAuth:
import { DigitwhaleAuth, LocalStorageTokenStore } from "@digitwhale_innovations/digitwhale-auth";
import { useDigitwhaleAuth } from "@digitwhale_innovations/digitwhale-auth/react";
const auth = new DigitwhaleAuth(new LocalStorageTokenStore());
function LoginPage() {
const { login, logout, user, isAuthenticated, isLoading, error } =
useDigitwhaleAuth(auth);
if (isAuthenticated) {
return (
<div>
<p>Welcome, {user?.email}</p>
<button onClick={logout}>Sign out</button>
</div>
);
}
return (
<div>
{error && <p className="error">{error}</p>}
<button
onClick={() => login("my_app", "https://myapp.com/auth/callback")}
disabled={isLoading}
>
{isLoading ? "Signing in..." : "Sign in with Digitwhale"}
</button>
</div>
);
}3. Web — Popup Flow
Opens a popup window for the auth flow, closes on success:
const { url, pkce, state } = await auth.buildAuthorizeUrl(
"my_app",
"https://myapp.com/auth/callback"
);
const popup = window.open(url, "auth", "width=600,height=700,popup=yes");
// Poll for redirect
const interval = setInterval(async () => {
if (popup?.closed) {
clearInterval(interval);
await auth.restoreSession();
return;
}
try {
if (popup.location.href.startsWith("https://myapp.com/auth/callback")) {
const params = new URLSearchParams(popup.location.search);
const code = params.get("code");
if (code) {
await auth.exchangeCode(code, redirectUri, clientId, pkce);
popup.close();
clearInterval(interval);
}
}
} catch {
// Cross-origin
}
}, 500);4. Web — Redirect Flow
Redirects the browser to the auth page:
const { url, pkce, state } = await auth.buildAuthorizeUrl(
"my_app",
"https://myapp.com/auth/callback"
);
// Store pkce and state for later
sessionStorage.setItem("pkce", JSON.stringify(pkce));
sessionStorage.setItem("state", state);
// Redirect
window.location.href = url;// On callback page (/auth/callback)
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
const storedState = sessionStorage.getItem("state");
if (state !== storedState) {
throw new Error("Invalid state");
}
const pkce = JSON.parse(sessionStorage.getItem("pkce")!);
await auth.exchangeCode(code!, redirectUri, clientId, pkce);
router.push("/dashboard");5. React Native / Expo
import * as Linking from "expo-linking";
const { url, pkce, state } = await auth.buildAuthorizeUrl(
"my_app",
"myapp://auth/callback"
);
// Open in browser
Linking.openURL(url);
// Listen for deep link
Linking.addEventListener("url", async ({ url }) => {
const uri = new URL(url);
const code = uri.searchParams.get("code");
const returnedState = uri.searchParams.get("state");
if (returnedState !== state) {
console.error("CSRF detected");
return;
}
if (code) {
const tokens = await auth.exchangeCode(
code,
"myapp://auth/callback",
"my_app",
pkce
);
console.log("Logged in:", tokens.user?.email);
}
});6. Express Backend
app.get("/auth/login", async (req, res) => {
const { url, pkce, state } = await auth.buildAuthorizeUrl(
"server_app",
"https://myapp.com/auth/callback"
);
req.session.pkce = pkce;
req.session.state = state;
res.redirect(url);
});
app.get("/auth/callback", async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.state) return res.status(403).send("Invalid state");
await auth.exchangeCode(
code as string,
"https://myapp.com/auth/callback",
"server_app",
req.session.pkce
);
res.redirect("/dashboard");
});7. Error Handling
import { AuthError, ErrorType } from "@digitwhale_innovations/digitwhale-auth";
try {
await auth.exchangeCode(code, redirectUri, clientId, pkce);
} catch (err) {
if (err instanceof AuthError) {
switch (err.type) {
case ErrorType.CodeExpired:
showNotification("Code expired, please try again");
break;
case ErrorType.PkceFailed:
showNotification("Security verification failed");
break;
case ErrorType.MfaRequired:
showNotification("MFA code required — re-initiate with MFA");
break;
case ErrorType.RefreshTokenRevoked:
showNotification("Session compromised, logging out");
await auth.signOut();
break;
case ErrorType.TokenFamilyRevoked:
showNotification("All tokens revoked — re-authenticate");
await auth.signOut();
break;
default:
if (err.retryable) {
showNotification("Network error, retrying...");
} else {
showNotification(`Auth error: ${err.message}`);
}
}
}
}8. Confidential Clients (server-side)
// For confidential clients, pass client_secret in the exchangeCode call
const tokens = await auth.exchangeCode(
code,
"https://myapp.com/auth/callback",
"server_app",
pkce,
"sec_xxxxx" // clientSecret
);9. Fetch User Info
const user = await auth.user();
console.log(`Logged in as: ${user.first_name} ${user.last_name}`);10. Update User Profile
const updated = await auth.updateUser({
first_name: "Jane",
last_name: "Doe",
phone_number: "+1234567890",
date_of_birth: "1990-01-01",
nationality: "US",
});11. Change Password
await auth.changePassword("current_pass", "new_pass", "new_pass");
// All existing tokens are revoked — user must re-authenticate12. Refresh Tokens
// Explicitly refresh (normally handled automatically by getAccessToken)
const refreshed = await auth.refresh();
console.log(`New token expires at: ${new Date(refreshed.expiresAt).toISOString()}`);13. Logout
// Revoke token on server + clear local storage
await auth.logout();14. Sign Out (local only)
// Clear local tokens without server revocation
await auth.signOut();Session Idle Timeout (automatic token expiry)
Tokens are expired and deleted on next use if they have not been used within a configurable window. This is enforced lazily — there is no background job; every time a token is requested the SDK checks the last-used timestamp — and it protects against stolen or idle tokens (e.g. an XSS-grabbed token that is never replayed while the user is active gets purged after the window).
// Default is 10 minutes. Override with idleTimeoutMs (here: 5 minutes).
const auth = new DigitwhaleAuth(new LocalStorageTokenStore(), {
idleTimeoutMs: 5 * 60 * 1000,
});When Date.now() - lastUsedAt > idleTimeoutMs, the SDK clears the stored
tokens and throws an AuthError with type: ErrorType.TokenExpired
(requiresReauth === true). Hook that to bounce the user back to login:
try {
const token = await auth.getAccessToken();
// ...use token...
} catch (err) {
if (err instanceof AuthError && err.requiresReauth) {
// session idle-expired (or token revoked) — send back to login
router.push("/login");
}
}The idle clock only advances while the app is actively using tokens, so a genuinely active session never expires mid-use.
Passkeys (WebAuthn)
Passwordless login & registration using device biometrics / PIN / security
keys. The same passkey works for both normal auth and the OAuth
authorize flow (pass oauth to receive a code instead of local tokens).
Browser ceremony (recommended)
The SDK runs navigator.credentials.get/create for you — no manual byte
handling required:
// Login
const res = await auth.authenticateWithPasskey({ identifier: "[email protected]" });
// res is PasskeyLoginResult (tokens) or PasskeyOAuthResult (code + redirect)
// Register a new passkey for the signed-in user
await auth.registerPasskey("My Laptop");OAuth passkey — receive a code instead of tokens
const res = await auth.authenticateWithPasskey({
identifier: "[email protected]",
oauth: {
client_id,
redirect_uri,
state,
scope,
code_challenge,
code_challenge_method: "S256",
},
});
// res.redirect -> send the user's browser there (it carries ?code=…)Lower-level (manual ceremony)
Use these when you want to run the ceremony yourself (custom UI, non-browser contexts):
import { getPasskeyAssertion, createPasskey } from "@digitwhale_innovations/digitwhale-auth/webauthn";
const options = await auth.passkeyLoginOptions(identifier);
const assertion = await getPasskeyAssertion(options);
const res = await auth.passkeyLogin(assertion, oauth?); // normal or OAuth
const regOptions = await auth.passkeyRegisterOptions();
const credential = await createPasskey(regOptions);
await auth.passkeyRegister(credential);Manage passkeys
const passkeys = await auth.listPasskeys();
await auth.deletePasskey(passkeys[0].id);Backend Integration (server-side / confidential clients)
If your app has its own backend, run the OAuth flow there as a confidential
client: the backend holds the client_secret (never shipped to the browser)
and performs the token exchange. The SDK also doubles as a typed API client for
the auth service, so once you hold a user's tokens you can read their profile,
manage their passkeys, etc.
1. Store tokens per user session
Never use LocalStorageTokenStore on a server. Implement TokenStore against
your session/DB. Here's an in-memory map keyed by session id (swap the Map
for Redis/your DB):
import type { TokenStore, StoredTokens } from "@digitwhale_innovations/digitwhale-auth";
const sessions = new Map<string, StoredTokens>();
function storeFor(sessionId: string): TokenStore {
return {
async save(t) { sessions.set(sessionId, t); },
async load() { return sessions.get(sessionId) ?? null; },
async clear() { sessions.delete(sessionId); },
};
}2. OAuth callback — exchange the code with the client secret
// GET /auth/callback
app.get("/auth/callback", async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.state) return res.status(403).send("Invalid state");
const auth = new DigitwhaleAuth(storeFor(req.session.id), {
idleTimeoutMs: 30 * 60 * 1000, // longer window on a server
});
await auth.exchangeCode(
code as string,
"https://myapp.com/auth/callback",
"server_app",
req.session.pkce, // PKCE verifier kept server-side in the session
"sec_xxxxx", // clientSecret — required for confidential clients
);
res.redirect("/dashboard");
});PKCE still applies: the backend stores the code_verifier in the session and
sends it at exchange; the client_secret is additionally required for
confidential clients (oauth.py:245).
3. Use the SDK as the auth-service API client
With a user's tokens loaded, call any authenticated endpoint on their behalf:
const auth = new DigitwhaleAuth(storeFor(req.session.id));
const user = await auth.user();
const passkeys = await auth.listPasskeys();
await auth.deletePasskey(passkeys[0].id);
await auth.changePassword(current, next, next);4. Idle timeout on the backend
On a server you usually manage expiry via your own session, so set a longer
idleTimeoutMs (or rely on your session TTL). The SDK still auto-refreshes
access tokens and forces re-auth (requiresReauth) when a token is truly dead.
Rate Limiting
The auth server enforces rate limits per IP address. If you hit a 429 Too Many Requests, wait and retry. The SDK does not automatically retry — handle this in your application code.
Token Revocation
When a user changes their password or deletes their account, all existing tokens are revoked. The SDK will receive a 401 Unauthorized on the next API call. Handle this by calling auth.signOut() and redirecting to the login page.
