已合并
WEB-01: define authenticated WebUI contracts #21
urandon创建于 8月5日
WEB-01: define authenticated WebUI contracts #21
已合并
urandon创建于 8月5日
19 个文件变更+2286-4
MREADME.md+11-3
@@ -37,8 +37,11 @@ delivery. A credential-free two-tenant black-box suite now composes the full
37local Telegram-to-worker-to-Telegram path and its recovery cases. Provider37local Telegram-to-worker-to-Telegram path and its recovery cases. Provider
38authorization, full multi-frontend projection, and subscription-backed Codex,38authorization, full multi-frontend projection, and subscription-backed Codex,
39OpenCode, Claude, or Hermes adapters remain later implementation slices.39OpenCode, 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## Components46## 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 runtime63- `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 without67- `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 atomic69- `internal/ydbstore`: serializable tenant-scoped YDB state and atomic
@@ -88,7 +93,10 @@ This proves orchestration semantics without implying that a permanent harness
88or subscription credential protocol has already been selected.93or subscription credential protocol has already been selected.
89 94 
90The contract invariants and transition tables are documented in95The contract invariants and transition tables are documented in
91-[docs/contracts.md](docs/contracts.md). The architecture source of truth is96+[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 delivery100[design issue #1](https://gitcode.com/urandon/sessionless/issues/1), and delivery
93order is maintained in101order 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).
Mdocs/contracts.md+24-1文件内容审核中,请稍后刷新重试
@@ -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 string20 SessionEventID string
21 FrontendBindingID string21 FrontendBindingID string
22 SessionSnapshotID string22 SessionSnapshotID string
23+ TenantInvitationID string
24+ UploadIntentID string
23 ConversationID string25 ConversationID string
24 ActorID string26 ActorID string
25 RunID string27 RunID string
@@ -46,6 +48,12 @@ func (id FrontendBindingID) Validate() error {
46func (id SessionSnapshotID) Validate() error {48func (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+}
49func (id ConversationID) Validate() error { return ValidateOpaqueID("conversation_id", string(id)) }57func (id ConversationID) Validate() error { return ValidateOpaqueID("conversation_id", string(id)) }
50func (id ActorID) Validate() error { return ValidateOpaqueID("actor_id", string(id)) }58func (id ActorID) Validate() error { return ValidateOpaqueID("actor_id", string(id)) }
51func (id RunID) Validate() error { return ValidateOpaqueID("run_id", string(id)) }59func (id RunID) Validate() error { return ValidateOpaqueID("run_id", string(id)) }
Ainternal/domain/web_auth.go+635-0文件内容审核中,请稍后刷新重试
@@ -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+}
Ainternal/domain/web_upload_test.go+52-0文件内容审核中,请稍后刷新重试
@@ -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_",
Minternal/idgen/generator_test.go+18-0文件内容审核中,请稍后刷新重试
Minternal/ports/ports.go+2-0文件内容审核中,请稍后刷新重试
Ainternal/ports/web.go+108-0文件内容审核中,请稍后刷新重试
Ainternal/ports/web_validation.go+131-0文件内容审核中,请稍后刷新重试
Ainternal/ports/web_validation_test.go+75-0文件内容审核中,请稍后刷新重试
Ainternal/webcontract/api.go+357-0文件内容审核中,请稍后刷新重试
@@ -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+}