avanta
v2.0.3
Published
π OAuth that does more with less. Prebuilt providers, with high user accessilibity.
Readme
Intro
Avanta is a super-lightweight (sub 100kB) TypeScript-first OAuth library for teams who want prebuilt providers with full type safety and zero framework lock-in.
If you like the convenience of NextAuth or Lucia, but want something that:
- gives you full control over the OAuth flow (no magic, no sessions, no database)
- returns scope-aware typed data (request
emailandguilds? the return type includes exactly those fields) - uses only the Fetch API (works in Bun, Node 18+, Deno, Cloudflare Workers)
- has zero dependencies
Avanta is a good fit.
It gives you:
- 5 prebuilt providers: Discord, GitHub, Google, Microsoft, Twitch, and more to come
- Scope-level typing: the data you get back is typed to the exact scopes you requested
- Token management:
TokenStorewith built-in refresh, serialization, and expiry tracking - Scope-dependent actions: Discord's
guilds.joinandguilds.members.readare only available when you request those scopes
Table of contents
- Quickstart
- Providers
- TokenStore
- Common API
- Scope-dependent actions (Discord)
- Framework examples
- API reference
- Contributor docs
- License
Quickstart
Install:
bun add avantaOther package managers:
pnpm add avanta
npm i avanta1) Create a provider
Every provider takes the same shape: clientId, clientSecret, redirectUri, and scopes. The scopes you pass in determine the return type of getData().
import Avanta from "avanta";
const discord = new Avanta.DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
redirectUri: "http://localhost:3000/auth/discord/callback",
scopes: ["identify", "email", "guilds"],
});2) Redirect the user to the OAuth page
Generate the authorization URL and redirect the user to it. You can optionally pass a state parameter for CSRF protection.
const url = discord.getOAuthUrl("random-state-string");
// Redirect the user to `url`3) Exchange the code for tokens
When the user is redirected back, exchange the authorization code for a TokenStore:
const tokenStore = await discord.getTokenStore(code);The TokenStore holds the access token, refresh token, and expiry timestamp. You can serialize it to JSON for storage:
const serialized = tokenStore.compress(); // JSON stringAnd restore it later:
import { TokenStore } from "avanta"; // or from the util path
const restored = TokenStore.extract(serialized);4) Fetch user data
Call getData() with the token store. The return type is automatically narrowed to the scopes you configured:
const data = await discord.getData(tokenStore);
// Because we requested ["identify", "email", "guilds"]:
data.username; // string (from "identify")
data.email; // string (from "email")
data.guilds; // Array<...> (from "guilds")
data.tokenStore; // TokenStore (always present, may be refreshed)If a scope wasn't requested, the field doesn't exist on the type β no runtime checks needed.
Providers
All providers share the same constructor shape and public API. The only differences are the available scopes and the shape of the returned data.
Discord
const discord = new Avanta.DiscordProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["identify", "email", "guilds", "connections"],
});Available scopes:
| Scope | Data returned |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| identify | id, username, avatar, discriminator, global_name, banner, accent_color, locale, mfa_enabled, premium_type, public_flags |
| email | email, verified |
| guilds | guilds (array of partial guild objects) |
| connections | connections (array of connection objects) |
| guilds.join | Enables actions.guilds.join(...) |
| guilds.members.read | Enables actions.guilds.members.read(...) |
| gdm.join | Group DM join capability |
| role_connections.write | Role connection metadata write |
| dm_channels.read | DM channel read access |
GitHub
const github = new Avanta.GitHubProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["read:user", "user:email", "read:org"],
});Available scopes:
| Scope | Data returned |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| read:user | login, id, node_id, avatar_url, html_url, name, company, blog, location, bio, twitter_username, public_repos, public_gists, followers, following, created_at, updated_at |
| user:email | emails (array with email, primary, verified, visibility) |
| read:org | orgs (array with login, id, node_id, avatar_url, description) |
Note: GitHub only issues refresh tokens when token expiration is enabled on the GitHub App. If your app doesn't have token expiration,
refreshTokens()will throw.
const google = new Avanta.GoogleProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["openid", "profile", "email"],
});Available scopes:
| Scope | Data returned |
| --------- | -------------------------------------------------------- |
| openid | sub (stable Google account ID) |
| profile | name, given_name, family_name, picture, locale |
| email | email, email_verified |
Google's OAuth URL automatically requests offline access and consent prompt for refresh token support.
Microsoft
const microsoft = new Avanta.MicrosoftProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["User.Read", "Organization.Read.All", "offline_access"],
});Available scopes:
| Scope | Data returned |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| User.Read | id, displayName, givenName, surname, mail, userPrincipalName, jobTitle, officeLocation, businessPhones, mobilePhone, preferredLanguage, avatarUrl |
| Organization.Read.All | organization (object with id, displayName, address fields, verifiedDomains, or null for personal accounts) |
| offline_access | Enables refresh tokens (no data fields) |
Note: The
avatarUrlfield requires the access token to fetch the actual image bytes. Personal Microsoft accounts may havenullβ useuserPrincipalNameas a fallback.
Twitch
const twitch = new Avanta.TwitchProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["user:read:email"],
});Twitch always returns base profile data regardless of scopes:
| Field | Description |
| ------------------- | --------------------------------------------------------- |
| id | Unique Twitch numeric ID (as string) |
| login | Lowercase login name |
| display_name | Case-preserved display name |
| type | Account type ("", "admin", "staff", "global_mod") |
| broadcaster_type | "", "affiliate", or "partner" |
| description | Channel bio |
| profile_image_url | Profile image URL |
| offline_image_url | Offline stream image URL |
| created_at | ISO 8601 account creation date |
Additional scopes:
| Scope | Data returned |
| ----------------- | ------------- |
| user:read:email | email |
TokenStore
TokenStore is a small utility class that holds OAuth tokens and manages serialization.
import Avanta from "avanta";
// Created automatically by getTokenStore()
const tokenStore = await provider.getTokenStore(code);
// Properties
tokenStore.access_token; // string
tokenStore.refresh_token; // string
tokenStore.access_token_expires_at; // number (ms since epoch)
// Serialize to JSON string (for database storage, cookies, etc.)
const json = tokenStore.compress();
// Restore from JSON string
const restored = TokenStore.extract(json);When you call getData(), Avanta automatically refreshes the access token if it has expired. The returned data.tokenStore may be a new instance with fresh tokens β always persist the returned token store.
const data = await discord.getData(tokenStore);
// IMPORTANT: persist the (possibly refreshed) token store
await db.user.update({
where: { id: userId },
data: { tokens: data.tokenStore.compress() },
});Common API
Every provider exposes the same methods:
| Method | Description |
| ------------------------------- | ---------------------------------------------------------------------------------------- |
| getOAuthUrl(state?) | Returns the full authorization URL. Pass an optional state string for CSRF protection. |
| getTokens(code) | Exchanges an authorization code for raw token data (provider-specific shape). |
| getTokenStore(code) | Exchanges a code for a TokenStore (recommended over getTokens). |
| refreshTokens(tokenStore) | Refreshes the access token using the refresh token. Returns raw token data. |
| refreshTokenStore(tokenStore) | Refreshes and returns a new TokenStore. |
| getData(tokenStore) | Fetches all user data for the configured scopes. Auto-refreshes expired tokens. |
Scope-dependent actions (Discord)
Discord's provider exposes an actions object whose methods are typed based on the scopes you requested.
guilds.join β Join a user to a guild
Requires the guilds.join scope and a bot token with CREATE_INSTANT_INVITE permission in the target guild.
const discord = new Avanta.DiscordProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["identify", "guilds.join"],
});
const result = await discord.actions.guilds.join({
guildId: "123456789",
tokenStore,
botToken: process.env.DISCORD_BOT_TOKEN!,
options: {
nick: "New Member",
roles: ["role-id-1"],
mute: false,
deaf: false,
},
});
// result.data is the guild member object (or null if already a member)
// result.tokenStore is the (possibly refreshed) token storeguilds.members.read β Read guild member data
Requires the guilds.members.read scope.
const discord = new Avanta.DiscordProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["identify", "guilds.members.read"],
});
const result = await discord.actions.guilds.members.read({
guildId: "123456789",
tokenStore,
});
// result.data is the guild member objectIf you don't request these scopes, discord.actions won't have these methods at the type level β TypeScript prevents you from calling them.
Framework examples
Next.js (App Router)
// app/auth/discord/route.ts
import Avanta from "avanta";
const discord = new Avanta.DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
redirectUri: "http://localhost:3000/auth/discord/callback",
scopes: ["identify", "email"],
});
export async function GET() {
const url = discord.getOAuthUrl();
return Response.redirect(url);
}// app/auth/discord/callback/route.ts
export async function GET(req: Request) {
const url = new URL(req.url);
const code = url.searchParams.get("code")!;
const tokenStore = await discord.getTokenStore(code);
const data = await discord.getData(tokenStore);
// data.username, data.email, data.tokenStore
// Store in your database, set a session cookie, etc.
return Response.redirect("/dashboard");
}Express / Bun / any Request-based server
import Avanta from "avanta";
const github = new Avanta.GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: "http://localhost:3000/auth/github/callback",
scopes: ["read:user", "user:email"],
});
// Redirect route
app.get("/auth/github", (req, res) => {
res.redirect(github.getOAuthUrl());
});
// Callback route
app.get("/auth/github/callback", async (req, res) => {
const code = req.query.code as string;
const tokenStore = await github.getTokenStore(code);
const data = await github.getData(tokenStore);
// data.login, data.emails, data.tokenStore
res.json({ user: data.login, emails: data.emails });
});API reference
Exports
The default export is an object containing all providers:
import {
DiscordProvider,
GitHubProvider,
GoogleProvider,
MicrosoftProvider,
TwitchProvider,
} from "avanta";Provider constructor
All providers accept the same options:
{
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: [...Scope[]]; // const tuple β drives return type inference
}TokenStore
| Property / Method | Type | Description |
| -------------------------- | --------------------- | ----------------------------------------------- |
| access_token | string | The current access token |
| refresh_token | string | The refresh token (empty string if unavailable) |
| access_token_expires_at | number | Expiry as ms since epoch |
| compress() | string | JSON-serialized token data |
| compressed | string (getter) | Alias for compress() |
| TokenStore.extract(json) | TokenStore (static) | Deserialize from JSON string |
Contributor docs
- Implementation + extension guide:
FOR_AGENTS.md
License
MIT
