Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .agent/context/20260909T190000Z-privy-auth-ops-landing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Session Context: Privy operator authentication

## Date/time

- UTC: 2026-09-09T22:34:33Z

## User goal

Implement the Privy operator-authentication plan through validation, mandatory review gates, and a draft pull request.

## Original prompt/request

Complete `2026-09-09-privy-operator-auth.md` from the current Task 5/10 state through all remaining work and required gates.

## Assumptions

- The named `feature/privy-operator-auth` branch is the intended short-lived branch.
- Tasks 1–4 are missing from the branch and must be completed before Tasks 7–10.
- Privy operator login is separate from the existing Privy wallet-authorization boundary.

## Plan

1. Implement and test missing API authentication Tasks 1–4.
2. Complete web Tasks 7–8 and operator documentation.
3. Run full validation, Gate A, draft PR CI, and Gate B.

## Key decisions

- Route JWT-shaped credentials only to the Privy verifier and opaque credentials only to the existing constant-time service-token verifier.
- Keep operator access tokens in memory and never render or log them.
- Preserve the existing service bearer for worker and agent clients.

## Files/components touched

- `apps/api/src/privy-auth.ts`, `auth.ts`, `config.ts`, and `runtime.ts`: ES256 verification, credential routing, fail-closed configuration, and runtime composition.
- `apps/api/test`: JWT rejection, allowlist, configuration, routing, runtime, logging, and forbidden-ledger boundary coverage.
- `apps/web/src/auth`, `LoginGate.tsx`, `App.tsx`, and `main.tsx`: in-memory operator session, gated console, credential selection, and lazy Privy provider.
- `apps/web/test` and `apps/web/browser`: unit, composition, credential-header, storage, token-leak, and Playwright gate coverage.
- `apps/web/package.json`, `pnpm-lock.yaml`, and `pnpm-workspace.yaml`: official React SDK plus strict-peer-compatible transitive pins and denied optional native build scripts.
- `.env.example`, `apps/web/.env.example`, and `README.md`: safe placeholders and operator bootstrap documentation.

## Commands/checks

- Task 5: 4 focused tests, typecheck, lint, and format passed.
- Task 6: 7 focused tests; full web suite 46/46; typecheck, lint, and format passed.
- API focused suite: 40/40 tests passed, including forbidden reconciliation never reaching the ledger.
- Web focused suite: 50/50 tests passed; Playwright: 7/7 passed.
- `pnpm peers check` - passed with no peer dependency issues.
- `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, and `pnpm check:generated` - passed.
- `pnpm test` - passed: 64 files, 942 tests.
- `pnpm --filter @oneshot/web test:browser` - passed: 7 tests.
- `pnpm --filter @oneshot/web build` - passed; Privy emitted as a separate lazy chunk.

## External-doc findings

- Privy access-token documentation checked 2026-09-09: access tokens are ES256 JWTs with `privy.io` issuer, app-id audience, Privy DID subject, and roughly one-hour lifetime; `getAccessToken` refreshes when needed.
- Privy login UI documentation checked 2026-09-09: React authentication state is exposed through `usePrivy`; configured login methods include email and wallet.
- Privy dashboard documentation checked 2026-09-09: the public verification key is available under Configuration, App settings, Basics, “Verify with key instead.”

## Unresolved questions

- Local Node is 22.23.2 while the repository requires 24.19.0; all checks passed with an engine warning, and CI must confirm on the required version.
- Planned React SDK 3.41.0 was younger than the 24-hour supply-chain threshold and introduced an unsatisfied Solana/TypeScript peer graph. Version 3.6.1 is the newest tested version before that dependency break; upgrade after a compatible release clears policy.

## Git and PR state

- Branch: `feature/privy-operator-auth`
- Base: `origin/develop` at `78b9d74bd4b111a6418485baff6379464325960c`
- Commit: `dd424c7e6651539f8c0b0d0abf9347393c00d6b2`
- PR: not created
- CI: not run

## Review gates

- Gate A: NOT RUN
- Gate B: NOT RUN

## Handoff/next steps

1. Commit operator documentation, run Gate A, push a draft PR, wait for CI, then run Gate B.
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ ONESHOT_SETTLEMENT_CAP_ATOMIC=1000000
ONESHOT_RPC_TIMEOUT_MS=10000
ONESHOT_ALLOW_MAINNET_ACTIVATION=false

# Operator login boundary. Separate from the ONESHOT_PRIVY_* wallet-authorization
# variables above; both may reference the same Privy application.
# The verification key is the PUBLIC ES256 key from the Privy dashboard.
# Leave all three empty to keep the service-bearer-only behavior.
PRIVY_AUTH_APP_ID=<privy-app-id>
PRIVY_AUTH_VERIFICATION_KEY=<privy-es256-spki-public-key>
PRIVY_AUTH_ALLOWED_SUBJECTS=did:privy:<operator-did>

# Recovery discovery and advisory configuration. Graph evidence is candidate-only;
# Vertex recommendations cannot authorize or submit settlement.
ONESHOT_SUBGRAPH_MCP_ENDPOINT=https://<subgraph-mcp-host>
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,23 @@ Copy `.env.example` to `.env` and fill in placeholders. Never commit a real
secret; see `docs/settlement/SETTLEMENT_CONFIG_V1.md` for how each variable is
classified.

### Operator sign-in

Privy operator login is optional and separate from the Privy wallet and
settlement-policy adapter in `packages/privy-adapter`. Set all three API
variables together: `PRIVY_AUTH_APP_ID`, `PRIVY_AUTH_VERIFICATION_KEY`, and
`PRIVY_AUTH_ALLOWED_SUBJECTS`. A partial configuration makes the API refuse to
start. With none set, the API accepts only `SERVICE_BEARER_TOKEN`; worker and
agent clients continue to use that service credential.

Bootstrap an operator by setting `VITE_PRIVY_APP_ID`, starting the web app,
signing in, copying the DID shown by the console, adding that DID to
`PRIVY_AUTH_ALLOWED_SUBJECTS`, and then starting the API. Copy the public ES256
verification key from Privy Dashboard → Configuration → App settings → Basics
→ Verify with key instead into runtime environment configuration. Never commit
the key. This boundary does not use the Privy app secret; never add that secret
to its configuration.

To verify a configured Arc endpoint really is the chain and token you think it
is:

Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@oneshot/contracts": "workspace:*",
"@oneshot/storage-postgres": "workspace:*",
"fastify": "5.12.3",
"jose": "6.2.12",
"pg": "8.23.0"
},
"devDependencies": {
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,24 @@ export function staticBearerAuthenticator(expectedToken: string): ServiceAuthent
},
};
}

export interface CredentialRoute {
readonly matches: (authorization: string | undefined) => boolean;
readonly authenticator: ServiceAuthenticator;
}

export function compositeAuthenticator(routes: readonly CredentialRoute[]): ServiceAuthenticator {
return {
async authenticate(authorization) {
if (!authorization) return 'UNAUTHORIZED';
let sawForbidden = false;
for (const route of routes) {
if (!route.matches(authorization)) continue;
const decision = await route.authenticator.authenticate(authorization);
if (decision === 'AUTHORIZED') return 'AUTHORIZED';
if (decision === 'FORBIDDEN') sawForbidden = true;
}
return sawForbidden ? 'FORBIDDEN' : 'UNAUTHORIZED';
},
};
}
55 changes: 55 additions & 0 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { createPublicKey } from 'node:crypto';
import type { PoolConfig } from 'pg';
import { PRIVY_DID_PREFIX } from './privy-auth.js';

export interface PrivyAuthRuntimeConfig {
readonly appId: string;
readonly verificationKey: string;
readonly allowedSubjects: readonly string[];
}

export interface ApiRuntimeConfig {
readonly host: string;
readonly port: number;
readonly serviceBearerToken: string;
readonly database: PoolConfig;
readonly submissionsDisabled: boolean;
readonly privyAuth?: PrivyAuthRuntimeConfig;
}

function required(environment: NodeJS.ProcessEnv, name: string): string {
Expand Down Expand Up @@ -53,14 +62,60 @@ function databaseConfig(environment: NodeJS.ProcessEnv): PoolConfig {
};
}

function normalizeVerificationKey(raw: string): string {
const normalized = raw.replace(/\\n/g, '\n').trim();
const key = createPublicKey(normalized);
if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
throw new Error('PRIVY_AUTH_VERIFICATION_KEY must be an EC P-256 (ES256) public key');
}
return normalized;
}

function privyAuthConfig(environment: NodeJS.ProcessEnv): PrivyAuthRuntimeConfig | undefined {
const appId = environment.PRIVY_AUTH_APP_ID?.trim() ?? '';
const rawKey = environment.PRIVY_AUTH_VERIFICATION_KEY?.trim() ?? '';
const rawSubjects = environment.PRIVY_AUTH_ALLOWED_SUBJECTS?.trim() ?? '';
if (appId.length === 0 && rawKey.length === 0 && rawSubjects.length === 0) return undefined;

if (appId.length === 0)
throw new Error('Missing required environment variable: PRIVY_AUTH_APP_ID');
if (rawKey.length === 0) {
throw new Error('Missing required environment variable: PRIVY_AUTH_VERIFICATION_KEY');
}
if (rawSubjects.length === 0) {
throw new Error('Missing required environment variable: PRIVY_AUTH_ALLOWED_SUBJECTS');
}

const allowedSubjects = [
...new Set(
rawSubjects
.split(',')
.map((subject) => subject.trim())
.filter((subject) => subject.length > 0),
),
];
if (allowedSubjects.length === 0) {
throw new Error('PRIVY_AUTH_ALLOWED_SUBJECTS must list at least one Privy DID');
}
for (const subject of allowedSubjects) {
if (!subject.startsWith(PRIVY_DID_PREFIX)) {
throw new Error(`PRIVY_AUTH_ALLOWED_SUBJECTS entries must start with ${PRIVY_DID_PREFIX}`);
}
}

return { appId, verificationKey: normalizeVerificationKey(rawKey), allowedSubjects };
}

export function loadApiRuntimeConfig(
environment: NodeJS.ProcessEnv = process.env,
): ApiRuntimeConfig {
const privyAuth = privyAuthConfig(environment);
return {
host: environment.HOST?.trim() || '0.0.0.0',
port: integer(environment, 'PORT', 3000, 1, 65_535),
serviceBearerToken: required(environment, 'SERVICE_BEARER_TOKEN'),
database: databaseConfig(environment),
submissionsDisabled: environment.ONESHOT_SUBMISSIONS_DISABLED === 'true',
...(privyAuth ? { privyAuth } : {}),
};
}
1 change: 1 addition & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './app.js';
export * from './auth.js';
export * from './config.js';
export * from './privy-auth.js';
export * from './rate-limit.js';
export * from './runtime.js';
73 changes: 73 additions & 0 deletions apps/api/src/privy-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { importSPKI, jwtVerify } from 'jose';
import type { ServiceAuthenticator } from './auth.js';

export const PRIVY_ISSUER = 'privy.io';
export const PRIVY_DID_PREFIX = 'did:privy:';

const BEARER_PREFIX = 'Bearer ';
const BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;

export interface PrivyAccessTokenAuthenticatorConfig {
readonly appId: string;
readonly verificationKey: string;
readonly allowedSubjects: readonly string[];
readonly clockToleranceSeconds?: number;
readonly onForbiddenSubject?: (subject: string) => void;
}

export function looksLikeJwt(token: string): boolean {
const parts = token.split('.');
return parts.length === 3 && parts.every((part) => BASE64URL_SEGMENT.test(part));
}

function bearerToken(authorization: string | undefined): string | null {
if (!authorization?.startsWith(BEARER_PREFIX)) return null;
const token = authorization.slice(BEARER_PREFIX.length);
return token.length > 0 ? token : null;
}

export function isJwtCredential(authorization: string | undefined): boolean {
const token = bearerToken(authorization);
return token !== null && looksLikeJwt(token);
}

export function createPrivyAccessTokenAuthenticator(
config: PrivyAccessTokenAuthenticatorConfig,
): ServiceAuthenticator {
if (config.appId.trim().length === 0) throw new Error('Privy app id must not be empty');
if (config.allowedSubjects.length === 0) {
throw new Error('Privy operator allowlist must not be empty');
}
const allowed = new Set(config.allowedSubjects);
const clockTolerance = config.clockToleranceSeconds ?? 60;
const keyPromise = importSPKI(config.verificationKey, 'ES256');
// An unusable key must not become an unhandled rejection before the first request.
void keyPromise.catch(() => undefined);

return {
async authenticate(authorization) {
const token = bearerToken(authorization);
if (token === null || !looksLikeJwt(token)) return 'UNAUTHORIZED';

let subject: string | undefined;
try {
const { payload } = await jwtVerify(token, await keyPromise, {
algorithms: ['ES256'],
issuer: PRIVY_ISSUER,
audience: config.appId,
clockTolerance,
});
subject = payload.sub;
} catch {
return 'UNAUTHORIZED';
}

if (subject === undefined || subject.length === 0) return 'UNAUTHORIZED';
if (!allowed.has(subject)) {
config.onForbiddenSubject?.(subject);
return 'FORBIDDEN';
}
return 'AUTHORIZED';
},
};
}
29 changes: 27 additions & 2 deletions apps/api/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,39 @@ import { randomUUID } from 'node:crypto';
import { IntentLedger, migrate } from '@oneshot/storage-postgres';
import { Pool } from 'pg';
import { buildApi } from './app.js';
import { staticBearerAuthenticator } from './auth.js';
import {
compositeAuthenticator,
staticBearerAuthenticator,
type ServiceAuthenticator,
} from './auth.js';
import { loadApiRuntimeConfig, type ApiRuntimeConfig } from './config.js';
import { createPrivyAccessTokenAuthenticator, isJwtCredential } from './privy-auth.js';

export interface ApiRuntime {
readonly address: string;
close(): Promise<void>;
}

export function buildApiAuthenticator(
config: ApiRuntimeConfig,
log: (line: string) => void = (line) => process.stderr.write(line),
): ServiceAuthenticator {
const bearer = staticBearerAuthenticator(config.serviceBearerToken);
if (!config.privyAuth) return bearer;

const privy = createPrivyAccessTokenAuthenticator({
...config.privyAuth,
onForbiddenSubject: (subject) => {
log(`privy operator is not allowlisted: ${subject}\n`);
},
});

return compositeAuthenticator([
{ matches: isJwtCredential, authenticator: privy },
{ matches: (authorization) => !isJwtCredential(authorization), authenticator: bearer },
]);
}

export async function startApiRuntime(config: ApiRuntimeConfig): Promise<ApiRuntime> {
const pool = new Pool(config.database);
try {
Expand All @@ -20,7 +45,7 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise<ApiRunt
});
const app = buildApi({
ledger,
authenticator: staticBearerAuthenticator(config.serviceBearerToken),
authenticator: buildApiAuthenticator(config),
config: {
submissionsDisabled: config.submissionsDisabled,
chainId: '5042002',
Expand Down
Loading
Loading