@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
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
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.
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?, 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.
const token = auth.getAccessToken();getState()
Get the full authentication state.
const state = auth.getState();
// { isAuthenticated, user, accessToken, isLoading, error }logout()
Clear the local session.
auth.logout();detectVault()
Check if Flowsta Vault is running locally.
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.
// 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.
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.
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:
{
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).
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. Public endpoint - no authentication required, but authenticated requests with the verify scope get higher rate limits.
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;
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)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 |
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()).
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
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
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 specific errors you can catch:
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_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 - 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):
// 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