@authpi/idp
v1.1.0
Published
Official TypeScript SDK for AuthPI authentication
Maintainers
Readme
@authpi/idp
Official TypeScript SDK for AuthPI identity provider.
Requirements: Node.js 18+ or modern browser with Web Crypto API
Stability
@authpi/idp follows semantic versioning. Public exports, constructor options, request and response types, and documented method behavior are stable across 1.x; incompatible changes will ship in a new major version.
Installation
npm install @authpi/idp
# or
pnpm add @authpi/idpQuick Start
import { IdpClient } from "@authpi/idp";
const idp = new IdpClient({
issuerUrl: "https://idp.authpi.com/i_xxx",
clientId: "c_xxx",
clientSecret: "secret", // omit for public clients (SPAs)
redirectUri: "https://app.example.com/callback",
// resources: ["https://mcp.authpi.com/mcp"], // optional RFC 8707 default
});
// 1. Create authorization URL
const auth = await idp.createAuthorizationUrl({
scopes: ["openid", "profile", "email"],
// org: "org_xxx", // optional selected-org token restriction
});
// 2. Store the authorization object in session, redirect user to auth.url
session.set("oauth", auth);
redirect(auth.url);
// 3. Handle callback - validates state, exchanges code, and verifies nonce
const agent = await idp.exchangeCallback(callbackUrl, await session.get("oauth"));
// 4. Store tokens for future requests
await session.set("tokens", agent.tokens);
// 5. Check authorization
if (agent.hasAccessIn("org_xxx", "write", "projects")) {
// User can write to projects in org_xxx
}Machine-to-Machine Authentication
For server-to-server or background service authentication using the client credentials flow:
const idp = new IdpClient({
issuerUrl: "https://idp.authpi.com/i_xxx",
clientId: "agt_machine1",
clientSecret: "secret",
// No redirectUri needed for client_credentials
});
const agent = await idp.clientCredentials({
scopes: ["users:read", "users:write"],
});
// Agent uses token-level scopes (no organizations)
agent.hasAccess("read", "users"); // true
agent.type; // PrincipalType.AgentResource Indicators (RFC 8707)
Configure a fixed resource set on the client when every authorization and refresh targets the same protected resource. This is the recommended shape for the AuthPI MCP server:
const idp = new IdpClient({
issuerUrl: "https://idp.authpi.com/i_xxx",
clientId: "c_xxx",
clientSecret: "secret",
redirectUri: "https://app.example.com/callback",
resources: ["https://mcp.authpi.com/mcp"],
});Multiple resources are serialized as repeated resource parameters in the
supplied order. A per-operation list overrides the client default:
const auth = await idp.createAuthorizationUrl({
scopes: ["openid", "offline_access"],
resources: ["https://api.example.com/orders", "https://api.example.com/payments"],
});
// exchangeCallback repeats auth.resources automatically.
const agent = await idp.exchangeCallback(callbackUrl, auth);
// Select a permitted subset for this refresh.
const narrowed = await idp.refresh(agent, {
resources: ["https://api.example.com/orders"],
});Omitting resources on authorization, code exchange, or manual refresh uses the
client default; an empty array explicitly omits the parameter. AuthorizationUrl
carries an immutable copy of the effective set through the callback, but
TokenSet remains unchanged. Automatic refresh from createAgent() therefore
omits resource by default and relies on the authorization server's stored
refresh-family selection. Pass resources to createAgent() when the issuer
policy requires the parameter or you need to select a permitted subset.
Session Management
The SDK automatically refreshes expired tokens when creating an agent from stored tokens:
// Load tokens from your session store
const tokens = await session.get("tokens");
// createAgent() automatically refreshes if tokens are expired
const agent = await idp.createAgent(tokens, {
// Called when tokens are refreshed - persist the new tokens
onRefresh: async (newTokens) => {
await session.set("tokens", newTokens);
},
// Called when refresh fails - handle the error
onRefreshError: async (error) => {
console.error("Session expired:", error);
await session.destroy();
// Redirect to login handled by your framework
},
});Configuring Auto-Refresh
const idp = new IdpClient({
// ...
autoRefresh: true, // Default: true
refreshBufferSeconds: 60, // Refresh 60s before expiry (default)
});
// Or disable per-call
const agent = await idp.createAgent(tokens, { autoRefresh: false });Token Expiration
// Check expiration
agent.expiresAt; // Unix timestamp
agent.expiresIn; // Seconds until expiry (negative if expired)
agent.isExpired(); // true if expires within 30 seconds (default clock skew buffer)
agent.isExpired(60); // true if expires within 60 seconds
agent.isExpired(0); // true only if actually expired (no buffer)Authorization
The SDK includes an optional authorization framework based on scopes. You can use it to make local authorization decisions without additional API calls, or you can ignore it entirely and implement your own authorization logic.
The authorization data comes from the organizations claim in the ID token, which AuthPI populates based on your organization memberships, optional selected-org requests, and client organization restrictions.
When you request a selected-org token with org, agent.orgId identifies the selected organization.
Using the Built-in Authorization
// Check access across all organizations
agent.hasAccess("read", "users");
// Check access in a specific organization
agent.hasAccessIn("org_xxx", "write", "projects");
agent.hasAccessIn("org_xxx", "delete", "projects.tasks");
// Role checks
agent.isOwnerOf("org_xxx"); // Has "owner" scope
agent.isAdminOf("org_xxx"); // Has "admin" scope
agent.isMemberOf("org_xxx"); // Has any membership
// Get scopes for an organization
const scopes = agent.getScopesFor("org_xxx");
// ["users:read", "projects:**"]Rolling Your Own Authorization
If the built-in scope system doesn't fit your needs, you can access the raw data directly:
// Access organizations directly
for (const org of agent.organizations) {
console.log(org.id); // "org_xxx"
console.log(org.title); // "Admin" or null
console.log(org.scopes); // ["users:read", "projects:**"]
console.log(org.joinedAt); // Unix timestamp
}
// Use agent.id for your own authorization lookups
const permissions = await myPermissionService.getPermissions(agent.id);How Scopes Work
AuthPI uses a hierarchical scope format: resource:action
Basic format:
resource:action
resource.subresource:actionExamples:
users:read— Can read usersusers:write— Can create/update usersprojects.tasks:delete— Can delete tasks within projects
Wildcards:
| Pattern | Description |
|---------|-------------|
| users:* | All actions on users (but not sub-resources) |
| users:** | All actions on users AND all sub-resources |
| *:read | Read access to all top-level resources |
| *:** | Full access to everything (super-admin) |
The difference between * and **:
projects:*grantsprojects:read,projects:write,projects:deleteprojects:*does NOT grantprojects.tasks:read(sub-resource)projects:**grants all of the above PLUSprojects.tasks:read,projects.tasks.comments:write, etc.
Scope evaluation example:
const scopes = ["projects:**", "users:read"];
// These all return true:
agent.hasAccessIn("org_xxx", "read", "projects");
agent.hasAccessIn("org_xxx", "write", "projects");
agent.hasAccessIn("org_xxx", "delete", "projects.tasks");
agent.hasAccessIn("org_xxx", "read", "projects.tasks.comments");
agent.hasAccessIn("org_xxx", "read", "users");
// These return false:
agent.hasAccessIn("org_xxx", "write", "users"); // Only has users:read
agent.hasAccessIn("org_xxx", "read", "billing"); // No billing scopeSpecial Role Scopes
Three scopes have special meaning and dedicated helper methods:
| Scope | Method | Typical Use |
|-------|--------|-------------|
| owner | isOwnerOf(orgId) | Organization billing, deletion, transfer |
| admin | isAdminOf(orgId) | Member management, settings |
| member | isMemberOf(orgId) | Basic membership check |
These are checked directly (not via wildcard expansion):
// User has scopes: ["owner", "admin", "projects:**"]
agent.isOwnerOf("org_xxx"); // true - has "owner" scope
agent.isAdminOf("org_xxx"); // true - has "admin" scope
agent.isMemberOf("org_xxx"); // true - has any membership
// Note: "*:**" does NOT grant owner/admin status
// These are explicit role assignments, not permissionsScope Utilities
For advanced use cases, you can use the scope utilities directly:
import { hasAccess, parseScope } from "@authpi/idp";
// Check if a list of scopes grants access
const scopes = ["users:read", "projects:**"];
hasAccess(scopes, "read", "users"); // true
hasAccess(scopes, "write", "projects.tasks"); // true (** is recursive)
hasAccess(scopes, "delete", "users"); // false
// Parse a scope string into its components
parseScope("users.verifiers:write");
// { action: "write", resource: "users", subResources: ["verifiers"] }
parseScope("projects:**");
// { action: "**", resource: "projects", subResources: [] }Error Handling
The SDK provides specific error types for different failure modes:
import {
OAuthError,
TokenExpiredError,
RefreshError,
TokenParseError,
SubjectMismatchError,
ConfigurationError,
InsufficientScopeError,
SessionExpiredError,
UserBlockedError,
AccountLinkingRequiredError,
InteractionRequiredError,
LoginRequiredError,
ConsentRequiredError,
} from "@authpi/idp";
try {
const agent = await idp.createAgent(tokens);
} catch (error) {
if (error instanceof TokenExpiredError) {
// Token expired and no refresh token available
redirectToLogin();
} else if (error instanceof RefreshError) {
// Refresh request failed (e.g., refresh token revoked)
console.error(`Refresh failed: ${error.errorDescription}`);
console.error(`Status: ${error.statusCode}`);
redirectToLogin();
} else if (error instanceof TokenParseError) {
// ID token missing or malformed
console.error("Invalid token data");
} else if (error instanceof SubjectMismatchError) {
// Security: refreshed token belongs to different user
console.error(`Expected ${error.expectedSub}, got ${error.actualSub}`);
}
}Error Hierarchy
All OAuth errors extend OAuthError:
class OAuthError extends Error {
error: string; // OAuth error code
errorDescription?: string;
}
class TokenExpiredError extends OAuthError {}
class RefreshError extends OAuthError {
statusCode?: number; // HTTP status from token endpoint
}
class TokenParseError extends OAuthError {}
class SubjectMismatchError extends OAuthError {
expectedSub: string;
actualSub: string;
}
// AuthPI-specific errors
class InsufficientScopeError extends OAuthError {} // Token lacks required scope
class SessionExpiredError extends OAuthError {} // Server-side session timed out
class UserBlockedError extends OAuthError {} // Blocked user attempted auth
class AccountLinkingRequiredError extends OAuthError {} // OAuth identity needs linking
// OIDC authorization endpoint errors
class InteractionRequiredError extends OAuthError {} // Silent auth failed
class LoginRequiredError extends OAuthError {} // No active session
class ConsentRequiredError extends OAuthError {} // User hasn't consentedUser Info
For full profile data beyond the ID token claims:
const userInfo = await idp.getUserInfo(agent);
userInfo.sub; // "usr_xxx"
userInfo.email; // "[email protected]"
userInfo.name; // "John Doe"
userInfo.picture; // "https://..."
userInfo.orgId; // "org_xxx" when the token is restricted to one org
userInfo.organizations; // [{ id, title, scopes, joinedAt }]Logout
const logoutUrl = idp.createLogoutUrl({
idTokenHint: agent.tokens.idToken,
postLogoutRedirectUri: "https://app.example.com",
state: "logout_state",
});
redirect(logoutUrl);Token Revocation
// Revoke refresh token (recommended on logout)
await idp.revokeToken(agent.tokens.refreshToken, "refresh_token");
// Revoke access token
await idp.revokeToken(agent.tokens.accessToken, "access_token");API Reference
IdpClient
new IdpClient(config: IdpClientConfig)
interface IdpClientConfig {
issuerUrl: string; // OIDC issuer URL
clientId: string; // OAuth client ID
clientSecret?: string; // Optional for public clients
redirectUri?: string; // Required for authorization code flow, optional for client_credentials
autoRefresh?: boolean; // Auto-refresh expired tokens (default: true)
refreshBufferSeconds?: number; // Refresh buffer (default: 60)
resources?: readonly string[]; // Default repeated RFC 8707 resource values
}Methods:
| Method | Description |
|--------|-------------|
| createAuthorizationUrl(options) | Create OAuth authorization URL with PKCE; accepts optional org and repeated resources |
| exchangeCallback(callbackUrlOrParams, auth) | Validate callback state, exchange code, and verify ID-token nonce |
| exchangeCode(code, codeVerifier, options?) | Exchange authorization code, optionally repeating transaction resources |
| createAgent(tokens, options?) | Create agent from stored tokens; auto-refresh options may override resources |
| refresh(agent, options?) | Manually refresh tokens, optionally selecting a resource subset |
| getUserInfo(agent) | Fetch full user profile |
| createLogoutUrl(options?) | Create OIDC logout URL |
| clientCredentials(options?) | Authenticate via client credentials (M2M) |
| revokeToken(token, hint?) | Revoke a token |
AuthenticatedAgent
interface AuthenticatedAgent {
// Identity
id: string; // Subject ID (usr_xxx, tok_xxx, key_xxx, agt_xxx)
type: PrincipalType; // "user" | "personal_token" | "api_key" | "agent"
email?: string;
emailVerified?: boolean;
orgId?: string; // Selected org for org-restricted tokens
// Tokens (for storage)
tokens: TokenSet;
// Organizations (from ID token)
organizations: Organization[];
// Expiration
expiresAt: number; // Unix timestamp
expiresIn: number; // Seconds until expiry
isExpired(buffer? = 30): boolean;
// Authorization
hasAccess(action, resource): boolean;
hasAccessIn(orgId, action, resource): boolean;
getScopesFor(orgId): string[];
isOwnerOf(orgId): boolean;
isAdminOf(orgId): boolean;
isMemberOf(orgId): boolean;
}Framework Examples
Express.js Middleware
import { IdpClient, TokenExpiredError } from "@authpi/idp";
const idp = new IdpClient({ /* ... */ });
async function authMiddleware(req, res, next) {
const tokens = req.session.tokens;
if (!tokens) {
return res.redirect("/login");
}
try {
req.agent = await idp.createAgent(tokens, {
onRefresh: (newTokens) => {
req.session.tokens = newTokens;
},
});
next();
} catch (error) {
if (error instanceof TokenExpiredError) {
req.session.destroy();
return res.redirect("/login");
}
next(error);
}
}
// Usage
app.get("/dashboard", authMiddleware, (req, res) => {
if (!req.agent.hasAccessIn("org_xxx", "read", "dashboard")) {
return res.status(403).send("Forbidden");
}
res.render("dashboard", { user: req.agent });
});Next.js Server Action
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { IdpClient } from "@authpi/idp";
const idp = new IdpClient({ /* ... */ });
export async function getAgent() {
const cookieStore = cookies();
const tokens = JSON.parse(cookieStore.get("tokens")?.value || "null");
if (!tokens) {
redirect("/login");
}
try {
return await idp.createAgent(tokens, {
onRefresh: async (newTokens) => {
cookieStore.set("tokens", JSON.stringify(newTokens), {
httpOnly: true,
secure: true,
sameSite: "lax",
});
},
onRefreshError: () => {
cookieStore.delete("tokens");
redirect("/login");
},
});
} catch {
redirect("/login");
}
}License
MIT
