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.6.1.

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 (async: it generates PKCE first, then navigates)
await 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. Returns Promise<void> - it generates the PKCE pair and state, stores them in sessionStorage, then sets window.location.href.

typescript
await 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?, signingMode? }

Email is shared, not fetched

Flowsta's server stores only a hash of the user's email - there is nothing to fetch. The email scope asks the user to share their address at the consent screen (checked against the hash Flowsta holds; verified addresses only); if they do, it's returned for your app, and revoking your app deletes the shared copy. Legacy custodial accounts may return a stored address. Design your app to work without one.

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: the three localStorage keys the SDK wrote (flowsta_access_token, flowsta_user, flowsta_refresh_token). It calls no endpoint. Sign-out is per site - the user's Flowsta session on login.flowsta.com is theirs and stays; POST /auth/logout needs a Flowsta session token, not an OAuth access token, so a web app cannot end it.

typescript
auth.logout();

To also revoke the refresh token the SDK stored (it never uses it itself), do so before calling logout():

typescript
const refreshToken = localStorage.getItem('flowsta_refresh_token');
if (refreshToken) {
  await fetch('https://auth-api.flowsta.com/oauth/revoke', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token: refreshToken, token_type_hint: 'refresh_token' }),
  });
}
auth.logout();

detectVault() ​

Check if Flowsta Vault is running locally. Since v2.4.0 it sweeps 127.0.0.1:27777-27779 (a second Vault instance shifts ports) and caches the winning URL for later calls. Since v2.5.0 the result also says when the browser is what stood in the way.

typescript
const vault = await auth.detectVault();
// { running: boolean, blocked?: boolean, agentPubKey?: string, did?: string }
if (vault.running) {
  console.log('Vault agent:', vault.agentPubKey);
} else if (vault.blocked) {
  // 2.5.0: Chrome 142+ Local Network Access permission denied (or Brave's
  // localhost block). The Vault may be running - the page can't reach it.
  // Point at the browser's site settings; never say "install the Vault".
}

Which browsers reach the Vault

Chromium (Chrome 142+ behind a one-time "connect to devices on your local network" prompt) and Firefox reach 127.0.0.1 from an https page. Safari blocks it, Brave blocks it silently unless your site is on Brave's allowlist, and phones have no Vault - use the relay login for those. loopbackPermissionState() (2.5.0) tells you what the browser has decided, where it can.

getLinkedAgents(agentPubKey?) ​

Deprecated in 2.6.0 - removed in 3.0

This lookup needs a Flowsta session token. The SDK sends your OAuth access token, and the API answers 401 for an OAuth app, so the call fails for every web app. Holochain apps read links on their own DHT through @flowsta/holochain.

typescript
// Deprecated - rejects with 'Invalid token' for an OAuth app
const agents = await auth.getLinkedAgents();

areAgentsLinked(agentA, agentB) ​

Deprecated in 2.6.0 - removed in 3.0

Same limitation as getLinkedAgents(): needs a Flowsta session token, so for an OAuth app it resolves to false (the 401 is swallowed).

typescript
// Deprecated - always false for an OAuth app
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, mismatch guard v2.4.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. The Vault returns a local signature to your app (agent_pub_key, signed_at, success: true) with action_hash: null: the signature is not on the Sign It network. Publishing through the Vault is available to Flowsta pages and linked apps only, so people publish from their Vault. Keep what you receive, and never promise that verifyFile() - which reads the network - will find it.
  • Vault running under a DIFFERENT identity - the call throws VaultIdentityMismatchError (v2.4.0): a signature must never come from someone else's Vault. Tell the user to switch the Vault to this identity - never fall back to the remote path.
  • No Vault reachable - the SDK tries the legacy API path (POST /api/v1/sign-it/sign), which only a legacy custodial account can use. For device-hosted identities (the norm) that fails, and the call throws VaultRequiredError - or VaultBlockedError (v2.5.0) when the browser is what blocked the loopback request. Tell the user to open (or install) Flowsta Vault, or to allow local network access in the browser's site settings.

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> }

contentRights keys: license, aiTraining, contactPreference, commercialLicensing (snake_case spellings are accepted too). Since 2.6.0 the camelCase keys are translated on the wire to what the Vault and the API read - before 2.6.0 they passed through unchanged and were dropped, so the signature carried no rights. contentRightsForWire() is exported if you build the payload yourself.

Returns:

typescript
{
  success: boolean;
  file_hash: string;
  agent_pub_key: string;      // signer's Holochain agent key (uhCAk...)
  signed_at: number | string; // ISO 8601 string from the Vault; Unix seconds from the legacy API
  action_hash: string | null; // null for a web app: the signature is local, not published to the Sign It network
}

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. When a Vault is running, signBatch() throws FlowstaAuthError with code 'batch_requires_individual_approval' - call signFile() per file instead (each shows its own Vault prompt). With no Vault reachable it calls the legacy batch API, which only a legacy custodial account can use; for device-hosted identities that ends in VaultRequiredError (or VaultBlockedError).

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 on the Sign It network. Public endpoint - no authentication required, but authenticated requests with the verify scope get higher rate limits. A signature your web app obtained through signFile() is local and not published, so it does not appear here; signatures people publish from their Vault do.

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 | string;   // Unix seconds from the API; ISO 8601 string for Vault-published records
    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)

agentKeysMatch(a, b) - utility export (2.4.0) ​

Compare two Holochain agent keys across the encodings they actually travel in - uhCAk + base64url (Vault /status) and uhCAk + base58 (/auth/me) - so a string compare never false-mismatches. Returns true (same key), false (definitely different), or null (one side can't be decoded - refusal requires certainty). This is the comparison signFile() uses before refusing with VaultIdentityMismatchError.

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

if (agentKeysMatch(sessionKey, vaultKey) === false) {
  // Definitely two different identities.
}

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
VaultBlockedError (2.5.0)'vault_blocked'The browser refused this page's request to the Vault's loopback address (Local Network Access permission denied; Brave). The Vault may be running. Tell the user to allow it in the browser's site settings - not to install the Vault
VaultIdentityMismatchError (2.4.0)'vault_identity_mismatch'The local Vault definitely holds a different identity than the signed-in session (carries sessionAgentPubKey/vaultAgentPubKey). Tell the user to switch Vaults or sign in as the Vault's identity - never fall back to remote signing
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), 'batch_requires_individual_approval' (see signBatch()), 'vault_signing_failed' (the Vault refused for another reason - the message says why), and 'signing_failed' (the legacy API refused a legacy custodial account's request). A Vault that reports itself locked surfaces as VaultRequiredError with the message "Flowsta Vault is locked. Ask the user to unlock it and try again."

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

try {
  const result = await auth.signFile({ fileHash: await hashFile(file) });
  showSuccess(result.file_hash);
} catch (err) {
  if (err instanceof VaultIdentityMismatchError) {
    // The Vault on this machine belongs to a DIFFERENT identity than the
    // one signed in here. Actionable, not generic: name the fix.
    showInfo('Your Flowsta Vault is signed in as a different identity - unlock the matching Vault, or sign in here as that identity.');
  } else 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;
  /**
   * Present only when the 'email' scope was granted AND the user shared
   * an address at the consent screen (verified addresses only; gone once
   * they revoke your app). A legacy custodial account may return its
   * stored address. Design for its absence.
   */
  email?: string;
  username?: string;
  displayName?: string;
  profilePicture?: string;           // usually a data: URI, sometimes a URL
  agentPubKey?: string;              // Holochain agent public key (public_key or holochain scope)
  did?: string;                      // W3C Decentralized Identifier
  linkedAgents?: LinkedAgent[];      // Declared on the type; handleCallback() never fills it
  /**
   * 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 and reachable
  blocked?: boolean;       // true when running is false because the BROWSER refused the loopback request (2.5.0)
  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 plain Errors whose messages you can match on. When Flowsta answers with an error parameter (the user chose Don't allow), the message is its error_description - User denied authorization. When the token exchange fails, the message is the server's error_description (for example Authorization code is invalid, expired, or already used); Token exchange failed appears only when the server sent none.

typescript
try {
  const user = await auth.handleCallback();
} catch (error) {
  switch (true) {
    case error.message.includes('User denied authorization'):
      // The user chose "Don't allow" on the consent screen
      break;
    case error.message.includes('No authorization code'):
      // The callback URL carried neither a code nor an error
      break;
    case error.message.includes('Invalid state'):
      // CSRF attack or the stored state is gone (new tab, cleared storage)
      break;
    case error.message.includes('Missing PKCE code verifier'):
      // Session storage was cleared before the callback (e.g. different browser tab)
      break;
    case error.message.includes('Authorization code is invalid'):
      // Code expired (10 minute limit), already used, or the exchange was retried
      break;
    case error.message.includes('Token exchange failed'):
      // Server error with no error_description
      break;
    case error.message.includes('Failed to fetch user info'):
      // Token was issued but the 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 - The SDK keeps the access token until you call logout(); it does not check the 24-hour expiry itself and never uses the refresh token it stores. Treat a 401 from the API as signed out: call logout() and send the user back through login()
  • Sign-out is per site - logout() clears this site's local state only; the user's Flowsta session on login.flowsta.com stays, and the SDK cannot end it (POST /auth/logout needs a Flowsta session token, not an OAuth access token)
  • One session, one identity - a session belongs to the identity that signed in. Switching identity in the Vault does not end or move it; the next login() follows the Vault. Key your records on user.id (the OAuth sub), and if you want to notice a switch, compare detectVault().agentPubKey with user.agentPubKey via agentKeysMatch() and offer a new sign-in - never switch automatically

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.