Skip to content

@flowsta/auth

OAuth 2.0 authentication SDK for web applications.

@flowsta/auth handles the complete OAuth 2.0 + PKCE flow for browser-based apps, plus Sign It document signing through the user's own Flowsta Vault. Zero dependencies, TypeScript-first, with React bindings included.

Current version: 2.3.0.

Installation

bash
npm install @flowsta/auth

Quick Start

typescript
import { FlowstaAuth } from '@flowsta/auth';

const auth = new FlowstaAuth({
  clientId: 'your_client_id',
  redirectUri: 'https://yourapp.com/auth/callback'
});

// Redirect to Flowsta login
auth.login();

// On your callback page
const user = await auth.handleCallback();
console.log('Welcome,', user.displayName);

Configuration

typescript
interface FlowstaAuthConfig {
  clientId: string;        // Required - from dev.flowsta.com
  redirectUri: string;     // Required - OAuth callback URL
  scopes?: string[];       // Optional - default: ['openid', 'display_name']
  loginUrl?: string;       // Optional - default: 'https://login.flowsta.com'
  apiUrl?: string;         // Optional - default: 'https://auth-api.flowsta.com'
}

No Client Secret Required

Flowsta uses PKCE (Proof Key for Code Exchange) which provides security without client secrets. Works safely in browsers and mobile apps.

Methods

login()

Redirect user to Flowsta's login page.

typescript
auth.login();

handleCallback()

Handle the OAuth callback and retrieve user data. Call this on your redirect URI page.

typescript
const user = await auth.handleCallback();

isAuthenticated()

Check if user is logged in.

typescript
if (auth.isAuthenticated()) {
  const user = auth.getUser();
}

getUser()

Get the current user profile.

typescript
const user = auth.getUser();
// { id, email?, username?, displayName?, profilePicture?, agentPubKey?, did?, linkedAgents?, signingMode? }

Don't rely on email

Flowsta accounts don't include an email address, even when the email scope is granted: Flowsta's server stores only a hash, so there's nothing to return. If your app needs an email address, ask the user for one directly.

getAccessToken()

Get the current access token.

typescript
const token = auth.getAccessToken();

getState()

Get the full authentication state.

typescript
const state = auth.getState();
// { isAuthenticated, user, accessToken, isLoading, error }

logout()

Clear the local session.

typescript
auth.logout();

detectVault()

Check if Flowsta Vault is running locally.

typescript
const vault = await auth.detectVault();
// { running: boolean, agentPubKey?: string, did?: string }
if (vault.running) {
  console.log('Vault agent:', vault.agentPubKey);
}

getLinkedAgents(agentPubKey?)

Get agents linked to a specific agent, or the current user's agent if no key is provided. Returns an array of agent public key strings.

typescript
// Get agents linked to current user
const agents = await auth.getLinkedAgents();
// ['uhCAk...', 'uhCAk...']

// Get agents linked to a specific agent
const agents = await auth.getLinkedAgents('uhCAk7Jp...');

areAgentsLinked(agentA, agentB)

Check if two agents are linked via Flowsta identity.

typescript
const linked = await auth.areAgentsLinked(agentKeyA, agentKeyB);

Sign It Methods

Signing requires the sign scope. The scope is enforced server-side by the Flowsta API - the SDK doesn't check it at construction, so a missing scope surfaces as a rejected signing request, not a constructor error.

Vault-first signing (v2.3.0)

signFile() probes for a running Flowsta Vault on every call:

  • Vault running - the request goes to the Vault on the user's own machine. The user approves it in the Vault UI, and the signing key never leaves their device. No one in between.
  • No Vault - the call throws VaultRequiredError: signing keys live only in the user's Vault, so there's nothing else that can sign. Tell the user to open (or install) Flowsta Vault.

signFile(options)

Sign a file hash. The file is never uploaded - only the hash is sent.

typescript
import { hashFile } from '@flowsta/auth';

const hash = await hashFile(file);
const result = await auth.signFile({
  fileHash: hash,
  intent: 'authorship',
  aiGeneration: 'none',
});

Options: { fileHash: string; intent?: string; aiGeneration?: string; contentRights?: Record<string, string> }

Returns:

typescript
{
  success: boolean;
  file_hash: string;
  agent_pub_key: string;      // signer's Holochain agent key (uhCAk...)
  signed_at: number;
  action_hash: string | null; // hash on Flowsta's tamper-proof network, built on Holochain
}

The Vault approval dialog gives the user about 60 seconds to respond; the SDK waits 70 seconds before throwing a timeout error.

signBatch(options)

Sign multiple file hashes in one request. Shared metadata applies to all files unless overridden per-file.

One approval per signature

The Vault approves one document at a time, so signBatch() throws FlowstaAuthError with code 'batch_requires_individual_approval' - call signFile() per file instead (each shows its own Vault prompt).

typescript
const result = await auth.signBatch({
  files: [{ fileHash: hashA }, { fileHash: hashB }],
  sharedMetadata: { intent: 'authorship' },
});

Options: { files: Array<{ fileHash; intent?; aiGeneration?; contentRights? }>; sharedMetadata?: { intent?; aiGeneration?; contentRights? } }

Returns:

typescript
{
  results: Array<{
    file_hash: string;
    action_hash: string | null;
    success: boolean;
    error?: string;
  }>;
  signed: number;
  failed: number;
}

verifyFile(fileHash)

Check if a hash has been signed. Public endpoint - no authentication required, but authenticated requests with the verify scope get higher rate limits.

typescript
const result = await auth.verifyFile(hash);
if (result.count > 0) {
  console.log('Signed by', result.signatures[0].signer);
}

Returns:

typescript
{
  signatures: Array<{
    file_hash: string;
    signer: string;
    signer_did?: string;
    signed_at: number;
    intent?: string;
    ai_generation?: string;
    content_rights?: Record<string, any>;
    revoked: boolean;
    contactable: boolean;
  }>;
  count: number;
}

getContentRights(fileHash)

Convenience wrapper over verifyFile() that returns just the aggregated rights info (revoked signatures excluded).

typescript
const rights = await auth.getContentRights(hash);

Returns:

typescript
{
  signed: boolean;
  signerCount: number;
  rights: Array<{
    signer: string;              // DID if available, else agent key
    license?: string;
    aiTraining?: string;
    commercialLicensing?: string;
    contactPreference?: string;
    aiGeneration?: string;
  }>;
}

hashFile(file) - utility export

SHA-256 hex hash of a File, computed in-browser via SubtleCrypto. No class instance needed; the file never leaves the browser.

typescript
import { hashFile } from '@flowsta/auth';

const input = document.querySelector('input[type=file]');
const hash = await hashFile(input.files[0]); // "a7f3b9c1e2d4..." (64 hex chars)

Full parameter and response tables: Sign It SDK Reference.

Signing Errors

The SDK exports typed error classes for signing:

ClasscodeWhen
FlowstaAuthErrorvariesBase class. Every SDK signing error carries a stable machine-readable code
VaultRequiredError'vault_required'No Vault is reachable (or it's locked) - nothing else holds the user's signing keys
UserDeniedError'user_denied'The user declined the request in the Vault approval dialog

Other FlowstaAuthError codes you may see: 'timeout' (the user didn't respond to the Vault dialog in time) and 'batch_requires_individual_approval' (see signBatch()).

typescript
import {
  FlowstaAuthError,
  VaultRequiredError,
  UserDeniedError,
  hashFile,
} from '@flowsta/auth';

try {
  const result = await auth.signFile({ fileHash: await hashFile(file) });
  showSuccess(result.file_hash);
} catch (err) {
  if (err instanceof VaultRequiredError) {
    // No Vault reachable (or it's locked) - signing keys live on the
    // user's device, so ask them to open or install Flowsta Vault.
    showVaultPrompt('https://flowsta.com/vault');
  } else if (err instanceof UserDeniedError) {
    // The user said no in the Vault dialog. Respect it - no retry loop.
    showInfo('Signing was declined.');
  } else if (err instanceof FlowstaAuthError) {
    console.error(`Signing failed (${err.code}):`, err.message);
  } else {
    throw err; // not a signing error
  }
}

VaultRequiredError and UserDeniedError extend FlowstaAuthError, so a single instanceof FlowstaAuthError catch-all works too - check the subclasses first.

Type Definitions

typescript
interface FlowstaUser {
  id: string;
  /**
   * Never returned - Flowsta's server stores only a hash. If your app
   * needs an email address, ask the user for one directly.
   */
  email?: string;
  username?: string;
  displayName?: string;
  profilePicture?: string;
  agentPubKey?: string;              // Holochain agent public key
  did?: string;                      // W3C Decentralized Identifier
  linkedAgents?: LinkedAgent[];      // Linked agents (DHT-verified)
  /**
   * Signing mode observed at sign-in ('remote' = deprecated server API,
   * 'ipc' = Flowsta Vault). Informational only - signFile() re-probes
   * the Vault live on every call, so this may be stale.
   */
  signingMode?: 'remote' | 'ipc';
}

interface LinkedAgent {
  agentPubKey: string;     // The linked agent's public key
  linkedAt?: string;       // When the link was created
  isRevoked: boolean;      // Whether the link has been revoked
}

interface VaultDetectionResult {
  running: boolean;        // Whether Vault is running
  agentPubKey?: string;    // Vault agent's public key (if unlocked)
  did?: string;            // Vault agent's DID (if unlocked)
}

interface AuthState {
  isAuthenticated: boolean;
  user: FlowstaUser | null;
  accessToken: string | null;
  isLoading: boolean;
  error: string | null;
}

React Integration

tsx
import { FlowstaAuthProvider, useFlowstaAuth } from '@flowsta/auth/react';

function App() {
  return (
    <FlowstaAuthProvider
      clientId="your_client_id"
      redirectUri="https://yourapp.com/auth/callback"
    >
      <MyApp />
    </FlowstaAuthProvider>
  );
}

function LoginButton() {
  const { isAuthenticated, user, login, logout } = useFlowstaAuth();

  if (isAuthenticated) {
    return (
      <div>
        <span>Hello, {user.displayName}!</span>
        <button onClick={logout}>Logout</button>
      </div>
    );
  }

  return <button onClick={login}>Sign in with Flowsta</button>;
}

Provider Required

useFlowstaAuth() must be used within a FlowstaAuthProvider. If used outside, it will throw: "useFlowstaAuth must be used within a FlowstaAuthProvider".

useRequireAuth()

Protect routes by redirecting unauthenticated users. Returns an isReady flag that is true once the user is confirmed authenticated.

tsx
import { useRequireAuth } from '@flowsta/auth/react';

function ProtectedPage() {
  const { isReady, user } = useRequireAuth();
  // Optionally pass a custom redirect: useRequireAuth({ redirectTo: '/login' })

  if (!isReady) {
    return <div>Loading...</div>;
  }

  return <div>Welcome, {user?.displayName}!</div>;
}

Error Handling

handleCallback() throws specific errors you can catch:

typescript
try {
  const user = await auth.handleCallback();
} catch (error) {
  switch (true) {
    case error.message.includes('No authorization code'):
      // User cancelled login or error from Flowsta
      break;
    case error.message.includes('Invalid state'):
      // CSRF attack or expired session
      break;
    case error.message.includes('Missing PKCE code verifier'):
      // Session was cleared before callback (e.g. different browser tab)
      break;
    case error.message.includes('Token exchange failed'):
      // Code expired (10 min limit) or server error
      break;
    case error.message.includes('Failed to fetch user info'):
      // Token was issued but userinfo request failed
      break;
    default:
      console.error('Authentication failed:', error.message);
  }
}

Security

The SDK automatically handles:

  • PKCE - Generates code_verifier and code_challenge for each login
  • State parameter - Random state stored in sessionStorage for CSRF protection
  • Secure storage - Access token in localStorage, PKCE/state in sessionStorage (cleared after use)
  • Session restoration - Authenticated state persists across page reloads via localStorage
  • Session lifetime - Sessions end when the access token expires; there is no refresh flow. Send the user back through login() to start a new session

Migrating from SDK 1.x

SDK 2.0 removes direct email/password authentication. All users now authenticate through Flowsta's hosted login page.

Before (SDK 1.x):

typescript
// No longer supported
await auth.login(email, password);

After (SDK 2.0+):

typescript
// OAuth redirect
auth.login(); // Redirects to login.flowsta.com
const user = await auth.handleCallback();

Next Steps

Documentation licensed under CC BY-SA 4.0.