Skip to content

npm

OAuth — Device Authorization Grant

OAuth 2.0 Device Authorization Grant (RFC 8628) for MCP servers. The server requests a device code, the user authorizes in a browser, and the token is stored for future sessions. No redirect URIs, no embedded browsers.

bash
npm install @vurb/oauth

Peer dependency: Vurb.ts ^2.0.0

Create the Auth Tool

typescript
import { createAuthTool } from '@vurb/oauth';
import { ToolRegistry } from '@vurb/core';

const auth = createAuthTool<AppContext>({
    clientId: 'your-client-id',
    authorizationEndpoint: 'https://api.example.com/oauth/device/code',
    tokenEndpoint: 'https://api.example.com/oauth/device/token',
    tokenManager: {
        configDir: '.myapp',
        tokenFile: 'mcp-token.json',
        envVar: 'MY_APP_TOKEN',
    },
    onAuthenticated: (token, ctx) => {
        ctx.client.setToken(token);
    },
    getUser: async (ctx) => {
        const user = await ctx.client.getMe();
        return { name: user.name, email: user.email };
    },
});

const registry = new ToolRegistry<AppContext>();
registry.register(auth);

The auth tool exposes 4 actions:

ActionDescription
loginInitiates Device Flow — returns verification URL + user code
completePolls until the user authorizes in the browser
statusChecks current authentication state
logoutClears the stored token

Protect Tools with Middleware

typescript
import { requireAuth } from '@vurb/oauth';
import { createTool, success } from '@vurb/core';

const projects = createTool<AppContext>('projects')
    .use(requireAuth({
        extractToken: (ctx) => {
            if (ctx.client.isAuthenticated()) return 'authenticated';
            return null;
        },
        recoveryHint: 'Run auth action=login to authenticate',
        recoveryAction: 'auth',
    }))
    .action({
        name: 'list',
        readOnly: true,
        handler: async (ctx) => success(await ctx.client.getProjects()),
    });

When no token is found, requireAuth() returns a structured toolError('AUTH_REQUIRED') with recovery hints — enabling the LLM to self-heal by calling the auth tool automatically.

Standalone Usage

DeviceAuthenticator and TokenManager work independently of Vurb.ts:

typescript
import { DeviceAuthenticator, TokenManager } from '@vurb/oauth';

const authenticator = new DeviceAuthenticator({
    authorizationEndpoint: 'https://api.example.com/oauth/device/code',
    tokenEndpoint: 'https://api.example.com/oauth/device/token',
});

// Phase 1: Get device code
const code = await authenticator.requestDeviceCode({
    clientId: 'my-client-id',
});
console.log(`Open: ${code.verification_uri_complete}`);
console.log(`Code: ${code.user_code}`);

// Phase 2: Poll until authorized
const token = await authenticator.pollForToken(code);

// Store securely
const manager = new TokenManager({ configDir: '.myapp' });
manager.saveToken(token.access_token);

Token Storage

TokenManager stores tokens securely:

  • File location: ~/.{configDir}/token.json
  • File permissions: 0o600 (owner read/write only)
  • Resolution order: Environment variable → File → null
  • Pending codes: Stored separately with TTL, surviving process restarts
typescript
const manager = new TokenManager({
    configDir: '.myapp',
    tokenFile: 'mcp-token.json',      // default: 'token.json'
    pendingAuthFile: 'pending.json',   // default: 'pending-auth.json'
    envVar: 'MY_APP_TOKEN',            // optional: env var override
});

// Token lifecycle
const token = manager.getToken();         // env var > file > null
const source = manager.getTokenSource();  // 'environment' | 'file' | null
manager.saveToken('eyJhbGc...');
manager.clearToken();

// Pending device code (survives restarts)
manager.savePendingDeviceCode(codeResponse, 900); // TTL in seconds
const pending = manager.getPendingDeviceCode();    // null if expired

API Reference

DeviceAuthenticator

MethodReturnsDescription
requestDeviceCode(request)DeviceCodeResponsePhase 1: Get device code + verification URL
pollForToken(codeResponse, signal?)TokenResponsePhase 2: Poll until authorized (respects slow_down)
attemptTokenExchange(request)TokenResponseSingle exchange attempt (manual polling)

TokenManager

MethodReturnsDescription
getToken()string | nullGet token (env var > file)
getTokenSource()'environment' | 'file' | nullToken origin
saveToken(token)voidSave to ~/{configDir}/token.json (0o600)
clearToken()voidRemove saved token
savePendingDeviceCode(code, ttl)voidStore pending auth state
getPendingDeviceCode()DeviceCodeResponse | nullGet pending code (auto-expired)

createAuthTool<TContext>(config)

Returns a GroupedToolBuilder with actions: login, complete, status, logout.

Config:

FieldTypeDescription
clientIdstringOAuth 2.0 client ID
authorizationEndpointstringDevice authorization URL
tokenEndpointstringToken exchange URL
headers?Record<string, string>Extra headers for requests
tokenManagerTokenManagerConfigStorage configuration
onAuthenticated(token, ctx) => voidCalled after successful auth
onLogout?(ctx) => voidCalled on logout
getUser?(ctx) => Promise<UserInfo>Fetch user info for status

requireAuth(options?)

Returns a Vurb.ts middleware function.

Options:

FieldTypeDescription
extractToken(ctx) => string | nullToken extraction function
recoveryHint?stringHint for the LLM to self-heal
recoveryAction?stringTool name to suggest

Types

typescript
interface DeviceCodeResponse {
    device_code: string;
    user_code: string;
    verification_uri: string;
    verification_uri_complete?: string;
    expires_in: number;
    interval: number;
}

interface TokenResponse {
    access_token: string;
    token_type: string;
    expires_in?: number;
    refresh_token?: string;
    scope?: string;
}

interface TokenManagerConfig {
    configDir: string;
    tokenFile?: string;
    pendingAuthFile?: string;
    envVar?: string;
}

type TokenSource = 'environment' | 'file' | null;