@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
npm install @flowsta/authQuick Start
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
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.
await auth.login();handleCallback()
Handle the OAuth callback and retrieve user data. Call this on your redirect URI page.
const user = await auth.handleCallback();isAuthenticated()
Check if user is logged in.
if (auth.isAuthenticated()) {
const user = auth.getUser();
}getUser()
Get the current user profile.
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.
const token = auth.getAccessToken();getState()
Get the full authentication state.
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.
auth.logout();To also revoke the refresh token the SDK stored (it never uses it itself), do so before calling logout():
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.
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.
// 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).
// 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) withaction_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 thatverifyFile()- 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 throwsVaultRequiredError- orVaultBlockedError(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.
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:
{
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).
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:
{
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.
const result = await auth.verifyFile(hash);
if (result.count > 0) {
console.log('Signed by', result.signatures[0].signer);
}Returns:
{
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).
const rights = await auth.getContentRights(hash);Returns:
{
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.
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.
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:
| Class | code | When |
|---|---|---|
FlowstaAuthError | varies | Base 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."
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
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
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.
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.
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_verifierandcode_challengefor each login - State parameter - Random state stored in
sessionStoragefor CSRF protection - Secure storage - Access token in
localStorage, PKCE/state insessionStorage(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 a401from the API as signed out: calllogout()and send the user back throughlogin() - Sign-out is per site -
logout()clears this site's local state only; the user's Flowsta session onlogin.flowsta.comstays, and the SDK cannot end it (POST /auth/logoutneeds 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 onuser.id(the OAuthsub), and if you want to notice a switch, comparedetectVault().agentPubKeywithuser.agentPubKeyviaagentKeysMatch()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):
// No longer supported
await auth.login(email, password);After (SDK 2.0+):
// OAuth redirect
auth.login(); // Redirects to login.flowsta.com
const user = await auth.handleCallback();Next Steps
- Auth Overview - OAuth flow documentation
- Login Button Widget - Pre-built buttons
- OAuth API Reference - REST endpoints
- Security Guide - Best practices