Skip to main content

Authentication and authorization

Overview

Chive's authentication and authorization system is built on ATProto's DID-based identity model. The system provides:

  1. DID-based authentication: Identity verification via AT Protocol DIDs
  2. JWT session management: Stateless access tokens with Redis-backed sessions
  3. RBAC authorization: Role-based access control via Casbin
  4. OAuth 2.0 + PKCE: Secure third-party application authorization
  5. Multi-factor authentication: TOTP and backup codes
  6. WebAuthn/Passkeys: Passwordless authentication
  7. Zero-trust architecture: NIST SP 800-207 compliant security model

Architecture

Directory structure

src/auth/
├── index.ts # Barrel exports
├── errors.ts # Auth-specific error types
├── authentication-service.ts # Main authentication orchestration
├── did/
│ ├── did-resolver.ts # DID document resolution
│ ├── did-verifier.ts # Signature verification
│ └── index.ts
├── jwt/
│ ├── jwt-service.ts # Token issuance/verification
│ ├── key-manager.ts # ES256 key management
│ └── index.ts
├── session/
│ ├── session-manager.ts # Redis session store
│ └── index.ts
├── authorization/
│ ├── authorization-service.ts # Casbin RBAC
│ ├── policies/
│ │ └── model.conf # Casbin model
│ └── index.ts
├── oauth/
│ ├── oauth-service.ts # OAuth 2.0 flows
│ ├── pkce.ts # PKCE utilities
│ └── index.ts
├── webauthn/
│ ├── webauthn-service.ts # Passkey operations
│ └── index.ts
├── mfa/
│ ├── mfa-service.ts # MFA orchestration
│ └── index.ts
└── zero-trust/
├── zero-trust-service.ts # Trust evaluation
└── index.ts

Quick start

Authenticating users

import { AuthenticationService } from '@/auth/authentication-service.js';
import { DIDResolver } from '@/auth/did/did-resolver.js';
import { JWTService } from '@/auth/jwt/jwt-service.js';
import { SessionManager } from '@/auth/session/session-manager.js';

// Create services
const didResolver = new DIDResolver({ redis, logger });
const jwtService = new JWTService({ keyManager, redis, logger });
const sessionManager = new SessionManager({ redis, logger });

const authService = new AuthenticationService({
didResolver,
jwtService,
sessionManager,
logger,
});

// Authenticate with DID
const result = await authService.authenticate({
did: 'did:plc:abc123',
credential: {
type: 'app_password',
value: 'xxxx-xxxx-xxxx-xxxx',
},
metadata: {
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0...',
},
});

// Result includes session and tokens
console.log(result.session.id);
console.log(result.accessToken);
console.log(result.refreshToken);

Protecting routes

import { authenticate, requirePermission } from '@/api/middleware/auth.js';

const app = new Hono<ChiveEnv>();

// Require authentication
app.use('/api/*', authenticate());

// Require specific permission
app.post('/api/v1/eprints', requirePermission('eprint', 'create'), async (c) => {
const user = c.get('user');
// User is authenticated and has eprint:create permission
});

DID resolution

Chive resolves ATProto DIDs to retrieve identity documents containing public keys for signature verification.

Supported DID methods

MethodFormatResolution
did:plcdid:plc:abc123...PLC Directory lookup
did:webdid:web:example.comHTTPS .well-known fetch

Usage

import { DIDResolver } from '@/auth/did/did-resolver.js';

const resolver = new DIDResolver({
redis,
logger,
config: {
plcDirectoryUrl: 'https://plc.directory',
cacheTtlSeconds: 300, // 5 minutes
},
});

// Resolve DID document
const didDocument = await resolver.resolve('did:plc:abc123');

// Get verification methods
const verificationMethods = didDocument.verificationMethod;

// Get PDS endpoint
const pdsEndpoint = await resolver.getPDSEndpoint('did:plc:abc123');

Caching

DID documents are cached in Redis with configurable TTL to reduce resolution latency and external API calls.

Key format: chive:did:document:{did}
Default TTL: 300 seconds (5 minutes)

JWT service

Token structure

Chive issues ES256-signed JWTs with the following claims:

ClaimDescription
subUser DID
issToken issuer (https://chive.pub)
audToken audience (https://chive.pub)
iatIssued at timestamp
expExpiration timestamp
jtiUnique token ID
sessionIdAssociated session ID
scopeSpace-separated scopes

Issuing tokens

import { JWTService } from '@/auth/jwt/jwt-service.js';
import { KeyManager } from '@/auth/jwt/key-manager.js';

const keyManager = new KeyManager({ redis, logger });
const jwtService = new JWTService({
keyManager,
redis,
logger,
config: {
issuer: 'https://chive.pub',
audience: 'https://chive.pub',
accessTokenExpirationSeconds: 3600, // 1 hour
},
});

const { token, jti, expiresAt } = await jwtService.issueToken({
subject: 'did:plc:abc123',
sessionId: 'sess_xyz',
scopes: ['read:eprints', 'write:reviews'],
});

Verifying tokens

try {
const { claims } = await jwtService.verifyToken(token);
console.log(claims.sub); // did:plc:abc123
console.log(claims.sessionId); // sess_xyz
console.log(claims.scope); // 'read:eprints write:reviews'
} catch (error) {
if (error instanceof TokenExpiredError) {
// Token has expired
} else if (error instanceof TokenValidationError) {
// Token is invalid
}
}

Token revocation

// Revoke a specific token
await jwtService.revokeToken(jti, expiresAt);

// Token is now blacklisted until expiration
await jwtService.verifyToken(token); // Throws TokenRevokedError

Key rotation

Keys are automatically rotated based on configuration:

const keyManager = new KeyManager({
redis,
logger,
config: {
rotationIntervalSeconds: 7776000, // 90 days
overlapPeriodSeconds: 86400, // 24 hours
},
});

During the overlap period, both old and new keys are valid for verification, so tokens signed with the previous key continue to work until the overlap expires.


Session management

Session lifecycle

Creating sessions

import { SessionManager } from '@/auth/session/session-manager.js';

const sessionManager = new SessionManager({
redis,
logger,
config: {
sessionExpirationSeconds: 2592000, // 30 days
maxSessionsPerUser: 10,
},
});

const session = await sessionManager.createSession('did:plc:abc123', {
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0...',
deviceId: 'device_xyz',
scope: ['read:eprints', 'write:reviews'],
});

Session operations

// Get session
const session = await sessionManager.getSession(sessionId);

// Update session (sliding expiration)
await sessionManager.updateSession(sessionId, {
lastActivity: new Date(),
});

// Revoke single session
await sessionManager.revokeSession(sessionId);

// Revoke all sessions (logout everywhere)
await sessionManager.revokeAllSessions('did:plc:abc123');

// List active sessions
const sessions = await sessionManager.listSessions('did:plc:abc123');

Redis storage

Session key:     chive:session:{sessionId}
User index: chive:user:sessions:{did}
Token blacklist: chive:token:revoked:{jti}

Authorization (RBAC)

Role hierarchy

Permission model

Permissions follow the format {resource}:{action}:

ResourceActions
eprintcreate, read, update, delete, admin
reviewcreate, read, update, delete
graphpropose, vote, approve, admin
userread, update, admin

Usage

import { AuthorizationService } from '@/auth/authorization/authorization-service.js';

const authzService = new AuthorizationService({ redis, logger });
await authzService.initialize();

// Assign role
await authzService.assignRole('did:plc:abc123', 'author');

// Check authorization
const result = await authzService.authorize({
subject: { did: 'did:plc:abc123', roles: ['author'] },
action: 'create',
resource: { type: 'eprint' },
});

if (result.allowed) {
// Proceed with action
} else {
// Access denied: result.reason
}

// Resource owner check
const ownerResult = await authzService.authorize({
subject: { did: 'did:plc:abc123', roles: ['author'] },
action: 'update',
resource: {
type: 'eprint',
ownerDid: 'did:plc:abc123', // Same as subject
},
});
// ownerResult.allowed === true
// ownerResult.reason === 'resource_owner'

Casbin policy model

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

ATProto OAuth

Chive uses the ATProto OAuth specification for authentication, which includes PKCE and DPoP automatically.

Client setup

import { ATProtoOAuthClient } from '@/auth/atproto-oauth/node-oauth-client.js';

const oauthClient = new ATProtoOAuthClient({
clientMetadata: {
clientId: 'https://chive.pub/oauth/client-metadata.json',
redirectUri: 'https://chive.pub/oauth/callback',
scopes: ['atproto', 'transition:generic'],
},
redis,
logger,
});

await oauthClient.initialize();

Authentication flow

The @atproto/oauth-client-node library handles the OAuth flow including:

  • PKCE code challenges (S256)
  • DPoP token binding
  • Pushed Authorization Requests (PAR)
  • Session management
// 1. Generate authorization URL for a PDS handle
const authUrl = await oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});

// 2. User authorizes at their PDS

// 3. Handle callback with authorization code
const session = await oauthClient.callback(callbackParams);

// Session provides authenticated agent for PDS operations
const agent = session.agent;

Session storage

Sessions are persisted in Redis:

  • RedisSessionStore - Stores authenticated sessions
  • RedisStateStore - Stores OAuth state parameters during flow

WebAuthn (Passkeys)

Planned

WebAuthn/Passkey support is not yet implemented. The API surface below reflects the planned design.

Registration flow

import { WebAuthnService } from '@/auth/webauthn/webauthn-service.js';

const webauthnService = new WebAuthnService({
redis,
logger,
config: {
rpId: 'chive.pub',
rpName: 'Chive',
origin: 'https://chive.pub',
},
});

// 1. Generate registration challenge
const challenge = await webauthnService.generateRegistrationChallenge('did:plc:abc123');

// 2. Client creates credential (browser WebAuthn API)

// 3. Verify registration
const credential = await webauthnService.verifyRegistration('did:plc:abc123', {
challenge,
credential: clientResponse,
});

Authentication flow

// 1. Generate authentication challenge
const challenge = await webauthnService.generateAuthenticationChallenge('did:plc:abc123');

// 2. Client authenticates (browser WebAuthn API)

// 3. Verify authentication
const result = await webauthnService.verifyAuthentication('did:plc:abc123', {
challenge,
credential: clientResponse,
});

Multi-factor authentication

Planned

MFA support (TOTP and backup codes) is not yet implemented. The API surface below reflects the planned design.

TOTP enrollment

import { MFAService } from '@/auth/mfa/mfa-service.js';

const mfaService = new MFAService({ redis, logger });

// 1. Start enrollment
const { secret, qrCodeUri } = await mfaService.enrollTOTP('did:plc:abc123');

// 2. User scans QR code in authenticator app

// 3. Verify enrollment with code
await mfaService.verifyTOTPEnrollment('did:plc:abc123', '123456');

Verification

// Verify TOTP or backup code
const result = await mfaService.verifyMFA('did:plc:abc123', {
type: 'totp',
code: '123456',
});

if (!result.success) {
console.log(result.error); // 'invalid_code' | 'expired_code' | etc.
}

Backup codes

// Generate backup codes
const codes = await mfaService.regenerateBackupCodes('did:plc:abc123');
// Returns array of 10 single-use codes

// Verify backup code
const result = await mfaService.verifyMFA('did:plc:abc123', {
type: 'backup_code',
code: 'XXXX-XXXX-XXXX',
});

Zero-trust architecture

Planned

The zero-trust evaluation service is not yet implemented. The API surface below reflects the planned design.

Trust evaluation

import { ZeroTrustService } from '@/auth/zero-trust/zero-trust-service.js';

const zeroTrustService = new ZeroTrustService({ redis, logger });

const decision = await zeroTrustService.evaluate({
subject: {
did: 'did:plc:abc123',
sessionId: 'sess_xyz',
authMethod: 'webauthn',
authTime: new Date(),
mfaVerified: true,
roles: ['author'],
},
resource: {
type: 'eprint',
sensitivity: 'high',
},
context: {
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0...',
deviceId: 'device_xyz',
requestTime: new Date(),
geoLocation: 'US',
},
});

if (decision.allow) {
// Proceed with request
} else {
// decision.reason explains why access was denied
// decision.requiredActions lists remediation steps
}

Trust score components

ComponentWeightDescription
Authentication0.3Auth method strength, MFA status
Device Posture0.25Known device, security state
Behavior Analysis0.25Anomaly detection, risk signals
Network Context0.2IP reputation, geolocation

Paper PDS authentication

Eprints can be stored in two ways: in the submitter's personal PDS (traditional model) or in a dedicated paper account PDS (paper-centric model). Edit and delete operations must authenticate against whichever PDS holds the record.

How it works

  1. The frontend checks whether the eprint has a paperDid field
  2. If paperDid is set, the PaperAuthGate component prompts the user to authenticate as the paper account before allowing modifications
  3. If paperDid is not set, the user's normal session is used

PaperAuthGate component

PaperAuthGate (web/components/eprints/paper-auth-gate.tsx) wraps edit and delete actions. It uses a render function pattern:

import { PaperAuthGate } from '@/components/eprints/paper-auth-gate';

<PaperAuthGate eprint={eprint}>
{(paperAgent) => <EditButton eprint={eprint} paperAgent={paperAgent} />}
</PaperAuthGate>;

For traditional eprints, paperAgent is null and children render immediately. For paper-centric eprints, the auth prompt appears first.

Backend authorization

The updateSubmission and deleteSubmission XRPC handlers validate authorization server-side. Authorization is granted to:

  • The original submitter (by DID)
  • The paper account (if paper-centric, by paperDid)
  • Any author whose DID appears in the eprint's authors array

The handlers do not write to the PDS; they validate permissions and return the information the frontend needs to make the actual PDS call.

Auth error messages

All authentication and authorization errors use a consistent format across the codebase. The error types from src/auth/errors.ts produce user-facing messages:

Error classUser-facing message pattern
AuthenticationError"Authentication required" or "Invalid credentials"
AuthorizationError"You do not have permission to {action} this {resource}"
TokenExpiredError"Your session has expired. Please sign in again."
SessionRevokedError"Your session has been revoked."

For paper PDS operations, the auth prompt displays: "This paper is managed by a dedicated paper account. Please authenticate as that account to make changes."

Error handling

Error types

import {
AuthenticationError,
TokenValidationError,
TokenExpiredError,
SessionRevokedError,
MFARequiredError,
WebAuthnError,
} from '@/auth/errors.js';

Error codes

CodeDescription
invalid_credentialsCredentials verification failed
invalid_signatureJWT signature verification failed
token_expiredJWT has expired
token_revokedJWT has been revoked
session_revokedSession has been revoked
mfa_requiredMFA verification required
invalid_codeMFA code is invalid

Database schema

PostgreSQL tables

-- User roles (RBAC)
CREATE TABLE user_roles (
did TEXT NOT NULL,
role TEXT NOT NULL,
assigned_at TIMESTAMPTZ DEFAULT NOW(),
assigned_by TEXT,
expires_at TIMESTAMPTZ,
PRIMARY KEY (did, role)
);

-- OAuth clients
CREATE TABLE oauth_clients (
client_id TEXT PRIMARY KEY,
client_secret_hash TEXT,
client_type TEXT NOT NULL,
name TEXT NOT NULL,
redirect_uris TEXT[] NOT NULL,
scopes TEXT[] NOT NULL,
owner_did TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- WebAuthn credentials
CREATE TABLE webauthn_credentials (
credential_id TEXT PRIMARY KEY,
did TEXT NOT NULL,
public_key TEXT NOT NULL,
counter BIGINT DEFAULT 0,
transports TEXT[],
nickname TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- MFA enrollments
CREATE TABLE mfa_enrollments (
did TEXT PRIMARY KEY,
totp_secret_encrypted TEXT,
totp_enabled BOOLEAN DEFAULT false,
backup_codes_hash TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Audit log
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL,
actor_did TEXT,
action TEXT NOT NULL,
result TEXT NOT NULL,
ip_address INET,
details JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);

Testing

Unit tests

# Run auth unit tests
npm run test:unit -- tests/unit/auth/

# Test specific service
npm run test:unit -- tests/unit/auth/jwt-service.test.ts

Integration tests

# Run auth integration tests
npm run test:integration -- tests/integration/auth/

Test fixtures

import { createMockLogger, createMockRedis } from '@tests/fixtures/mocks.js';

const logger = createMockLogger();
const redis = createMockRedis();

const service = new JWTService({ keyManager, redis, logger });

Security considerations

Token security

  • Access tokens expire after 1 hour
  • Refresh tokens are single-use
  • Token revocation is immediate via Redis blacklist
  • ES256 algorithm with 256-bit keys

Session security

  • Sessions expire after 30 days of inactivity
  • Maximum 10 sessions per user
  • IP and user agent tracked for anomaly detection
  • Session revocation cascades to all tokens

MFA security

  • TOTP secrets encrypted at rest (AES-256-GCM)
  • Backup codes hashed with SHA-256
  • Rate limiting on verification attempts
  • Lockout after repeated failures

WebAuthn security

  • Credential counter tracking for replay detection
  • Attestation verification (none, direct)
  • Challenge expiration (5 minutes)

Next steps