oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package linkstore
2
3import (
4 "bytes"
5 "context"
6 "database/sql"
7 "encoding/json"
8 "errors"
9 "io"
10 "time"
11
12 "amadan.net/oficina/home/handoff"
13)
14
15type flow struct {
16 Version int `json:"v"`
17 PersonID int64 `json:"person"`
18 SessionHash string `json:"session"`
19 LocalAuth int64 `json:"local_auth"`
20 Issuer string `json:"issuer"`
21 NonceHash string `json:"nonce,omitempty"`
22 Subject string `json:"subject,omitempty"`
23 HomeAuth int64 `json:"home_auth,omitempty"`
24 ReturnPath string `json:"return"`
25 Expires int64 `json:"expires"`
26}
27
28func (s *Store) purpose(stage, id string) string {
29 return "identity-link/v1/" + stage + "/" + id + "/" + s.origin
30}
31func (s *Store) put(ctx context.Context, tx *sql.Tx, stage, id string, f flow) error {
32 raw, err := json.Marshal(f)
33 if err != nil {
34 return ErrStorage
35 }
36 sealed, err := s.seal.Seal(s.purpose(stage, id), raw)
37 if err != nil {
38 return ErrStorage
39 }
40 _, err = tx.ExecContext(ctx, "INSERT INTO docs_link_flows (id_hash, stage, expires_at, payload) VALUES (?, ?, ?, ?)", id, stage, f.Expires, sealed)
41 if err != nil {
42 return ErrStorage
43 }
44 return nil
45}
46
47// load deletes a presented row before interpreting it when consume is true.
48// Callers commit refusals, so a rejected conflict or mixed-up browser cannot
49// reuse its proof. Storage failures roll back and never install a mapping.
50func (s *Store) load(ctx context.Context, tx *sql.Tx, stage, id string, high int64, consume bool) (flow, error) {
51 var raw []byte
52 var expires int64
53 err := tx.QueryRowContext(ctx, "SELECT expires_at, payload FROM docs_link_flows WHERE id_hash = ? AND stage = ?", id, stage).Scan(&expires, &raw)
54 if errors.Is(err, sql.ErrNoRows) {
55 return flow{}, ErrRefused
56 }
57 if err != nil {
58 return flow{}, ErrStorage
59 }
60 if consume {
61 if _, err := tx.ExecContext(ctx, "DELETE FROM docs_link_flows WHERE id_hash = ? AND stage = ?", id, stage); err != nil {
62 return flow{}, ErrStorage
63 }
64 }
65 if len(raw) > 8192 || expires <= high {
66 return flow{}, ErrRefused
67 }
68 opened, err := s.seal.Open(s.purpose(stage, id), raw)
69 if err != nil {
70 return flow{}, ErrRefused
71 }
72 var f flow
73 dec := json.NewDecoder(bytes.NewReader(opened))
74 dec.DisallowUnknownFields()
75 if dec.Decode(&f) != nil || dec.Decode(new(any)) != io.EOF {
76 return flow{}, ErrRefused
77 }
78 if f.Version != 1 || f.Expires != expires || f.Issuer != s.issuer || !handoff.SafePath(f.ReturnPath) {
79 return flow{}, ErrRefused
80 }
81 return f, nil
82}
83func (s *Store) matches(f flow, local Local, now, high int64) bool {
84 return validLocal(local, now, high) && f.PersonID == local.PersonID && f.SessionHash == hash(local.SessionToken) && f.LocalAuth == local.AuthTime.Unix() && fresh(f.LocalAuth, now, high)
85}
86func personExists(ctx context.Context, tx *sql.Tx, id int64) (bool, error) {
87 var found int
88 err := tx.QueryRowContext(ctx, "SELECT 1 FROM people WHERE id = ? AND email <> ''", id).Scan(&found)
89 if errors.Is(err, sql.ErrNoRows) {
90 return false, nil
91 }
92 if err != nil {
93 return false, ErrStorage
94 }
95 return true, nil
96}
97
98// Start is called only by an explicit same-origin local-account link POST.
99// The returned secret belongs in a host-only Secure/HttpOnly/Lax cookie.
100func (s *Store) Start(ctx context.Context, local Local, returnPath string, now time.Time) (string, error) {
101 tx, high, err := s.begin(ctx, now)
102 if err != nil {
103 return "", err
104 }
105 defer tx.Rollback()
106 if err = sweep(ctx, tx, high); err != nil {
107 return "", err
108 }
109 if !validLocal(local, now.Unix(), high) {
110 return "", refuse(tx)
111 }
112 exists, err := personExists(ctx, tx, local.PersonID)
113 if err != nil {
114 return "", err
115 }
116 if !exists {
117 return "", refuse(tx)
118 }
119 var count int
120 if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM docs_link_flows").Scan(&count); err != nil {
121 return "", ErrStorage
122 }
123 if count >= maxFlows {
124 return "", refuse(tx)
125 }
126 secret, err := newSecret()
127 if err != nil {
128 return "", err
129 }
130 expires := min(now.Unix()+maxAge, local.AuthTime.Unix()+maxAge)
131 if expires <= high {
132 return "", refuse(tx)
133 }
134 f := flow{Version: 1, PersonID: local.PersonID, SessionHash: hash(local.SessionToken), LocalAuth: local.AuthTime.Unix(), Issuer: s.issuer, ReturnPath: safePath(returnPath), Expires: expires}
135 if err := s.put(ctx, tx, "init", hash(secret), f); err != nil {
136 return "", err
137 }
138 if err := commit(tx); err != nil {
139 return "", err
140 }
141 return secret, nil
142}
143
144// BindNonce is the shared consumer's start hook. It accepts one generated
145// nonce exactly once, before that consumer sets its cookie or redirects.
146func (s *Store) BindNonce(ctx context.Context, secret string, local Local, nonce string, now time.Time) error {
147 id, ok := secretHash(secret)
148 if !ok {
149 return ErrRefused
150 }
151 tx, high, err := s.begin(ctx, now)
152 if err != nil {
153 return err
154 }
155 defer tx.Rollback()
156 f, err := s.load(ctx, tx, "init", id, high, true)
157 if errors.Is(err, ErrRefused) {
158 return refuse(tx)
159 }
160 if err != nil {
161 return err
162 }
163 if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(nonce) || f.NonceHash != "" {
164 return refuse(tx)
165 }
166 f.NonceHash = hash(nonce)
167 if err := s.put(ctx, tx, "init", id, f); err != nil {
168 return err
169 }
170 return commit(tx)
171}
172
173// Complete consumes the initiation after the shared consumer has consumed and
174// verified the Home assertion. It keeps the local Docs session intact.
175func (s *Store) Complete(ctx context.Context, secret string, local Local, home Home, now time.Time) (string, error) {
176 id, ok := secretHash(secret)
177 if !ok {
178 return "", ErrRefused
179 }
180 tx, high, err := s.begin(ctx, now)
181 if err != nil {
182 return "", err
183 }
184 defer tx.Rollback()
185 f, err := s.load(ctx, tx, "init", id, high, true)
186 if errors.Is(err, ErrRefused) {
187 return "", refuse(tx)
188 }
189 if err != nil {
190 return "", err
191 }
192 if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(home.Nonce) || f.NonceHash != hash(home.Nonce) || home.Issuer != s.issuer || !validSubject(home.Subject) || !fresh(home.AuthTime.Unix(), now.Unix(), high) {
193 return "", refuse(tx)
194 }
195 exists, err := personExists(ctx, tx, local.PersonID)
196 if err != nil {
197 return "", err
198 }
199 if !exists {
200 return "", refuse(tx)
201 }
202 pending, err := newSecret()
203 if err != nil {
204 return "", err
205 }
206 f.Subject, f.HomeAuth = home.Subject, home.AuthTime.Unix()
207 f.Expires = min(f.Expires, f.HomeAuth+maxAge)
208 if f.Expires <= high {
209 return "", refuse(tx)
210 }
211 if err := s.put(ctx, tx, "pending", hash(pending), f); err != nil {
212 return "", err
213 }
214 if err := commit(tx); err != nil {
215 return "", err
216 }
217 return pending, nil
218}
219
220func validPending(f flow, now, high int64) bool {
221 return f.NonceHash != "" && validSubject(f.Subject) && fresh(f.HomeAuth, now, high)
222}
223func pendingView(f flow) Pending {
224 return Pending{PersonID: f.PersonID, ReturnPath: f.ReturnPath, HomeAuthTime: time.Unix(f.HomeAuth, 0)}
225}
226
227// Pending reads a still-valid continuation for its current local session.
228// Invalid presented state is consumed; reading valid state creates no link.
229func (s *Store) Pending(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) {
230 id, ok := secretHash(secret)
231 if !ok {
232 return Pending{}, ErrRefused
233 }
234 tx, high, err := s.begin(ctx, now)
235 if err != nil {
236 return Pending{}, err
237 }
238 defer tx.Rollback()
239 f, err := s.load(ctx, tx, "pending", id, high, true)
240 if errors.Is(err, ErrRefused) {
241 return Pending{}, refuse(tx)
242 }
243 if err != nil {
244 return Pending{}, err
245 }
246 if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) {
247 return Pending{}, refuse(tx)
248 }
249 exists, err := personExists(ctx, tx, local.PersonID)
250 if err != nil {
251 return Pending{}, err
252 }
253 if !exists {
254 return Pending{}, refuse(tx)
255 }
256 if err := s.put(ctx, tx, "pending", id, f); err != nil {
257 return Pending{}, err
258 }
259 if err := commit(tx); err != nil {
260 return Pending{}, err
261 }
262 return pendingView(f), nil
263}
264
265// Confirm requires an explicit same-origin POST. The proof deletion and unique
266// mapping write commit together. A separate proof for the same mapping is
267// idempotent; a reused cookie or conflicting mapping is consumed and refused.
268func (s *Store) Confirm(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) {
269 id, ok := secretHash(secret)
270 if !ok {
271 return Pending{}, ErrRefused
272 }
273 tx, high, err := s.begin(ctx, now)
274 if err != nil {
275 return Pending{}, err
276 }
277 defer tx.Rollback()
278 f, err := s.load(ctx, tx, "pending", id, high, true)
279 if errors.Is(err, ErrRefused) {
280 return Pending{}, refuse(tx)
281 }
282 if err != nil {
283 return Pending{}, err
284 }
285 if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) {
286 return Pending{}, refuse(tx)
287 }
288 exists, err := personExists(ctx, tx, local.PersonID)
289 if err != nil {
290 return Pending{}, err
291 }
292 if !exists {
293 return Pending{}, refuse(tx)
294 }
295 _, err = tx.ExecContext(ctx, "INSERT INTO docs_identity_links (issuer, subject, person_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", s.issuer, f.Subject, f.PersonID)
296 if err != nil {
297 return Pending{}, ErrStorage
298 }
299 var person int64
300 err = tx.QueryRowContext(ctx, "SELECT person_id FROM docs_identity_links WHERE issuer = ? AND subject = ?", s.issuer, f.Subject).Scan(&person)
301 if errors.Is(err, sql.ErrNoRows) {
302 return Pending{}, refuse(tx)
303 }
304 if err != nil {
305 return Pending{}, ErrStorage
306 }
307 if person != f.PersonID {
308 return Pending{}, refuse(tx)
309 }
310 if err := commit(tx); err != nil {
311 return Pending{}, err
312 }
313 return pendingView(f), nil
314}
315