Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ VITE_ENABLE_TELEMETRY=false

# Optional app version tag for event properties
VITE_APP_VERSION=dev

# Optional trusted-proxy auth for Cloud Run / IAP deployments.
# When enabled, Proof can treat a verified upstream email header as hosted auth.
# Example:
# PROOF_TRUST_PROXY_HEADERS=true
# PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS=x-goog-authenticated-user-email
# PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS=elastic.co
# PROOF_SHARE_MARKDOWN_AUTH_MODE=oauth
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@ npm test
- `docs/proof.SKILL.md`
- `docs/adr/2026-03-proof-sdk-public-core.md`

## Cloud Run + IAP

If you deploy Proof SDK behind Cloud Run + Identity-Aware Proxy, the proxy must admit the caller before Proof's own share-token auth runs.

For agent traffic, you can now configure Proof to trust an upstream verified email header:

```bash
PROOF_TRUST_PROXY_HEADERS=true
PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS=x-goog-authenticated-user-email
PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS=elastic.co
PROOF_SHARE_MARKDOWN_AUTH_MODE=oauth
```

- Cloud Run / IAP will authenticate the caller and inject `x-goog-authenticated-user-email`.
- Proof SDK will accept that trusted header for hosted-auth flows such as `POST /api/share/markdown`.
- New shares created through that path always use the authenticated trusted email as `ownerId`; mismatched `ownerId` values are rejected.
- Share/document routes still use the normal Proof share tokens (`Authorization: Bearer <token>` or `x-share-token`) once the request has passed IAP.

See `docs/agent-docs.md` for the full agent-side flow.

## License

- Code: `MIT` in `LICENSE`
Expand Down
41 changes: 41 additions & 0 deletions docs/agent-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,47 @@ If a URL contains `?token=`, treat it as an access token:
- Preferred: `Authorization: Bearer <token>`
- Also accepted: `x-share-token: <token>`

## Cloud Run Behind IAP

If you place Proof SDK behind Cloud Run + Identity-Aware Proxy, agents must satisfy the IAP layer **before** they can use Proof share tokens.

Recommended configuration:

```bash
PROOF_TRUST_PROXY_HEADERS=true
PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS=x-goog-authenticated-user-email
PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS=elastic.co
PROOF_SHARE_MARKDOWN_AUTH_MODE=oauth
```

What this does:

- IAP authenticates the caller and injects `x-goog-authenticated-user-email`.
- Proof SDK trusts that header only when `PROOF_TRUST_PROXY_HEADERS=true` and the email matches `PROOF_TRUSTED_IDENTITY_EMAILS` or `PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS`.
- `POST /api/share/markdown` and other hosted-auth checks can use that trusted identity without a separate Proof OAuth session.
- When a direct share is created this way, the document owner is always the authenticated trusted email; mismatched `ownerId` values are rejected.

Example request from an agent that already has IAP access:

```bash
curl -X POST "https://your-proof.example.com/api/share/markdown" \
-H "Content-Type: application/json" \
-H "X-Goog-Authenticated-User-Email: accounts.google.com:agent@elastic.co" \
-d '{"markdown":"# Hello from IAP"}'
```

After the request passes IAP, the returned Proof share token works the same as any other deployment:

```bash
curl -H "Authorization: Bearer <access-token>" \
"https://your-proof.example.com/documents/<slug>/state"
```

Notes:

- This is intended for trusted reverse proxies such as IAP. Do **not** enable it on a public origin without a proxy that strips/spoofs those headers.
- External agents that cannot authenticate to IAP will still be blocked at the IAP layer. In that case, either give the agent an allowed IAP identity/service account or expose a separate non-IAP agent ingress.

## Edit Via Ops (Comments, Suggestions, Rewrite)

Use:
Expand Down
6 changes: 5 additions & 1 deletion server/discovery-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
import { readFileSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { resolveShareMarkdownAuthMode } from './hosted-auth.js';
import { getTrustedProxyIdentityConfig, resolveShareMarkdownAuthMode } from './hosted-auth.js';
import {
AGENT_DOCS_PATH,
ALT_SHARE_TOKEN_HEADER_FORMAT,
Expand Down Expand Up @@ -76,13 +76,17 @@ discoveryRoutes.get('/.well-known/agent.json', (req: Request, res: Response) =>
const shareBase = base || '';

const authMode = resolveShareMarkdownAuthMode(base);
const trustedProxyIdentity = getTrustedProxyIdentityConfig();
const authMethods = authMode === 'none'
? ['none']
: authMode === 'api_key'
? ['api_key']
: authMode === 'oauth_or_api_key'
? ['api_key', 'oauth']
: ['oauth'];
if (trustedProxyIdentity.enabled) {
authMethods.push('trusted_proxy_email');
}

res.setHeader('Cache-Control', 'public, max-age=300');
res.json({
Expand Down
84 changes: 84 additions & 0 deletions server/hosted-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,90 @@ export type ShareMarkdownAuthMode = 'none' | 'api_key' | 'oauth' | 'oauth_or_api

type PendingAuthStatus = 'pending' | 'completed' | 'failed';

export type TrustedProxyIdentityPrincipal = {
provider: 'trusted_proxy_email';
email: string;
actor: string;
ownerId: string;
header: string;
};

export type TrustedProxyIdentityConfig = {
enabled: boolean;
emailHeaders: string[];
allowedEmails: string[];
allowedDomains: string[];
};

function parseCsvEnv(value: string | undefined): string[] {
return (value || '')
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}

function trustProxyHeaders(): boolean {
const value = (process.env.PROOF_TRUST_PROXY_HEADERS || '').trim().toLowerCase();
return value === '1' || value === 'true' || value === 'yes';
}

function normalizeTrustedIdentityEmail(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const withoutScheme = trimmed.replace(/^mailto:/i, '');
const suffix = withoutScheme.includes(':')
? withoutScheme.slice(withoutScheme.lastIndexOf(':') + 1)
: withoutScheme;
const normalized = suffix.trim().toLowerCase();
if (!normalized || !normalized.includes('@') || normalized.startsWith('@') || normalized.endsWith('@')) {
return null;
}
return normalized;
}

function isTrustedIdentityEmailAllowed(email: string, config: TrustedProxyIdentityConfig): boolean {
if (config.allowedEmails.includes(email)) return true;
const domain = email.split('@')[1];
return domain ? config.allowedDomains.includes(domain) : false;
}

export function getTrustedProxyIdentityConfig(): TrustedProxyIdentityConfig {
const emailHeaders = parseCsvEnv(
process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS
|| 'x-goog-authenticated-user-email',
);
const allowedEmails = parseCsvEnv(process.env.PROOF_TRUSTED_IDENTITY_EMAILS);
const allowedDomains = parseCsvEnv(process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS);
return {
enabled: trustProxyHeaders() && emailHeaders.length > 0 && (allowedEmails.length > 0 || allowedDomains.length > 0),
emailHeaders,
allowedEmails,
allowedDomains,
};
}

export function resolveTrustedProxyIdentity(input: {
header(name: string): string | string[] | undefined | null;
}): TrustedProxyIdentityPrincipal | null {
const config = getTrustedProxyIdentityConfig();
if (!config.enabled) return null;
for (const headerName of config.emailHeaders) {
const raw = input.header(headerName);
const firstValue = Array.isArray(raw) ? raw[0] : raw;
if (typeof firstValue !== 'string' || !firstValue.trim()) continue;
const email = normalizeTrustedIdentityEmail(firstValue);
if (!email || !isTrustedIdentityEmailAllowed(email, config)) continue;
return {
provider: 'trusted_proxy_email',
email,
actor: `email:${email}`,
ownerId: email,
header: headerName,
};
}
return null;
}

export function isOAuthConfigured(_publicBaseUrl?: string): boolean {
return false;
}
Expand Down
Loading