已合并
WEB-01: define authenticated WebUI contracts #21
urandon创建于 8月5日
WEB-01: define authenticated WebUI contracts #21
已合并
共 19 个文件变更+2286-4
| @@ -37,8 +37,11 @@ delivery. A credential-free two-tenant black-box suite now composes the full | |||
| 37 | local Telegram-to-worker-to-Telegram path and its recovery cases. Provider | 37 | local Telegram-to-worker-to-Telegram path and its recovery cases. Provider |
| 38 | authorization, full multi-frontend projection, and subscription-backed Codex, | 38 | authorization, full multi-frontend projection, and subscription-backed Codex, |
| 39 | OpenCode, Claude, or Hermes adapters remain later implementation slices. | 39 | OpenCode, Claude, or Hermes adapters remain later implementation slices. |
| 40 | -Canonical sessions, ordered events, memberships, snapshots, activity indexes, | 40 | +Canonical sessions, ordered events, session participants, snapshots, activity indexes, |
| 41 | -and revisioned frontend bindings are persisted directly in YDB. | 41 | +and revisioned frontend bindings are persisted directly in YDB. The repository |
| 42 | +also freezes the WebUI OIDC, explicit enrollment, membership authorization, | ||
| 43 | +revocable web-session, CSRF, same-origin API, and upload-intent contracts; the | ||
| 44 | +Web BFF and its auth persistence remain the next implementation slice. | ||
| 42 | 45 | ||
| 43 | ## Components | 46 | ## Components |
| 44 | 47 | ||
| @@ -59,6 +62,8 @@ and revisioned frontend bindings are persisted directly in YDB. | |||
| 59 | identities, state machines, quota/usage semantics, outboxes, and artifacts; | 62 | identities, state machines, quota/usage semantics, outboxes, and artifacts; |
| 60 | - `internal/ports`: YDB/queue/blob/frontend/credential/harness-neutral runtime | 63 | - `internal/ports`: YDB/queue/blob/frontend/credential/harness-neutral runtime |
| 61 | interfaces; | 64 | interfaces; |
| 65 | +- `internal/webcontract`: same-origin WebUI request/response, secure-cookie, | ||
| 66 | + CSRF, and tenant-selector contracts without browser-side tenant authority; | ||
| 62 | - `internal/portlog`: process-boundary structured correlation logs without | 67 | - `internal/portlog`: process-boundary structured correlation logs without |
| 63 | payload or credential logging; | 68 | payload or credential logging; |
| 64 | - `internal/ydbstore`: serializable tenant-scoped YDB state and atomic | 69 | - `internal/ydbstore`: serializable tenant-scoped YDB state and atomic |
| @@ -88,7 +93,10 @@ This proves orchestration semantics without implying that a permanent harness | |||
| 88 | or subscription credential protocol has already been selected. | 93 | or subscription credential protocol has already been selected. |
| 89 | 94 | ||
| 90 | The contract invariants and transition tables are documented in | 95 | The contract invariants and transition tables are documented in |
| 91 | -[docs/contracts.md](docs/contracts.md). The architecture source of truth is | 96 | +[docs/contracts.md](docs/contracts.md). Web authentication and its focused |
| 97 | +threat model are documented in | ||
| 98 | +[docs/web-auth-contracts.md](docs/web-auth-contracts.md) and | ||
| 99 | +[docs/web-threat-model.md](docs/web-threat-model.md). The architecture source of truth is | ||
| 92 | [design issue #1](https://gitcode.com/urandon/sessionless/issues/1), and delivery | 100 | [design issue #1](https://gitcode.com/urandon/sessionless/issues/1), and delivery |
| 93 | order is maintained in | 101 | order is maintained in |
| 94 | [implementation epic #6](https://gitcode.com/urandon/sessionless/issues/6). | 102 | [implementation epic #6](https://gitcode.com/urandon/sessionless/issues/6). |
| @@ -0,0 +1,276 @@ | |||
| 1 | +# Web authentication and API contracts | ||
| 2 | + | ||
| 3 | +This document freezes the WEB-01 contracts. It is an implementation input for | ||
| 4 | +WEB-02 and WEB-03; it does not claim that the Web BFF, YDB auth tables, or | ||
| 5 | +browser application already exist. | ||
| 6 | + | ||
| 7 | +The WebUI is a projection over canonical Sessionless sessions and events. | ||
| 8 | +Telegram is the first identity provider for the WebUI, but Telegram chats, | ||
| 9 | +updates, usernames, and transport identifiers do not grant product access. | ||
| 10 | + | ||
| 11 | +## Trust boundaries | ||
| 12 | + | ||
| 13 | +```mermaid | ||
| 14 | +flowchart LR | ||
| 15 | + Browser["Untrusted browser"] -->|"opaque cookie + exact Origin + CSRF"| BFF["Go Web BFF"] | ||
| 16 | + BFF -->|"code + PKCE verifier"| OIDC["Telegram OIDC"] | ||
| 17 | + BFF -->|"digests and resolved IDs"| Auth["YDB auth records"] | ||
| 18 | + BFF -->|"authorized tenant + session"| Core["Canonical application ports"] | ||
| 19 | + BFF -->|"short-lived upload capability"| Storage["Object Storage"] | ||
| 20 | + Core --> YDB["YDB canonical sessions/events"] | ||
| 21 | + | ||
| 22 | + classDef untrusted fill:#ffe5e5,stroke:#a00; | ||
| 23 | + class Browser untrusted; | ||
| 24 | +``` | ||
| 25 | + | ||
| 26 | +The browser may supply resource and tenant selectors. The BFF treats them as | ||
| 27 | +untrusted input and resolves authority from the current web session, an active | ||
| 28 | +tenant membership, and (for session resources) a session participant record. | ||
| 29 | +Authentication alone never creates a tenant or membership. | ||
| 30 | + | ||
| 31 | +## Telegram OIDC flow | ||
| 32 | + | ||
| 33 | +The selected flow is Authorization Code with PKCE `S256`. Telegram documents | ||
| 34 | +the authorization, token, and JWKS endpoints and requires server-side ID-token | ||
| 35 | +validation. OAuth security guidance recommends PKCE for confidential web | ||
| 36 | +clients as well as public clients, transaction-specific state/nonce values, | ||
| 37 | +and exact registered redirect URI matching: | ||
| 38 | + | ||
| 39 | +- [Telegram Login OIDC](https://core.telegram.org/bots/telegram-login) | ||
| 40 | +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) | ||
| 41 | +- [RFC 7636: PKCE](https://www.rfc-editor.org/rfc/rfc7636) | ||
| 42 | +- [RFC 9700: OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700) | ||
| 43 | + | ||
| 44 | +```mermaid | ||
| 45 | +sequenceDiagram | ||
| 46 | + actor User | ||
| 47 | + participant Browser | ||
| 48 | + participant BFF | ||
| 49 | + participant YDB | ||
| 50 | + participant Telegram | ||
| 51 | + | ||
| 52 | + User->>BFF: GET /auth/telegram/start | ||
| 53 | + BFF->>YDB: store state digest, browser-binding digest, nonce, verifier, expiry | ||
| 54 | + BFF-->>Browser: browser-binding cookie + Telegram redirect | ||
| 55 | + Browser->>Telegram: code request with state, nonce, PKCE S256 | ||
| 56 | + Telegram-->>BFF: code + state | ||
| 57 | + BFF->>YDB: atomically consume challenge by state digest and browser binding | ||
| 58 | + BFF->>Telegram: server-side code exchange with verifier and client secret | ||
| 59 | + Telegram-->>BFF: signed ID token | ||
| 60 | + BFF->>BFF: verify signature, algorithm, iss, aud, exp, iat, nonce | ||
| 61 | + BFF->>YDB: resolve/create external identity only | ||
| 62 | + BFF->>YDB: resolve explicit enrollment grant and active memberships | ||
| 63 | + alt no membership or grant | ||
| 64 | + BFF-->>Browser: 403 access_denied | ||
| 65 | + else active membership | ||
| 66 | + BFF->>YDB: store opaque-session and CSRF digests | ||
| 67 | + BFF-->>Browser: __Host- cookies; redirect without code/state | ||
| 68 | + end | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +### Protocol defaults | ||
| 72 | + | ||
| 73 | +- Production issuer is exactly `https://oauth.telegram.org`; audience is the | ||
| 74 | + configured Bot ID. The redirect URI is one exact HTTPS URI. Loopback HTTP is | ||
| 75 | + allowed only in an explicitly local environment. | ||
| 76 | +- State, nonce, browser-binding, session, invitation, and CSRF secrets contain | ||
| 77 | + at least 32 random bytes. Application tables store SHA-256 digests, not raw | ||
| 78 | + bearer values. | ||
| 79 | +- The PKCE verifier is 43–128 RFC 7636 unreserved characters. Only `S256` is | ||
| 80 | + accepted. | ||
| 81 | +- The requested scopes are exactly `openid profile`; phone access is not | ||
| 82 | + requested by the MVP. | ||
| 83 | +- A login challenge expires after 10 minutes and is consumed exactly once in a | ||
| 84 | + serializable transaction before code exchange. It is bound to the browser | ||
| 85 | + that initiated the login. | ||
| 86 | +- The MVP pins the Telegram client to `RS256`. A configured algorithm change is | ||
| 87 | + a reviewed deployment change, not a value accepted from the token header. | ||
| 88 | +- JWKS responses have a bounded 10-minute cache. An unknown `kid` triggers one | ||
| 89 | + synchronous refresh; if the key or allowed algorithm remains unknown, login | ||
| 90 | + fails closed. | ||
| 91 | +- The callback response clears code/state-bearing URLs with an immediate local | ||
| 92 | + redirect and sends `Cache-Control: no-store` and a restrictive Referrer | ||
| 93 | + Policy. Authorization codes, tokens, cookies, raw state/nonce, PKCE verifiers, | ||
| 94 | + invitation secrets, CSRF values, and upload URLs are redacted from logs. | ||
| 95 | + | ||
| 96 | +`internal/domain.OIDCLoginChallenge`, `OIDCIdentityClaims`, and | ||
| 97 | +`internal/ports.OIDCProvider` encode these boundaries. The OIDC adapter returns | ||
| 98 | +verified identity claims only; provider tokens cannot cross the port. | ||
| 99 | + | ||
| 100 | +## Identity, membership, and enrollment | ||
| 101 | + | ||
| 102 | +An `ExternalSubject(provider, subject)` maps immutably to one internal | ||
| 103 | +`UserID`. A successful OIDC exchange may create or refresh this mapping. It may | ||
| 104 | +not create a tenant membership by itself. | ||
| 105 | + | ||
| 106 | +An active `TenantMembership(tenant_id, user_id)` is the authorization source. | ||
| 107 | +Roles are `owner`, `member`, and `viewer`; statuses are `active`, `suspended`, | ||
| 108 | +and `revoked`. Every security-relevant membership mutation increments a | ||
| 109 | +positive `security_version`, invalidating web sessions issued against the old | ||
| 110 | +version. | ||
| 111 | + | ||
| 112 | +Enrollment sources are evaluated in this order: | ||
| 113 | + | ||
| 114 | +1. an active membership corroborated by an existing authorized frontend | ||
| 115 | + binding; | ||
| 116 | +2. a one-time tenant invitation; | ||
| 117 | +3. an explicitly audited development bootstrap grant. | ||
| 118 | + | ||
| 119 | +There is no fourth or implicit fallback. Invitations store a secret digest, | ||
| 120 | +expiry, role, and optional provider/subject restriction. Consumption and | ||
| 121 | +membership creation are one serializable transaction; expiry, subject mismatch, | ||
| 122 | +replay, and competing consumption fail closed. | ||
| 123 | + | ||
| 124 | +The development bootstrap exists only for `cloud-dev`. WEB-02 must expose it as | ||
| 125 | +an operator-only binary or Make-backed target with these requirements: | ||
| 126 | + | ||
| 127 | +- use the normal YDB metadata/environment credential chain; never accept a YDB | ||
| 128 | + IAM token, invitation secret, or service-account key on the command line; | ||
| 129 | +- require an already verified external identity or an explicitly supplied | ||
| 130 | + internal user ID plus provider/subject match; | ||
| 131 | +- require the exact environment, tenant, role, operator identity, human reason, | ||
| 132 | + and typed confirmation through an interactive prompt or standard input; | ||
| 133 | +- atomically create the membership and append a redacted audit record; | ||
| 134 | +- be idempotent only for the exact same user, tenant, role, operator, and reason; | ||
| 135 | +- refuse every environment except `cloud-dev`. | ||
| 136 | + | ||
| 137 | +This procedure does not depend on a Telegram webhook. General membership | ||
| 138 | +administration and production bootstrap are outside the MVP contract. | ||
| 139 | + | ||
| 140 | +## First-party web session | ||
| 141 | + | ||
| 142 | +The browser receives at least 256 bits of opaque session material. YDB stores | ||
| 143 | +only its digest, the authenticated subject, internal user, active tenant, | ||
| 144 | +membership security version, CSRF digest, and lifecycle timestamps. | ||
| 145 | + | ||
| 146 | +| Property | Default / invariant | | ||
| 147 | +| --- | --- | | ||
| 148 | +| Cookie | `__Host-sessionless`; `Secure`; `HttpOnly`; `SameSite=Lax`; `Path=/`; no `Domain` | | ||
| 149 | +| Idle expiry | 12 hours, enforced server-side | | ||
| 150 | +| Absolute expiry | 7 days, enforced server-side | | ||
| 151 | +| Rotation | mandatory after login and every tenant switch | | ||
| 152 | +| Revocation | logout, membership suspension/revocation/version change, operator action | | ||
| 153 | +| Tenant switch | selected tenant must have a fresh active membership; old digest is revoked atomically | | ||
| 154 | +| Browser storage | no access/ID token, client secret, provider credential, or Sessionless bearer token | | ||
| 155 | + | ||
| 156 | +Every request resolves the session digest by a bounded point lookup, then | ||
| 157 | +rechecks expiry, revocation, membership status, user/tenant equality, role, and | ||
| 158 | +security version. A browser-supplied `tenant_id` is only a tenant-switch | ||
| 159 | +selector. It never overrides `active_tenant_id` in the stored session. | ||
| 160 | + | ||
| 161 | +Every mutation also requires an exact normalized HTTPS `Origin` and a | ||
| 162 | +session-bound CSRF value in `X-Sessionless-CSRF`. The readable | ||
| 163 | +`__Host-sessionless-csrf` cookie uses `Secure`, `SameSite=Strict`, `Path=/`, and | ||
| 164 | +no `Domain`; YDB stores its digest with the HttpOnly session. | ||
| 165 | + | ||
| 166 | +## Same-origin API | ||
| 167 | + | ||
| 168 | +The DTOs live in `internal/webcontract`. They intentionally contain no | ||
| 169 | +authoritative user, role, or tenant field, except that | ||
| 170 | +`POST /api/web/v1/active-tenant` carries the tenant selector that must be | ||
| 171 | +resolved to a membership before session rotation. | ||
| 172 | + | ||
| 173 | +| Method and path | Request selector/body | Authorization and result | | ||
| 174 | +| --- | --- | --- | | ||
| 175 | +| `GET /auth/telegram/start` | optional local `return_to` | create browser-bound challenge; redirect | | ||
| 176 | +| `GET /auth/telegram/callback` | exactly one of `code` or `error`, plus `state` | consume challenge; verify claims; resolve enrollment | | ||
| 177 | +| `POST /auth/logout` | none | CSRF; revoke current digest; clear cookies | | ||
| 178 | +| `GET /api/web/v1/me` | none | resolved identity and memberships | | ||
| 179 | +| `GET /api/web/v1/tenants` | none | active memberships only | | ||
| 180 | +| `POST /api/web/v1/active-tenant` | `tenant_id` selector | active membership; rotate session | | ||
| 181 | +| `GET /api/web/v1/sessions` | bounded cursor/limit | active tenant plus participant read grant | | ||
| 182 | +| `POST /api/web/v1/sessions` | idempotency key | membership write grant; create canonical session | | ||
| 183 | +| `GET /api/web/v1/sessions/{session_id}/events` | `after_seq`, bounded limit | participant read grant; ordered canonical events | | ||
| 184 | +| `POST /api/web/v1/sessions/{session_id}/archive` | desired state/idempotency | participant write grant | | ||
| 185 | +| `POST /api/web/v1/sessions/{session_id}/messages` | text, up to 8 committed upload IDs, idempotency key | participant write grant; canonical ingestion port | | ||
| 186 | +| `POST /api/web/v1/uploads` | session selector, name, media type, size, SHA-256 | participant write grant; short-lived intent | | ||
| 187 | +| `POST /api/web/v1/uploads/{upload_id}/commit` | matching upload selector | reauthorize and verify storage metadata | | ||
| 188 | +| `GET /api/web/v1/runs/{run_id}` | run selector | run tenant/session participant read grant | | ||
| 189 | + | ||
| 190 | +Resource-not-found and unauthorized-resource responses have the same public | ||
| 191 | +shape so tenant/session ID probing cannot distinguish them. Errors use: | ||
| 192 | + | ||
| 193 | +```json | ||
| 194 | +{ | ||
| 195 | + "error": { | ||
| 196 | + "code": "stable_machine_code", | ||
| 197 | + "message": "safe user-facing message", | ||
| 198 | + "request_id": "opaque-correlation-id" | ||
| 199 | + } | ||
| 200 | +} | ||
| 201 | +``` | ||
| 202 | + | ||
| 203 | +The initial status mapping is `400 invalid_request`, `401 unauthenticated`, | ||
| 204 | +`403 access_denied|csrf_failed`, `404 not_found`, `409 conflict`, `413 | ||
| 205 | +payload_too_large`, `429 rate_limited`, and `503 temporarily_unavailable`. | ||
| 206 | +Responses never echo credentials, provider payloads, internal object keys, or | ||
| 207 | +authorization failure details. | ||
| 208 | + | ||
| 209 | +### Audit contract | ||
| 210 | + | ||
| 211 | +Append a redacted audit event for login success/failure, logout, tenant switch, | ||
| 212 | +session rotation/revocation, invitation consumption, development bootstrap, | ||
| 213 | +CSRF rejection, and privileged session/upload mutations. Each event contains a | ||
| 214 | +stable action/code, occurred-at time, request ID, actor/user when resolved, | ||
| 215 | +tenant when resolved, selected resource IDs, and the membership security | ||
| 216 | +version used for authorization. Pre-authentication failures use a provider plus | ||
| 217 | +one-way subject fingerprint rather than the raw provider token or claims body. | ||
| 218 | + | ||
| 219 | +Audit records never contain authorization codes, ID/access tokens, client | ||
| 220 | +secrets, cookies, state/nonce/verifier values, invitation or CSRF secrets, | ||
| 221 | +upload URLs, request bodies, message content, file content, or raw error | ||
| 222 | +objects. Audit persistence participates in the corresponding state transaction | ||
| 223 | +where the action mutates YDB; best-effort logging is not an audit substitute. | ||
| 224 | + | ||
| 225 | +## Upload-intent contract | ||
| 226 | + | ||
| 227 | +Large browser uploads go directly to Object Storage through a short-lived | ||
| 228 | +capability URL. The stored intent is bound to tenant, user, target canonical | ||
| 229 | +session, object key, name, media type, expected size, expected SHA-256, expiry, | ||
| 230 | +and one-time status. Its object key is generated server-side under: | ||
| 231 | + | ||
| 232 | +```text | ||
| 233 | +tenants/<tenant-id>/uploads/<upload-id>/... | ||
| 234 | +``` | ||
| 235 | + | ||
| 236 | +Commit reauthorizes the current web session and target session, obtains object | ||
| 237 | +metadata from Object Storage, and compares tenant, exact key, size, and digest. | ||
| 238 | +The browser cannot commit a different key or tenant by changing its JSON. An | ||
| 239 | +expired or already committed intent fails closed; abandoned objects are | ||
| 240 | +retention-controlled staging data and never become canonical events. | ||
| 241 | + | ||
| 242 | +Presigned URLs are capabilities: responses containing them use `no-store`, and | ||
| 243 | +logs, audit payloads, analytics, browser-persistent storage, and referrers must | ||
| 244 | +not contain them. WEB-03 defines allowed media types, maximum object size, | ||
| 245 | +malware/content inspection, and the final event attachment projection. | ||
| 246 | + | ||
| 247 | +## Browser response policy | ||
| 248 | + | ||
| 249 | +WEB-02/04 must set at least: | ||
| 250 | + | ||
| 251 | +- `Strict-Transport-Security: max-age=31536000; includeSubDomains` after the | ||
| 252 | + delegated development hostname is HTTPS-only; | ||
| 253 | +- `Content-Security-Policy` with `default-src 'self'`, `object-src 'none'`, | ||
| 254 | + `base-uri 'none'`, and `frame-ancestors 'none'`; narrowly list only verified | ||
| 255 | + Telegram/OIDC navigation and Object Storage upload origins; | ||
| 256 | +- `X-Content-Type-Options: nosniff`; | ||
| 257 | +- `Referrer-Policy: no-referrer` on auth/capability responses and | ||
| 258 | + `strict-origin-when-cross-origin` elsewhere; | ||
| 259 | +- `Cache-Control: no-store` on auth, identity, tenant, mutation, and upload | ||
| 260 | + capability responses; fingerprinted static assets may be immutable. | ||
| 261 | + | ||
| 262 | +The BFF is same-origin. It does not enable credentialed wildcard CORS. | ||
| 263 | + | ||
| 264 | +## Ownership of follow-up verification | ||
| 265 | + | ||
| 266 | +- WEB-02: signature/JWKS behavior, challenge and invitation concurrency, | ||
| 267 | + identity immutability, session rotation/revocation, membership-version | ||
| 268 | + invalidation, and audited bootstrap. | ||
| 269 | +- WEB-03: resource-level IDOR checks, canonical operation authorization, | ||
| 270 | + upload storage verification, bounded pagination, and error-shape parity. | ||
| 271 | +- WEB-04: browser storage, CSP, cookie, and CSRF integration tests. | ||
| 272 | +- WEB-06: two-user/two-tenant browser E2E, replay/fixation negatives, cloud | ||
| 273 | + headers, secret/log scanning, and incident procedures. | ||
| 274 | + | ||
| 275 | +The focused threat model and executable-test mapping are in | ||
| 276 | +[web-threat-model.md](web-threat-model.md). | ||
| @@ -0,0 +1,73 @@ | |||
| 1 | +# WebUI authentication threat model | ||
| 2 | + | ||
| 3 | +## Scope and assets | ||
| 4 | + | ||
| 5 | +This model covers the future same-origin WebUI, Go BFF, Telegram OIDC callback, | ||
| 6 | +YDB auth records, canonical-session API, and direct-to-Object-Storage upload | ||
| 7 | +flow. Telegram message ingress, worker sandbox escape, provider-subscription | ||
| 8 | +automation, and general tenant administration are covered by their own tracks. | ||
| 9 | + | ||
| 10 | +Protected assets are tenant memberships, canonical sessions/events, uploaded | ||
| 11 | +content, opaque web sessions, OIDC client credentials and tokens, invitation | ||
| 12 | +capabilities, audit integrity, and the ability to enqueue AI work. | ||
| 13 | + | ||
| 14 | +## Attacker assumptions | ||
| 15 | + | ||
| 16 | +- A remote attacker controls a website, browser requests, URL/query/body | ||
| 17 | + fields, guessed opaque IDs, and uploaded bytes. | ||
| 18 | +- A user may have membership in several tenants and may be suspended while a | ||
| 19 | + browser session remains open. | ||
| 20 | +- Queue delivery, retries, callbacks, and browser requests may repeat or race. | ||
| 21 | +- Logs, browser history, analytics, Terraform state, and object metadata are | ||
| 22 | + potential secondary disclosure channels. | ||
| 23 | +- YDB and Object Storage IAM policies are necessary but do not replace | ||
| 24 | + application tenant checks. | ||
| 25 | + | ||
| 26 | +## Threats, controls, and executable owners | ||
| 27 | + | ||
| 28 | +| Threat | Required controls | Executable evidence owner | | ||
| 29 | +| --- | --- | --- | | ||
| 30 | +| Forged or replayed OIDC callback | browser-bound one-time state digest; PKCE S256; nonce; exact redirect; signature and allowed-algorithm verification; exact issuer/audience/time checks | WEB-01 claim/challenge tests; WEB-02 provider and concurrent-consumption tests | | ||
| 31 | +| Authorization-code or token leakage | server-side exchange; immediate clean redirect; no-store; no-referrer; structured redaction; no browser token storage | WEB-02 HTTP/log tests; WEB-04 browser-storage tests; WEB-06 cloud log scan | | ||
| 32 | +| Login/session fixation | at least 256 random bits; digest-only storage; rotate after login and tenant switch; revoke previous digest in the same transaction | WEB-01 rotation invariants; WEB-02 persistence tests; WEB-06 replay test | | ||
| 33 | +| Stolen or stale session replay | Secure HttpOnly `__Host-` cookie; idle and absolute expiry; revocation; membership security-version recheck on every request | WEB-01 authorization matrix; WEB-02 expiry/revoke tests; WEB-06 browser E2E | | ||
| 34 | +| Authentication becomes authorization | OIDC may create/refresh external identity only; active membership is mandatory; no implicit tenant | WEB-01 enrollment tests; WEB-02 unknown-identity test | | ||
| 35 | +| Invitation theft/replay/race | digest-only one-time secret; short expiry; optional provider/subject binding; serializable consume plus membership create | WEB-01 grant/replay contract; WEB-02 two-consumer YDB test | | ||
| 36 | +| Development bootstrap becomes a production backdoor | exact `cloud-dev` guard; metadata credentials; typed confirmation; required operator/reason; atomic redacted audit; no secret CLI args | WEB-01 validation; WEB-02 CLI tests; WEB-06 deployment-policy check | | ||
| 37 | +| Tenant switch or resource IDOR | browser tenant is a selector; session resolves user and active membership; every session/run/upload checks tenant plus participant; indistinguishable denied/not-found response | WEB-01 two-user/two-tenant matrix; WEB-03 API negatives; WEB-06 E2E | | ||
| 38 | +| CSRF | exact HTTPS Origin; session-bound CSRF digest; `SameSite` cookies; no state-changing GET; same-origin BFF | WEB-01 CSRF tests; WEB-02 handler tests; WEB-04 browser tests | | ||
| 39 | +| Malicious or cross-tenant upload | server-generated tenant key; short expiry; expected size/digest/media type; commit uses storage-observed metadata; reauthorization; staging retention; later content inspection | WEB-01 intent tests; WEB-03 storage/inspection tests; WEB-06 cross-tenant E2E | | ||
| 40 | +| Presigned upload URL leakage | capability response no-store/no-referrer; URL redaction; no analytics or persistent browser storage | WEB-03 log tests; WEB-04 browser tests; WEB-06 cloud log scan | | ||
| 41 | +| XSS steals readable CSRF or performs actions | strict CSP; no unsafe inline script; escaped rendering; exact Origin plus server-side authorization; HttpOnly session | WEB-04 component/browser tests; WEB-06 deployed-header check | | ||
| 42 | +| Cache exposes another user | no-store on personalized/auth/capability responses; cache keys never rely on untrusted tenant selectors | WEB-02/03 HTTP tests; WEB-06 gateway test | | ||
| 43 | +| Secret disclosure through operations | Lockbox references outside Terraform state; metadata credentials; structured field allowlist; raw tokens/secrets forbidden in queue and audit records | WEB-02 redaction tests; WEB-05 Terraform review; WEB-06 secret scan | | ||
| 44 | + | ||
| 45 | +## Security invariants | ||
| 46 | + | ||
| 47 | +1. No request reaches a canonical application port until the stored web session | ||
| 48 | + and current membership have both been validated. | ||
| 49 | +2. No OIDC claim, Telegram username, cookie field, request tenant, object key, | ||
| 50 | + or opaque resource ID is sufficient authorization by itself. | ||
| 51 | +3. Membership suspension, revocation, or security-version change invalidates | ||
| 52 | + outstanding sessions without waiting for their TTL. | ||
| 53 | +4. Challenge, invitation, tenant-switch rotation, and upload commit each have | ||
| 54 | + one serializable winner under concurrency. | ||
| 55 | +5. Canonical event creation consumes only a committed, authorized upload | ||
| 56 | + intent; an uploaded object alone is not product history. | ||
| 57 | +6. Provider tokens and Sessionless bearer secrets never enter canonical events, | ||
| 58 | + browser-readable persistent storage, queue envelopes, logs, audit payloads, | ||
| 59 | + Terraform state, or command arguments. | ||
| 60 | + | ||
| 61 | +## Residual risks and gates | ||
| 62 | + | ||
| 63 | +- Telegram JWKS or authorization endpoints may be temporarily unavailable. | ||
| 64 | + Login fails closed; an already authorized first-party session continues only | ||
| 65 | + until its own expiry/revocation checks fail. | ||
| 66 | +- A presigned URL is a bearer capability until expiry. Keep expiry and allowed | ||
| 67 | + operation minimal, bind exact object metadata, and verify at commit. | ||
| 68 | +- Content inspection rules are not frozen in WEB-01. WEB-03 must choose and | ||
| 69 | + test them before attachment uploads are enabled in cloud-dev. | ||
| 70 | +- CSP correctness depends on the final Svelte bundle and upload endpoints. | ||
| 71 | + WEB-04/06 must inspect the rendered/deployed application, not only constants. | ||
| 72 | +- Production operator bootstrap is intentionally unresolved and cannot reuse | ||
| 73 | + the `cloud-dev` exception. | ||
| @@ -20,6 +20,8 @@ type ( | |||
| 20 | SessionEventID string | 20 | SessionEventID string |
| 21 | FrontendBindingID string | 21 | FrontendBindingID string |
| 22 | SessionSnapshotID string | 22 | SessionSnapshotID string |
| 23 | + TenantInvitationID string | ||
| 24 | + UploadIntentID string | ||
| 23 | ConversationID string | 25 | ConversationID string |
| 24 | ActorID string | 26 | ActorID string |
| 25 | RunID string | 27 | RunID string |
| @@ -46,6 +48,12 @@ func (id FrontendBindingID) Validate() error { | |||
| 46 | func (id SessionSnapshotID) Validate() error { | 48 | func (id SessionSnapshotID) Validate() error { |
| 47 | return ValidateOpaqueID("session_snapshot_id", string(id)) | 49 | return ValidateOpaqueID("session_snapshot_id", string(id)) |
| 48 | } | 50 | } |
| 51 | +func (id TenantInvitationID) Validate() error { | ||
| 52 | + return ValidateOpaqueID("tenant_invitation_id", string(id)) | ||
| 53 | +} | ||
| 54 | +func (id UploadIntentID) Validate() error { | ||
| 55 | + return ValidateOpaqueID("upload_intent_id", string(id)) | ||
| 56 | +} | ||
| 49 | func (id ConversationID) Validate() error { return ValidateOpaqueID("conversation_id", string(id)) } | 57 | func (id ConversationID) Validate() error { return ValidateOpaqueID("conversation_id", string(id)) } |
| 50 | func (id ActorID) Validate() error { return ValidateOpaqueID("actor_id", string(id)) } | 58 | func (id ActorID) Validate() error { return ValidateOpaqueID("actor_id", string(id)) } |
| 51 | func (id RunID) Validate() error { return ValidateOpaqueID("run_id", string(id)) } | 59 | func (id RunID) Validate() error { return ValidateOpaqueID("run_id", string(id)) } |
| @@ -0,0 +1,220 @@ | |||
| 1 | +package domain_test | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "errors" | ||
| 5 | + "sync" | ||
| 6 | + "sync/atomic" | ||
| 7 | + "testing" | ||
| 8 | + "time" | ||
| 9 | + | ||
| 10 | + "gitcode.com/urandon/sessionless/internal/domain" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +var webTestTime = time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) | ||
| 14 | + | ||
| 15 | +func externalSubject(id string) domain.ExternalSubject { | ||
| 16 | + return domain.ExternalSubject{Provider: domain.IdentityProviderTelegram, Subject: id} | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +func membership(tenant domain.TenantID, user domain.UserID, role domain.TenantMembershipRole) domain.TenantMembership { | ||
| 20 | + return domain.TenantMembership{ | ||
| 21 | + TenantID: tenant, UserID: user, Role: role, Status: domain.TenantMembershipActive, | ||
| 22 | + SecurityVersion: 2, CreatedAt: webTestTime, UpdatedAt: webTestTime, | ||
| 23 | + } | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +func invitation(target *domain.ExternalSubject) domain.TenantInvitation { | ||
| 27 | + return domain.TenantInvitation{ | ||
| 28 | + ID: "invite-1", TenantID: "tenant-a", SecretDigest: domain.DigestSecret("invite-secret"), | ||
| 29 | + Role: domain.TenantMembershipMember, TargetSubject: target, | ||
| 30 | + CreatedAt: webTestTime, ExpiresAt: webTestTime.Add(time.Hour), | ||
| 31 | + } | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +func webSession(tenant domain.TenantID, user domain.UserID, token string, issued time.Time) domain.WebSession { | ||
| 35 | + return domain.WebSession{ | ||
| 36 | + SessionDigest: domain.DigestSecret(token), CSRFTokenDigest: domain.DigestSecret(token + "-csrf"), | ||
| 37 | + UserID: user, ActiveTenantID: tenant, AuthenticatedSubject: externalSubject("1001"), | ||
| 38 | + MembershipSecurityVersion: 2, IssuedAt: issued, LastSeenAt: issued, | ||
| 39 | + IdleExpiresAt: issued.Add(12 * time.Hour), AbsoluteExpiresAt: issued.Add(7 * 24 * time.Hour), | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +func TestOIDCClaimsVerificationMatrix(t *testing.T) { | ||
| 44 | + t.Parallel() | ||
| 45 | + policy := domain.OIDCVerificationPolicy{ | ||
| 46 | + Issuer: "https://oauth.telegram.org", Audience: "123456", | ||
| 47 | + AllowedAlgorithms: []string{"RS256"}, MaxClockSkew: time.Minute, | ||
| 48 | + } | ||
| 49 | + valid := domain.OIDCIdentityClaims{ | ||
| 50 | + Issuer: policy.Issuer, Audience: []string{"123456"}, Subject: "1001", Nonce: "nonce-1", | ||
| 51 | + IssuedAt: webTestTime.Add(-time.Minute), ExpiresAt: webTestTime.Add(time.Hour), | ||
| 52 | + } | ||
| 53 | + if err := valid.Verify(policy, "nonce-1", webTestTime); err != nil { | ||
| 54 | + t.Fatalf("valid claims rejected: %v", err) | ||
| 55 | + } | ||
| 56 | + tests := map[string]func(*domain.OIDCIdentityClaims){ | ||
| 57 | + "issuer": func(claims *domain.OIDCIdentityClaims) { claims.Issuer = "https://attacker.invalid" }, | ||
| 58 | + "audience": func(claims *domain.OIDCIdentityClaims) { claims.Audience = []string{"other-client"} }, | ||
| 59 | + "nonce": func(claims *domain.OIDCIdentityClaims) { claims.Nonce = "replayed-nonce" }, | ||
| 60 | + "expiry": func(claims *domain.OIDCIdentityClaims) { claims.ExpiresAt = webTestTime.Add(-2 * time.Minute) }, | ||
| 61 | + } | ||
| 62 | + for name, mutate := range tests { | ||
| 63 | + name, mutate := name, mutate | ||
| 64 | + t.Run(name, func(t *testing.T) { | ||
| 65 | + t.Parallel() | ||
| 66 | + claims := valid | ||
| 67 | + mutate(&claims) | ||
| 68 | + if err := claims.Verify(policy, "nonce-1", webTestTime); err == nil { | ||
| 69 | + t.Fatal("invalid OIDC claims accepted") | ||
| 70 | + } | ||
| 71 | + }) | ||
| 72 | + } | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +func TestLoginChallengeIsBrowserBoundExpiringAndOneTime(t *testing.T) { | ||
| 76 | + t.Parallel() | ||
| 77 | + challenge := domain.OIDCLoginChallenge{ | ||
| 78 | + StateDigest: domain.DigestSecret("state"), BrowserBindingDigest: domain.DigestSecret("browser"), | ||
| 79 | + PKCEVerifier: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~", | ||
| 80 | + Nonce: "nonce-1", RedirectPath: "/sessions", CreatedAt: webTestTime, | ||
| 81 | + ExpiresAt: webTestTime.Add(10 * time.Minute), | ||
| 82 | + } | ||
| 83 | + wrongBrowser := challenge | ||
| 84 | + if err := wrongBrowser.Consume("attacker", webTestTime.Add(time.Minute)); !errors.Is(err, domain.ErrMembershipDenied) { | ||
| 85 | + t.Fatalf("wrong browser error = %v", err) | ||
| 86 | + } | ||
| 87 | + expired := challenge | ||
| 88 | + if err := expired.Consume("browser", expired.ExpiresAt); !errors.Is(err, domain.ErrLoginChallengeExpired) { | ||
| 89 | + t.Fatalf("expired challenge error = %v", err) | ||
| 90 | + } | ||
| 91 | + if err := challenge.Consume("browser", webTestTime.Add(time.Minute)); err != nil { | ||
| 92 | + t.Fatalf("challenge consume failed: %v", err) | ||
| 93 | + } | ||
| 94 | + if err := challenge.Consume("browser", webTestTime.Add(2*time.Minute)); !errors.Is(err, domain.ErrLoginChallengeConsumed) { | ||
| 95 | + t.Fatalf("replayed challenge error = %v", err) | ||
| 96 | + } | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +func TestExternalIdentityCannotBeRemapped(t *testing.T) { | ||
| 100 | + t.Parallel() | ||
| 101 | + existing := domain.ExternalIdentity{ | ||
| 102 | + Subject: externalSubject("1001"), UserID: "user-a", | ||
| 103 | + CreatedAt: webTestTime, UpdatedAt: webTestTime, | ||
| 104 | + } | ||
| 105 | + retry := existing | ||
| 106 | + retry.UpdatedAt = webTestTime.Add(time.Minute) | ||
| 107 | + if err := domain.ValidateExternalIdentityMapping(existing, retry); err != nil { | ||
| 108 | + t.Fatalf("same identity refresh rejected: %v", err) | ||
| 109 | + } | ||
| 110 | + retry.UserID = "user-b" | ||
| 111 | + if err := domain.ValidateExternalIdentityMapping(existing, retry); !errors.Is(err, domain.ErrExternalIdentityConflict) { | ||
| 112 | + t.Fatalf("identity remap error = %v", err) | ||
| 113 | + } | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +func TestEnrollmentPrecedenceAndGrantFailures(t *testing.T) { | ||
| 117 | + t.Parallel() | ||
| 118 | + subject := externalSubject("1001") | ||
| 119 | + bindingID := domain.FrontendBindingID("binding-1") | ||
| 120 | + invite := invitation(&subject) | ||
| 121 | + bootstrap := domain.DevelopmentBootstrapGrant{ | ||
| 122 | + TenantID: "tenant-a", UserID: "user-a", Role: domain.TenantMembershipOwner, | ||
| 123 | + Environment: domain.DevelopmentEnvironment, Operator: "operator@example.test", | ||
| 124 | + Reason: "initial cloud-dev operator", GrantedAt: webTestTime, | ||
| 125 | + } | ||
| 126 | + source, err := domain.SelectEnrollmentSource(domain.EnrollmentCandidates{ | ||
| 127 | + ExistingFrontendBindingID: &bindingID, Invitation: &invite, Bootstrap: &bootstrap, | ||
| 128 | + }, subject, "user-a", webTestTime) | ||
| 129 | + if err != nil || source != domain.EnrollmentExistingFrontend { | ||
| 130 | + t.Fatalf("source = %q, err = %v", source, err) | ||
| 131 | + } | ||
| 132 | + source, err = domain.SelectEnrollmentSource(domain.EnrollmentCandidates{Invitation: &invite, Bootstrap: &bootstrap}, subject, "user-a", webTestTime) | ||
| 133 | + if err != nil || source != domain.EnrollmentTenantInvitation { | ||
| 134 | + t.Fatalf("invitation source = %q, err = %v", source, err) | ||
| 135 | + } | ||
| 136 | + source, err = domain.SelectEnrollmentSource(domain.EnrollmentCandidates{Bootstrap: &bootstrap}, subject, "user-a", webTestTime) | ||
| 137 | + if err != nil || source != domain.EnrollmentDevelopmentBootstrap { | ||
| 138 | + t.Fatalf("bootstrap source = %q, err = %v", source, err) | ||
| 139 | + } | ||
| 140 | + if _, err := domain.SelectEnrollmentSource(domain.EnrollmentCandidates{}, subject, "user-a", webTestTime); !errors.Is(err, domain.ErrEnrollmentGrantRequired) { | ||
| 141 | + t.Fatalf("missing grant error = %v", err) | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + wrongSubject := invite | ||
| 145 | + if _, err := domain.SelectEnrollmentSource(domain.EnrollmentCandidates{Invitation: &wrongSubject}, externalSubject("2002"), "user-a", webTestTime); !errors.Is(err, domain.ErrInvitationSubjectMismatch) { | ||
| 146 | + t.Fatalf("wrong-subject invitation error = %v", err) | ||
| 147 | + } | ||
| 148 | + expired := invite | ||
| 149 | + if _, err := domain.SelectEnrollmentSource(domain.EnrollmentCandidates{Invitation: &expired}, subject, "user-a", expired.ExpiresAt); !errors.Is(err, domain.ErrInvitationExpired) { | ||
| 150 | + t.Fatalf("expired invitation error = %v", err) | ||
| 151 | + } | ||
| 152 | + bootstrap.Environment = "production" | ||
| 153 | + if _, err := domain.SelectEnrollmentSource(domain.EnrollmentCandidates{Bootstrap: &bootstrap}, subject, "user-a", webTestTime); err == nil { | ||
| 154 | + t.Fatal("production development-bootstrap grant accepted") | ||
| 155 | + } | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +func TestInvitationConcurrentConsumptionHasOneWinner(t *testing.T) { | ||
| 159 | + var lock sync.Mutex | ||
| 160 | + invite := invitation(nil) | ||
| 161 | + var winners atomic.Uint32 | ||
| 162 | + var wait sync.WaitGroup | ||
| 163 | + for index := 0; index < 16; index++ { | ||
| 164 | + wait.Add(1) | ||
| 165 | + go func() { | ||
| 166 | + defer wait.Done() | ||
| 167 | + lock.Lock() | ||
| 168 | + defer lock.Unlock() | ||
| 169 | + if invite.Consume(externalSubject("1001"), "user-a", webTestTime.Add(time.Minute)) == nil { | ||
| 170 | + winners.Add(1) | ||
| 171 | + } | ||
| 172 | + }() | ||
| 173 | + } | ||
| 174 | + wait.Wait() | ||
| 175 | + if winners.Load() != 1 { | ||
| 176 | + t.Fatalf("invitation winners = %d, want 1", winners.Load()) | ||
| 177 | + } | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +func TestWebAuthorizationMatrixAndSessionRotation(t *testing.T) { | ||
| 181 | + t.Parallel() | ||
| 182 | + ownerA := membership("tenant-a", "user-a", domain.TenantMembershipOwner) | ||
| 183 | + viewerB := membership("tenant-b", "user-a", domain.TenantMembershipViewer) | ||
| 184 | + otherUser := membership("tenant-a", "user-b", domain.TenantMembershipMember) | ||
| 185 | + suspended := ownerA | ||
| 186 | + suspended.Status = domain.TenantMembershipSuspended | ||
| 187 | + sessionA := webSession("tenant-a", "user-a", "session-a", webTestTime) | ||
| 188 | + if err := sessionA.Authorize(ownerA, domain.TenantPermissionAdmin, webTestTime.Add(time.Minute)); err != nil { | ||
| 189 | + t.Fatalf("owner authorization failed: %v", err) | ||
| 190 | + } | ||
| 191 | + for name, candidate := range map[string]domain.TenantMembership{ | ||
| 192 | + "wrong user": otherUser, "wrong tenant": viewerB, "suspended": suspended, | ||
| 193 | + } { | ||
| 194 | + if err := sessionA.Authorize(candidate, domain.TenantPermissionRead, webTestTime.Add(time.Minute)); !errors.Is(err, domain.ErrMembershipDenied) { | ||
| 195 | + t.Fatalf("%s error = %v", name, err) | ||
| 196 | + } | ||
| 197 | + } | ||
| 198 | + sessionB := webSession("tenant-b", "user-a", "session-b", webTestTime.Add(time.Minute)) | ||
| 199 | + if err := domain.ValidateWebSessionRotation(sessionA, sessionB, viewerB, webTestTime.Add(time.Minute)); err != nil { | ||
| 200 | + t.Fatalf("valid tenant switch rejected: %v", err) | ||
| 201 | + } | ||
| 202 | + if err := sessionB.Authorize(viewerB, domain.TenantPermissionWrite, webTestTime.Add(2*time.Minute)); !errors.Is(err, domain.ErrMembershipDenied) { | ||
| 203 | + t.Fatalf("viewer write error = %v", err) | ||
| 204 | + } | ||
| 205 | + changed := viewerB | ||
| 206 | + changed.SecurityVersion++ | ||
| 207 | + if err := sessionB.Authorize(changed, domain.TenantPermissionRead, webTestTime.Add(2*time.Minute)); !errors.Is(err, domain.ErrMembershipVersionChanged) { | ||
| 208 | + t.Fatalf("security-version error = %v", err) | ||
| 209 | + } | ||
| 210 | + revoked := sessionA | ||
| 211 | + if err := revoked.Revoke(webTestTime.Add(time.Minute)); err != nil { | ||
| 212 | + t.Fatal(err) | ||
| 213 | + } | ||
| 214 | + if err := revoked.Authorize(ownerA, domain.TenantPermissionRead, webTestTime.Add(2*time.Minute)); !errors.Is(err, domain.ErrWebSessionRevoked) { | ||
| 215 | + t.Fatalf("revoked session error = %v", err) | ||
| 216 | + } | ||
| 217 | + if err := sessionA.Authorize(ownerA, domain.TenantPermissionRead, sessionA.IdleExpiresAt); !errors.Is(err, domain.ErrWebSessionExpired) { | ||
| 218 | + t.Fatalf("expired session error = %v", err) | ||
| 219 | + } | ||
| 220 | +} | ||
| @@ -0,0 +1,117 @@ | |||
| 1 | +package domain | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/hex" | ||
| 5 | + "errors" | ||
| 6 | + "fmt" | ||
| 7 | + "path" | ||
| 8 | + "strings" | ||
| 9 | + "time" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +var ErrUploadMismatch = errors.New("uploaded object does not match its upload intent") | ||
| 13 | + | ||
| 14 | +type UploadIntentStatus string | ||
| 15 | + | ||
| 16 | +const ( | ||
| 17 | + UploadIntentPending UploadIntentStatus = "pending" | ||
| 18 | + UploadIntentCommitted UploadIntentStatus = "committed" | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | +type UploadIntent struct { | ||
| 22 | + ID UploadIntentID `json:"id"` | ||
| 23 | + TenantID TenantID `json:"tenant_id"` | ||
| 24 | + UserID UserID `json:"user_id"` | ||
| 25 | + SessionID SessionID `json:"session_id"` | ||
| 26 | + ObjectKey string `json:"object_key"` | ||
| 27 | + Name string `json:"name"` | ||
| 28 | + MediaType string `json:"media_type"` | ||
| 29 | + ExpectedSize int64 `json:"expected_size"` | ||
| 30 | + ExpectedSHA256 string `json:"expected_sha256"` | ||
| 31 | + Status UploadIntentStatus `json:"status"` | ||
| 32 | + CreatedAt time.Time `json:"created_at"` | ||
| 33 | + ExpiresAt time.Time `json:"expires_at"` | ||
| 34 | + CommittedAt *time.Time `json:"committed_at,omitempty"` | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +func UploadIntentObjectPrefix(tenantID TenantID, intentID UploadIntentID) string { | ||
| 38 | + return TenantObjectPrefix(tenantID) + "uploads/" + string(intentID) + "/" | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +func (intent UploadIntent) Validate() error { | ||
| 42 | + if err := intent.ID.Validate(); err != nil { | ||
| 43 | + return err | ||
| 44 | + } | ||
| 45 | + if err := intent.TenantID.Validate(); err != nil { | ||
| 46 | + return err | ||
| 47 | + } | ||
| 48 | + if err := intent.UserID.Validate(); err != nil { | ||
| 49 | + return err | ||
| 50 | + } | ||
| 51 | + if err := intent.SessionID.Validate(); err != nil { | ||
| 52 | + return err | ||
| 53 | + } | ||
| 54 | + if path.Clean(intent.ObjectKey) != intent.ObjectKey || strings.HasPrefix(intent.ObjectKey, "/") || | ||
| 55 | + !strings.HasPrefix(intent.ObjectKey, UploadIntentObjectPrefix(intent.TenantID, intent.ID)) { | ||
| 56 | + return ValidationError{ | ||
| 57 | + Field: "upload_intent.object_key", | ||
| 58 | + Reason: fmt.Sprintf("must be under %q", UploadIntentObjectPrefix(intent.TenantID, intent.ID)), | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | + if strings.TrimSpace(intent.Name) == "" || strings.TrimSpace(intent.MediaType) == "" { | ||
| 62 | + return ValidationError{Field: "upload_intent.metadata", Reason: "name and media_type are required"} | ||
| 63 | + } | ||
| 64 | + if intent.ExpectedSize <= 0 { | ||
| 65 | + return ValidationError{Field: "upload_intent.expected_size", Reason: "must be positive"} | ||
| 66 | + } | ||
| 67 | + if err := validateSHA256("upload_intent.expected_sha256", intent.ExpectedSHA256); err != nil { | ||
| 68 | + return err | ||
| 69 | + } | ||
| 70 | + if intent.Status != UploadIntentPending && intent.Status != UploadIntentCommitted { | ||
| 71 | + return ValidationError{Field: "upload_intent.status", Reason: "is unknown"} | ||
| 72 | + } | ||
| 73 | + if intent.CreatedAt.IsZero() || !intent.ExpiresAt.After(intent.CreatedAt) { | ||
| 74 | + return ValidationError{Field: "upload_intent.expires_at", Reason: "must be after a non-zero created_at"} | ||
| 75 | + } | ||
| 76 | + if intent.Status == UploadIntentCommitted && intent.CommittedAt == nil { | ||
| 77 | + return ValidationError{Field: "upload_intent.committed_at", Reason: "is required when committed"} | ||
| 78 | + } | ||
| 79 | + if intent.Status == UploadIntentPending && intent.CommittedAt != nil { | ||
| 80 | + return ValidationError{Field: "upload_intent.committed_at", Reason: "is allowed only when committed"} | ||
| 81 | + } | ||
| 82 | + return nil | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +// Commit validates storage metadata observed server-side. The browser never | ||
| 86 | +// supplies an authoritative tenant, object key, size, or digest. | ||
| 87 | +func (intent *UploadIntent) Commit(blob BlobRef, at time.Time) error { | ||
| 88 | + if intent == nil { | ||
| 89 | + return ValidationError{Field: "upload_intent", Reason: "must not be nil"} | ||
| 90 | + } | ||
| 91 | + if err := intent.Validate(); err != nil { | ||
| 92 | + return err | ||
| 93 | + } | ||
| 94 | + if intent.Status == UploadIntentCommitted { | ||
| 95 | + return ErrUploadIntentCommitted | ||
| 96 | + } | ||
| 97 | + if !at.Before(intent.ExpiresAt) { | ||
| 98 | + return ErrUploadIntentExpired | ||
| 99 | + } | ||
| 100 | + if err := blob.Validate(); err != nil { | ||
| 101 | + return err | ||
| 102 | + } | ||
| 103 | + if blob.TenantID != intent.TenantID || blob.Key != intent.ObjectKey || | ||
| 104 | + blob.Size != intent.ExpectedSize || blob.SHA256 != intent.ExpectedSHA256 { | ||
| 105 | + return ErrUploadMismatch | ||
| 106 | + } | ||
| 107 | + intent.Status, intent.CommittedAt = UploadIntentCommitted, &at | ||
| 108 | + return nil | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +func validateSHA256(field, value string) error { | ||
| 112 | + digest, err := hex.DecodeString(value) | ||
| 113 | + if err != nil || len(digest) != 32 || value != strings.ToLower(value) { | ||
| 114 | + return ValidationError{Field: field, Reason: "must be a lowercase 64-character SHA-256 digest"} | ||
| 115 | + } | ||
| 116 | + return nil | ||
| 117 | +} | ||
| @@ -58,6 +58,8 @@ var prefixes = map[ports.IDKind]string{ | |||
| 58 | ports.IDSessionEvent: "sev_", | 58 | ports.IDSessionEvent: "sev_", |
| 59 | ports.IDFrontendBinding: "fbd_", | 59 | ports.IDFrontendBinding: "fbd_", |
| 60 | ports.IDSessionSnapshot: "ssn_", | 60 | ports.IDSessionSnapshot: "ssn_", |
| 61 | + ports.IDTenantInvitation: "tiv_", | ||
| 62 | + ports.IDUploadIntent: "upl_", | ||
| 61 | ports.IDActor: "act_", | 63 | ports.IDActor: "act_", |
| 62 | ports.IDConversation: "con_", | 64 | ports.IDConversation: "con_", |
| 63 | ports.IDSubscriptionConnection: "sub_", | 65 | ports.IDSubscriptionConnection: "sub_", |
| @@ -0,0 +1,107 @@ | |||
| 1 | +package webcontract_test | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "net/http" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | + "time" | ||
| 9 | + | ||
| 10 | + "gitcode.com/urandon/sessionless/internal/webcontract" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +func TestMutationRequestsDoNotCarryTenantAuthority(t *testing.T) { | ||
| 14 | + t.Parallel() | ||
| 15 | + requests := []any{ | ||
| 16 | + webcontract.CreateSessionRequest{IdempotencyKey: "create-1"}, | ||
| 17 | + webcontract.ArchiveSessionRequest{Archived: true, IdempotencyKey: "archive-1"}, | ||
| 18 | + webcontract.CreateMessageRequest{IdempotencyKey: "message-1", Text: "hello"}, | ||
| 19 | + webcontract.CreateUploadIntentRequest{SessionID: "session-1", Name: "a.txt", MediaType: "text/plain", Size: 1, SHA256: strings.Repeat("a", 64)}, | ||
| 20 | + webcontract.CommitUploadRequest{UploadID: "upload-1"}, | ||
| 21 | + } | ||
| 22 | + for _, request := range requests { | ||
| 23 | + encoded, err := json.Marshal(request) | ||
| 24 | + if err != nil { | ||
| 25 | + t.Fatal(err) | ||
| 26 | + } | ||
| 27 | + if strings.Contains(string(encoded), "tenant_id") { | ||
| 28 | + t.Fatalf("request %T carries tenant authority: %s", request, encoded) | ||
| 29 | + } | ||
| 30 | + } | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +func TestPaginationContractsAreBounded(t *testing.T) { | ||
| 34 | + t.Parallel() | ||
| 35 | + if err := (webcontract.SessionListQuery{Limit: webcontract.MaxPageSize}).Validate(); err != nil { | ||
| 36 | + t.Fatalf("valid session page rejected: %v", err) | ||
| 37 | + } | ||
| 38 | + if err := (webcontract.SessionListQuery{Limit: webcontract.MaxPageSize + 1}).Validate(); err == nil { | ||
| 39 | + t.Fatal("unbounded session page accepted") | ||
| 40 | + } | ||
| 41 | + if err := (webcontract.EventListQuery{Limit: 0}).Validate(); err == nil { | ||
| 42 | + t.Fatal("zero-sized event page accepted") | ||
| 43 | + } | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +func TestCookieContracts(t *testing.T) { | ||
| 47 | + t.Parallel() | ||
| 48 | + session := webcontract.SessionCookie("opaque", 12*time.Hour) | ||
| 49 | + if session.Name != "__Host-sessionless" || !session.Secure || !session.HttpOnly || | ||
| 50 | + session.Path != "/" || session.Domain != "" || session.SameSite != http.SameSiteLaxMode { | ||
| 51 | + t.Fatalf("session cookie = %#v", session) | ||
| 52 | + } | ||
| 53 | + csrf := webcontract.CSRFCookie("csrf", 12*time.Hour) | ||
| 54 | + if csrf.Name != "__Host-sessionless-csrf" || !csrf.Secure || csrf.HttpOnly || | ||
| 55 | + csrf.Path != "/" || csrf.Domain != "" || csrf.SameSite != http.SameSiteStrictMode { | ||
| 56 | + t.Fatalf("CSRF cookie = %#v", csrf) | ||
| 57 | + } | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +func TestStableErrorEnvelopeStatusMapping(t *testing.T) { | ||
| 61 | + t.Parallel() | ||
| 62 | + for _, code := range []webcontract.ErrorCode{ | ||
| 63 | + webcontract.ErrorInvalidRequest, webcontract.ErrorUnauthenticated, | ||
| 64 | + webcontract.ErrorAccessDenied, webcontract.ErrorCSRFFailed, | ||
| 65 | + webcontract.ErrorNotFound, webcontract.ErrorConflict, | ||
| 66 | + webcontract.ErrorPayloadTooLarge, webcontract.ErrorRateLimited, | ||
| 67 | + webcontract.ErrorTemporarilyUnavailable, | ||
| 68 | + } { | ||
| 69 | + if code.HTTPStatus() == 0 { | ||
| 70 | + t.Fatalf("code %q has no HTTP status", code) | ||
| 71 | + } | ||
| 72 | + } | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +func TestOIDCCallbackContract(t *testing.T) { | ||
| 76 | + t.Parallel() | ||
| 77 | + if err := (webcontract.OIDCCallback{Code: "code", State: "state"}).Validate(); err != nil { | ||
| 78 | + t.Fatalf("valid callback rejected: %v", err) | ||
| 79 | + } | ||
| 80 | + for _, callback := range []webcontract.OIDCCallback{ | ||
| 81 | + {Code: "code"}, | ||
| 82 | + {State: "state"}, | ||
| 83 | + {Code: "code", State: "state", Error: "access_denied"}, | ||
| 84 | + } { | ||
| 85 | + if err := callback.Validate(); err == nil { | ||
| 86 | + t.Fatalf("invalid callback accepted: %#v", callback) | ||
| 87 | + } | ||
| 88 | + } | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +func TestBoundedMessageAndUploadContracts(t *testing.T) { | ||
| 92 | + t.Parallel() | ||
| 93 | + message := webcontract.CreateMessageRequest{IdempotencyKey: "message-1", Text: "hello"} | ||
| 94 | + if err := message.Validate(); err != nil { | ||
| 95 | + t.Fatalf("valid message rejected: %v", err) | ||
| 96 | + } | ||
| 97 | + upload := webcontract.CreateUploadIntentRequest{ | ||
| 98 | + SessionID: "session-1", Name: "a.txt", MediaType: "text/plain", Size: 1, SHA256: strings.Repeat("a", 64), | ||
| 99 | + } | ||
| 100 | + if err := upload.Validate(1024); err != nil { | ||
| 101 | + t.Fatalf("valid upload rejected: %v", err) | ||
| 102 | + } | ||
| 103 | + upload.Size = 1025 | ||
| 104 | + if err := upload.Validate(1024); err == nil { | ||
| 105 | + t.Fatal("oversized upload accepted") | ||
| 106 | + } | ||
| 107 | +} | ||
| @@ -0,0 +1,39 @@ | |||
| 1 | +package webcontract | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "crypto/subtle" | ||
| 5 | + "net/url" | ||
| 6 | + "strings" | ||
| 7 | + | ||
| 8 | + "gitcode.com/urandon/sessionless/internal/domain" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +// ValidateMutationSecurity requires an exact normalized HTTPS Origin and a | ||
| 12 | +// session-bound double-submit token whose digest is stored with the session. | ||
| 13 | +func ValidateMutationSecurity(expectedOrigin, actualOrigin, presentedToken string, expectedDigest domain.SecretDigest) error { | ||
| 14 | + expected, err := normalizeOrigin(expectedOrigin) | ||
| 15 | + if err != nil { | ||
| 16 | + return domain.ValidationError{Field: "csrf.expected_origin", Reason: "is invalid"} | ||
| 17 | + } | ||
| 18 | + actual, err := normalizeOrigin(actualOrigin) | ||
| 19 | + if err != nil || actual != expected { | ||
| 20 | + return domain.ValidationError{Field: "csrf.origin", Reason: "does not match the configured origin"} | ||
| 21 | + } | ||
| 22 | + if err := expectedDigest.Validate("csrf.token_digest"); err != nil { | ||
| 23 | + return err | ||
| 24 | + } | ||
| 25 | + actualDigest := domain.DigestSecret(presentedToken) | ||
| 26 | + if presentedToken == "" || subtle.ConstantTimeCompare([]byte(actualDigest), []byte(expectedDigest)) != 1 { | ||
| 27 | + return domain.ValidationError{Field: "csrf.token", Reason: "does not match the web session"} | ||
| 28 | + } | ||
| 29 | + return nil | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +func normalizeOrigin(raw string) (string, error) { | ||
| 33 | + origin, err := url.Parse(raw) | ||
| 34 | + if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || | ||
| 35 | + origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" { | ||
| 36 | + return "", domain.ValidationError{Field: "origin", Reason: "must be an HTTPS origin without path, query, or credentials"} | ||
| 37 | + } | ||
| 38 | + return strings.ToLower(origin.Scheme + "://" + origin.Host), nil | ||
| 39 | +} | ||
| @@ -0,0 +1,31 @@ | |||
| 1 | +package webcontract_test | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "testing" | ||
| 5 | + | ||
| 6 | + "gitcode.com/urandon/sessionless/internal/domain" | ||
| 7 | + "gitcode.com/urandon/sessionless/internal/webcontract" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +func TestMutationSecurityRequiresExactOriginAndSessionToken(t *testing.T) { | ||
| 11 | + t.Parallel() | ||
| 12 | + digest := domain.DigestSecret("csrf-secret") | ||
| 13 | + if err := webcontract.ValidateMutationSecurity( | ||
| 14 | + "https://web.dev.sessionless.triborg.dev", "https://web.dev.sessionless.triborg.dev", | ||
| 15 | + "csrf-secret", digest, | ||
| 16 | + ); err != nil { | ||
| 17 | + t.Fatalf("valid mutation security rejected: %v", err) | ||
| 18 | + } | ||
| 19 | + for _, test := range []struct{ origin, token string }{ | ||
| 20 | + {"https://attacker.invalid", "csrf-secret"}, | ||
| 21 | + {"https://web.dev.sessionless.triborg.dev/path", "csrf-secret"}, | ||
| 22 | + {"https://web.dev.sessionless.triborg.dev", "wrong"}, | ||
| 23 | + {"http://web.dev.sessionless.triborg.dev", "csrf-secret"}, | ||
| 24 | + } { | ||
| 25 | + if err := webcontract.ValidateMutationSecurity( | ||
| 26 | + "https://web.dev.sessionless.triborg.dev", test.origin, test.token, digest, | ||
| 27 | + ); err == nil { | ||
| 28 | + t.Fatalf("invalid origin/token accepted: %#v", test) | ||
| 29 | + } | ||
| 30 | + } | ||
| 31 | +} | ||