carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "crypto/aes"
6 "crypto/cipher"
7 "crypto/ecdh"
8 "crypto/ecdsa"
9 "crypto/elliptic"
10 "crypto/hkdf"
11 "crypto/rand"
12 "crypto/sha256"
13 "encoding/base64"
14 "encoding/json"
15 "errors"
16 "math/big"
17 "net/http"
18 "net/http/httptest"
19 "net/url"
20 "strings"
21 "sync"
22 "testing"
23 "time"
24)
25
26// ---- a fake keymail, faithful to oauth.go / appapi.go on the wires the
27// console touches. Shared with server_test.go.
28
29type sentMessage struct {
30 ID, From, To, Subject, Body, Kind string
31}
32
33type fakeKeymail struct {
34 t *testing.T
35 srv *httptest.Server
36
37 mu sync.Mutex
38 address string // the user identity minted at exchange
39 expectCode string // the single code the fake will honor
40 challenge string // PKCE challenge captured from the authorize URL
41 token string // access token minted at exchange
42 signPub string // sign_pub pinned at exchange
43 origin string // client_id seen at exchange
44 boxPriv *ecdh.PrivateKey
45 send401 bool // simulate a revoked grant on /api/app/send
46 revoked []string
47 sends []sentMessage
48 exchanges int
49
50 // Step-up (stepup.go). reauth mirrors /.well-known/keymail's advertised
51 // capability; authTime is what the next exchange reports (zero = "this
52 // deployment proved no gesture", the fail-closed case); stepUp records
53 // whether the last exchange replayed the step-up redirect_uri.
54 reauth bool
55 authTime time.Time
56 stepUp bool
57
58 // Federation (federation.go / relay.go proxyAPI). fedSends is every
59 // envelope accepted over /api/federation/deliver (Kind "federation").
60 // powBits is the price this mailbox charges a sender it doesn't know yet.
61 // noMailbox marks local parts with no claimed mailbox (404 at lookup).
62 // blocked marks senders this mailbox has blocked. senders is the
63 // registered-sender table: from -> the sign_pub it proved. registrations
64 // counts successful /api/federation/register calls. challenges is the
65 // outstanding challenge-token table (the real Keymail seals the token
66 // instead of keeping a map, but the wire is the same).
67 fedSends []sentMessage
68 powBits int
69 noMailbox map[string]bool
70 blocked map[string]bool
71 senders map[string]string
72 registrations int
73 challenges map[string]fakeChallenge
74
75 // lookupStatus makes the lookup for one full address answer that HTTP
76 // status (400/404/409/500...) instead of its usual 200; lookupStall makes
77 // it hold the answer for that long (or until the caller gives up).
78 lookupStatus map[string]int
79 lookupStall map[string]time.Duration
80 // lookups counts every federation lookup the fake answered or was asked.
81 lookups int
82 // deliverStatus, when set, makes /api/federation/deliver answer that
83 // status (an ordinary post-lookup failure) before looking at the envelope.
84 deliverStatus int
85}
86
87// sentBodies is every message body the fake has accepted — how a test proves
88// a link was delivered into a Keymail inbox rather than emailed.
89func (f *fakeKeymail) sentBodies() []string {
90 f.mu.Lock()
91 defer f.mu.Unlock()
92 out := make([]string, 0, len(f.sends))
93 for _, s := range f.sends {
94 out = append(out, s.Body)
95 }
96 return out
97}
98
99func newFakeKeymail(t *testing.T, address string) *fakeKeymail {
100 t.Helper()
101 boxPriv, err := ecdh.P256().GenerateKey(rand.Reader)
102 if err != nil {
103 t.Fatal(err)
104 }
105 // reauth defaults on: the deployments this console talks to are relays, and
106 // a test that wants the fail-closed path turns it off explicitly.
107 f := &fakeKeymail{
108 t: t, address: address, boxPriv: boxPriv, reauth: true,
109 powBits: 8,
110 noMailbox: map[string]bool{},
111 blocked: map[string]bool{},
112 lookupStatus: map[string]int{},
113 lookupStall: map[string]time.Duration{},
114 senders: map[string]string{},
115 }
116 mux := http.NewServeMux()
117 mux.HandleFunc("GET /.well-known/keymail", f.handleWellKnown)
118 mux.HandleFunc("POST /api/oauth/token", f.handleToken)
119 mux.HandleFunc("POST /api/oauth/revoke", f.handleRevoke)
120 mux.HandleFunc("GET /api/federation/lookup", f.handleLookup)
121 mux.HandleFunc("POST /api/app/send", f.handleSend)
122 mux.HandleFunc("POST /api/federation/challenge", f.handleChallenge)
123 mux.HandleFunc("POST /api/federation/register", f.handleRegister)
124 mux.HandleFunc("POST /api/federation/deliver", f.handleDeliver)
125 f.srv = httptest.NewServer(mux)
126 t.Cleanup(f.srv.Close)
127 return f
128}
129
130func jsonErr(w http.ResponseWriter, status int, msg string) {
131 w.Header().Set("Content-Type", "application/json")
132 w.WriteHeader(status)
133 json.NewEncoder(w).Encode(map[string]string{"error": msg})
134}
135
136func (f *fakeKeymail) handleToken(w http.ResponseWriter, r *http.Request) {
137 f.mu.Lock()
138 defer f.mu.Unlock()
139 var req struct {
140 Code string `json:"code"`
141 CodeVerifier string `json:"code_verifier"`
142 ClientID string `json:"client_id"`
143 RedirectURI string `json:"redirect_uri"`
144 SignPub string `json:"sign_pub"`
145 }
146 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
147 jsonErr(w, http.StatusBadRequest, "bad json")
148 return
149 }
150 if f.expectCode == "" || req.Code != f.expectCode {
151 jsonErr(w, http.StatusBadRequest, "code invalid, used, or expired")
152 return
153 }
154 f.expectCode = "" // single-use, burn-then-check manners
155 if f.challenge != "" && ChallengeS256(req.CodeVerifier) != f.challenge {
156 jsonErr(w, http.StatusBadRequest, "PKCE verification failed")
157 return
158 }
159 switch req.RedirectURI {
160 case req.ClientID + "/auth/callback":
161 f.stepUp = false
162 case req.ClientID + "/auth/stepup/callback":
163 f.stepUp = true
164 default:
165 jsonErr(w, http.StatusBadRequest, "client_id or redirect_uri does not match the authorization")
166 return
167 }
168 // Scope "identify send" requires the app's envelope-signing key: a
169 // base64 raw uncompressed P-256 point (keymail parseECDSAPub).
170 raw, err := base64.StdEncoding.DecodeString(req.SignPub)
171 if err != nil || len(raw) != 65 || raw[0] != 4 {
172 jsonErr(w, http.StatusBadRequest, "sign_pub (the app's envelope-signing key) is required to send")
173 return
174 }
175 f.signPub = req.SignPub
176 f.origin = req.ClientID
177 f.token = "user.tok-" + f.address
178 f.exchanges++
179 out := map[string]any{
180 "access_token": f.token,
181 "token_type": "bearer",
182 "scope": "identify send",
183 "address": f.address,
184 }
185 // auth_time is OMITTED when no gesture was proved — keymail never invents
186 // one, and the console must treat its absence as a refusal.
187 if !f.authTime.IsZero() {
188 out["auth_time"] = f.authTime.Unix()
189 }
190 w.Header().Set("Content-Type", "application/json")
191 json.NewEncoder(w).Encode(out)
192}
193
194// handleWellKnown is the capability probe the step-up gate makes before it
195// sends anyone anywhere.
196func (f *fakeKeymail) handleWellKnown(w http.ResponseWriter, r *http.Request) {
197 f.mu.Lock()
198 defer f.mu.Unlock()
199 w.Header().Set("Content-Type", "application/json")
200 json.NewEncoder(w).Encode(map[string]any{
201 "version": "keymail1", "host": "keymail.dev", "pow_bits": 8, "reauth": f.reauth,
202 })
203}
204
205func (f *fakeKeymail) handleRevoke(w http.ResponseWriter, r *http.Request) {
206 f.mu.Lock()
207 defer f.mu.Unlock()
208 var req struct {
209 Token string `json:"token"`
210 }
211 json.NewDecoder(r.Body).Decode(&req)
212 f.revoked = append(f.revoked, req.Token)
213 w.Header().Set("Content-Type", "application/json")
214 json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
215}
216
217// handleLookup routes like the relay (keymail/relay.go proxyAPI) and prices
218// like the instance (keymail/federation.go handleLookup): a host other than
219// keymail.dev never resolves (the relay only owns that one host), and an
220// unclaimed local part 404s as "no such mailbox" one level up from "no such
221// inbox" — the fake collapses the two hops into one handler.
222func (f *fakeKeymail) handleLookup(w http.ResponseWriter, r *http.Request) {
223 addr := r.URL.Query().Get("addr")
224 f.mu.Lock()
225 f.lookups++
226 stall, status := f.lookupStall[addr], f.lookupStatus[addr]
227 f.mu.Unlock()
228 if stall > 0 {
229 select {
230 case <-time.After(stall):
231 case <-r.Context().Done():
232 return
233 }
234 }
235 if status != 0 && status != http.StatusOK {
236 jsonErr(w, status, http.StatusText(status))
237 return
238 }
239 f.mu.Lock()
240 defer f.mu.Unlock()
241 // A forced 200 resolves whatever the host (a Keymail-served custom
242 // domain), so it skips the keymail.dev-only routing.
243 if status != http.StatusOK {
244 local, host, ok := strings.Cut(addr, "@")
245 if !ok || host != "keymail.dev" {
246 jsonErr(w, http.StatusBadRequest, "could not determine target mailbox")
247 return
248 }
249 if f.noMailbox[local] {
250 jsonErr(w, http.StatusNotFound, "no such mailbox")
251 return
252 }
253 }
254 bits := f.powBits
255 if _, known := f.senders[r.URL.Query().Get("from")]; known {
256 bits = 0 // keymail prices a known sender at 0 whatever key it brings
257 }
258 w.Header().Set("Content-Type", "application/json")
259 json.NewEncoder(w).Encode(map[string]any{
260 "addr": addr,
261 "sign_pub": "",
262 "box_pub": base64.StdEncoding.EncodeToString(f.boxPriv.PublicKey().Bytes()),
263 "pow_bits": bits,
264 })
265}
266
267// fakeChallenge is the outstanding-challenge record keyed by token. The real
268// Keymail seals the challenge instead of keeping a map (federation.go
269// sealChallenge/openChallenge), but the wire — and what register checks — is
270// the same.
271type fakeChallenge struct {
272 to, from, signPub string
273 bits int
274}
275
276func (f *fakeKeymail) handleChallenge(w http.ResponseWriter, r *http.Request) {
277 f.mu.Lock()
278 defer f.mu.Unlock()
279 var raw struct {
280 To string `json:"to"`
281 From string `json:"from"`
282 SignPub string `json:"sign_pub"`
283 }
284 if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
285 jsonErr(w, http.StatusBadRequest, "bad json")
286 return
287 }
288 if f.blocked[raw.From] {
289 jsonErr(w, http.StatusForbidden, "blocked")
290 return
291 }
292 bits := f.powBits
293 if f.senders[raw.From] == raw.SignPub {
294 bits = 0
295 }
296 tok := make([]byte, 16)
297 rand.Read(tok)
298 token := base64.RawURLEncoding.EncodeToString(tok)
299 if f.challenges == nil {
300 f.challenges = map[string]fakeChallenge{}
301 }
302 f.challenges[token] = fakeChallenge{to: raw.To, from: raw.From, signPub: raw.SignPub, bits: bits}
303 w.Header().Set("Content-Type", "application/json")
304 json.NewEncoder(w).Encode(map[string]any{"challenge": token, "bits": bits, "expires_at": time.Now().Add(time.Minute).Unix()})
305}
306
307func (f *fakeKeymail) handleRegister(w http.ResponseWriter, r *http.Request) {
308 f.mu.Lock()
309 defer f.mu.Unlock()
310 var req struct {
311 To string `json:"to"`
312 Challenge string `json:"challenge"`
313 Counter uint64 `json:"counter"`
314 From string `json:"from"`
315 SignPub string `json:"sign_pub"`
316 BoxPub string `json:"box_pub"`
317 Attestation string `json:"attestation"`
318 }
319 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
320 jsonErr(w, http.StatusBadRequest, "bad json")
321 return
322 }
323 c, ok := f.challenges[req.Challenge]
324 if !ok {
325 jsonErr(w, http.StatusBadRequest, "bad challenge")
326 return
327 }
328 delete(f.challenges, req.Challenge)
329 if c.from != req.From || c.signPub != req.SignPub {
330 jsonErr(w, http.StatusBadRequest, "challenge does not match this sender")
331 return
332 }
333 nonce := sha256.Sum256([]byte(req.Challenge))
334 signPubRaw, _ := base64.StdEncoding.DecodeString(req.SignPub)
335 if refLeadingZeros(refHash(nonce[:], signPubRaw, req.Counter)) < c.bits {
336 jsonErr(w, http.StatusForbidden, "proof of work does not meet difficulty")
337 return
338 }
339 attest := []byte("keymail-attest-v1\n" + req.From + "\n" + req.SignPub + "\n" + req.BoxPub)
340 if !testVerify(req.SignPub, attest, req.Attestation) {
341 jsonErr(w, http.StatusForbidden, "bad key attestation")
342 return
343 }
344 f.senders[req.From] = req.SignPub
345 f.registrations++
346 w.Header().Set("Content-Type", "application/json")
347 json.NewEncoder(w).Encode(map[string]string{"status": "proven"})
348}
349
350func (f *fakeKeymail) handleDeliver(w http.ResponseWriter, r *http.Request) {
351 f.mu.Lock()
352 defer f.mu.Unlock()
353 var req struct {
354 Envelope envelope `json:"envelope"`
355 }
356 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
357 jsonErr(w, http.StatusBadRequest, "bad json")
358 return
359 }
360 e := req.Envelope
361 if f.deliverStatus != 0 {
362 jsonErr(w, f.deliverStatus, http.StatusText(f.deliverStatus))
363 return
364 }
365 if f.blocked[e.From] {
366 jsonErr(w, http.StatusForbidden, "blocked")
367 return
368 }
369 reg, known := f.senders[e.From]
370 if !known {
371 w.Header().Set("Content-Type", "application/json")
372 w.WriteHeader(http.StatusForbidden)
373 json.NewEncoder(w).Encode(map[string]any{"error": "unknown_sender", "pow_bits": f.powBits})
374 return
375 }
376 if reg != e.SignPub {
377 jsonErr(w, http.StatusForbidden, "signing key does not match registered key")
378 return
379 }
380 if e.V != "keymail1" || e.ID == "" || e.Date == "" ||
381 !testVerify(e.SignPub, envelopeMsg(e.ID, e.From, e.To, e.Date, e.Blob), e.Sig) {
382 jsonErr(w, http.StatusBadRequest, "bad envelope signature")
383 return
384 }
385 plain, err := testOpen(f.boxPriv, e.Blob)
386 if err != nil {
387 jsonErr(w, http.StatusBadRequest, "blob does not open")
388 return
389 }
390 var p struct{ Subject, Body string }
391 json.Unmarshal(plain, &p)
392 f.fedSends = append(f.fedSends, sentMessage{ID: e.ID, From: e.From, To: e.To, Subject: p.Subject, Body: p.Body, Kind: "federation"})
393 w.Header().Set("Content-Type", "application/json")
394 json.NewEncoder(w).Encode(map[string]string{"status": "delivered"})
395}
396
397// fedBodies is every message body the fake has accepted over federation —
398// how a test proves an invite reached someone who never granted the
399// console anything.
400func (f *fakeKeymail) fedBodies() []string {
401 f.mu.Lock()
402 defer f.mu.Unlock()
403 out := make([]string, 0, len(f.fedSends))
404 for _, s := range f.fedSends {
405 out = append(out, s.Body)
406 }
407 return out
408}
409
410func (f *fakeKeymail) handleSend(w http.ResponseWriter, r *http.Request) {
411 f.mu.Lock()
412 defer f.mu.Unlock()
413 if f.send401 || r.Header.Get("Authorization") != "Bearer "+f.token || f.token == "" {
414 jsonErr(w, http.StatusUnauthorized, "bad, expired or revoked app token")
415 return
416 }
417 var req struct {
418 Envelope envelope `json:"envelope"`
419 Kind string `json:"kind"`
420 }
421 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
422 jsonErr(w, http.StatusBadRequest, "bad json")
423 return
424 }
425 e := req.Envelope
426 if e.To == "" {
427 jsonErr(w, http.StatusForbidden, "envelope needs a recipient")
428 return
429 }
430 // The grant authorizes exactly one recipient: the user who approved it
431 // (keymail appapi.go handleAppSend). This fake used to accept anyone,
432 // which is how invites shipped broken.
433 grantUser := strings.TrimPrefix(f.token, "user.tok-")
434 if e.To != grantUser {
435 jsonErr(w, http.StatusForbidden, "envelope to must be "+grantUser)
436 return
437 }
438 if host := OriginHost(f.origin); !strings.HasSuffix(e.From, "@"+host) {
439 jsonErr(w, http.StatusForbidden, "envelope from must be an address on "+host)
440 return
441 }
442 if e.SignPub != f.signPub {
443 jsonErr(w, http.StatusForbidden, "signing key does not match this grant")
444 return
445 }
446 if e.V != "keymail1" || e.ID == "" || e.Date == "" ||
447 !testVerify(e.SignPub, envelopeMsg(e.ID, e.From, e.To, e.Date, e.Blob), e.Sig) {
448 jsonErr(w, http.StatusBadRequest, "bad envelope signature")
449 return
450 }
451 plain, err := testOpen(f.boxPriv, e.Blob)
452 if err != nil {
453 jsonErr(w, http.StatusBadRequest, "blob does not open: "+err.Error())
454 return
455 }
456 var payload struct{ Subject, Body string }
457 if err := json.Unmarshal(plain, &payload); err != nil {
458 jsonErr(w, http.StatusBadRequest, "payload is not subject/body json")
459 return
460 }
461 f.sends = append(f.sends, sentMessage{
462 ID: e.ID, From: e.From, To: e.To,
463 Subject: payload.Subject, Body: payload.Body, Kind: req.Kind,
464 })
465 w.Header().Set("Content-Type", "application/json")
466 json.NewEncoder(w).Encode(map[string]string{"status": "delivered", "folder": "apps"})
467}
468
469// testVerify mirrors keymail crypto.go verifySig: raw r||s ECDSA P-256 over
470// sha256(msg), key a base64 raw uncompressed point.
471func testVerify(b64pub string, msg []byte, b64sig string) bool {
472 raw, err := base64.StdEncoding.DecodeString(b64pub)
473 if err != nil || len(raw) != 65 || raw[0] != 4 {
474 return false
475 }
476 pub := &ecdsa.PublicKey{
477 Curve: elliptic.P256(),
478 X: new(big.Int).SetBytes(raw[1:33]),
479 Y: new(big.Int).SetBytes(raw[33:65]),
480 }
481 sig, err := base64.StdEncoding.DecodeString(b64sig)
482 if err != nil || len(sig) != 64 {
483 return false
484 }
485 h := sha256.Sum256(msg)
486 return ecdsa.Verify(pub, h[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:]))
487}
488
489// testOpen mirrors keymail crypto.go open: the inverse of sealToBox.
490func testOpen(boxPriv *ecdh.PrivateKey, blobB64 string) ([]byte, error) {
491 blob, err := base64.StdEncoding.DecodeString(blobB64)
492 if err != nil || len(blob) < 65+12+16 {
493 return nil, errors.New("bad blob")
494 }
495 ephPub, err := ecdh.P256().NewPublicKey(blob[:65])
496 if err != nil {
497 return nil, err
498 }
499 shared, err := boxPriv.ECDH(ephPub)
500 if err != nil {
501 return nil, err
502 }
503 key, err := hkdf.Key(sha256.New, shared, nil, envelopeInfo, 32)
504 if err != nil {
505 return nil, err
506 }
507 block, err := aes.NewCipher(key)
508 if err != nil {
509 return nil, err
510 }
511 gcm, err := cipher.NewGCM(block)
512 if err != nil {
513 return nil, err
514 }
515 return gcm.Open(nil, blob[65:77], blob[77:], nil)
516}
517
518func testSigningKey(t *testing.T) *ecdsa.PrivateKey {
519 t.Helper()
520 k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
521 if err != nil {
522 t.Fatal(err)
523 }
524 return k
525}
526
527// ---- tests ------------------------------------------------------------------
528
529func TestAuthorizeURL(t *testing.T) {
530 km := NewKeymail("https://keymail.dev/", "https://platform.carlosframework.com")
531 km.SetSigningKey(testSigningKey(t))
532 u, err := url.Parse(km.AuthorizeURL("state123", "chal456"))
533 if err != nil {
534 t.Fatal(err)
535 }
536 if u.Scheme != "https" || u.Host != "keymail.dev" || u.Path != "/oauth/authorize" {
537 t.Fatalf("authorize URL = %v", u)
538 }
539 q := u.Query()
540 for k, want := range map[string]string{
541 "client_id": "https://platform.carlosframework.com",
542 "redirect_uri": "https://platform.carlosframework.com/auth/callback",
543 "scope": "identify send",
544 "state": "state123",
545 "code_challenge": "chal456",
546 "code_challenge_method": "S256",
547 } {
548 if got := q.Get(k); got != want {
549 t.Errorf("%s = %q, want %q", k, got, want)
550 }
551 }
552}
553
554func TestAuthorizeURLScopeWithoutKey(t *testing.T) {
555 km := NewKeymail("https://keymail.dev", "https://platform.carlosframework.com")
556 u, _ := url.Parse(km.AuthorizeURL("s", "c"))
557 if got := u.Query().Get("scope"); got != "identify" {
558 t.Fatalf("scope without signing key = %q, want identify", got)
559 }
560}
561
562func TestExchangeRoundTrip(t *testing.T) {
563 fk := newFakeKeymail(t, "paul@keymail.dev")
564 km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com")
565 km.SetSigningKey(testSigningKey(t))
566
567 verifier, err := NewVerifier()
568 if err != nil {
569 t.Fatal(err)
570 }
571 if n := len(verifier); n < 43 || n > 128 {
572 t.Fatalf("verifier length %d outside RFC 7636 window", n)
573 }
574 fk.challenge = ChallengeS256(verifier)
575 fk.expectCode = "code-1"
576 addr, token, err := km.Exchange(context.Background(), "code-1", verifier)
577 if err != nil {
578 t.Fatal(err)
579 }
580 if addr != "paul@keymail.dev" {
581 t.Fatalf("address = %q", addr)
582 }
583 if token == "" || token != fk.token {
584 t.Fatalf("token = %q, fake minted %q", token, fk.token)
585 }
586 if fk.signPub != km.SignPub() {
587 t.Fatalf("fake pinned sign_pub %q, client's is %q", fk.signPub, km.SignPub())
588 }
589}
590
591func TestExchangePKCEMismatch(t *testing.T) {
592 fk := newFakeKeymail(t, "paul@keymail.dev")
593 km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com")
594 km.SetSigningKey(testSigningKey(t))
595 fk.challenge = ChallengeS256("the-real-verifier")
596 fk.expectCode = "code-1"
597 _, _, err := km.Exchange(context.Background(), "code-1", "a-different-verifier")
598 if err == nil || !strings.Contains(err.Error(), "PKCE") {
599 t.Fatalf("err = %v, want PKCE failure surfaced", err)
600 }
601}
602
603func TestExchangeBadCode(t *testing.T) {
604 fk := newFakeKeymail(t, "paul@keymail.dev")
605 km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com")
606 km.SetSigningKey(testSigningKey(t))
607 fk.expectCode = "code-1"
608 _, _, err := km.Exchange(context.Background(), "guessed", "v")
609 if err == nil || !strings.Contains(err.Error(), "code invalid") {
610 t.Fatalf("err = %v, want code-invalid error", err)
611 }
612}
613
614func TestRevoke(t *testing.T) {
615 fk := newFakeKeymail(t, "paul@keymail.dev")
616 km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com")
617 if err := km.Revoke(context.Background(), "user.tok"); err != nil {
618 t.Fatal(err)
619 }
620 if len(fk.revoked) != 1 || fk.revoked[0] != "user.tok" {
621 t.Fatalf("revoked = %v", fk.revoked)
622 }
623}
624
625func TestSendAppSealedRoundTrip(t *testing.T) {
626 fk := newFakeKeymail(t, "paul@keymail.dev")
627 km := NewKeymail(fk.srv.URL, "http://127.0.0.1:8090")
628 km.SetSigningKey(testSigningKey(t))
629 verifier, _ := NewVerifier()
630 fk.challenge = ChallengeS256(verifier)
631 fk.expectCode = "code-1"
632 _, token, err := km.Exchange(context.Background(), "code-1", verifier)
633 if err != nil {
634 t.Fatal(err)
635 }
636 id, err := km.SendApp(context.Background(), token, "paul@keymail.dev",
637 "Sign in", "click https://example.com/auth/confirm?token=abc")
638 if err != nil {
639 t.Fatal(err)
640 }
641 if id == "" {
642 t.Fatal("no envelope id")
643 }
644 if len(fk.sends) != 1 {
645 t.Fatalf("fake got %d sends", len(fk.sends))
646 }
647 got := fk.sends[0]
648 if got.ID != id || got.To != "paul@keymail.dev" || got.From != "console@127.0.0.1:8090" {
649 t.Fatalf("send = %+v", got)
650 }
651 if got.Subject != "Sign in" || !strings.Contains(got.Body, "confirm?token=abc") {
652 t.Fatalf("sealed payload did not round-trip: %+v", got)
653 }
654 if got.Kind != "app" {
655 t.Fatalf("kind = %q, want app", got.Kind)
656 }
657}
658
659func TestSendAppRevokedGrant(t *testing.T) {
660 fk := newFakeKeymail(t, "paul@keymail.dev")
661 km := NewKeymail(fk.srv.URL, "http://127.0.0.1:8090")
662 km.SetSigningKey(testSigningKey(t))
663 verifier, _ := NewVerifier()
664 fk.challenge = ChallengeS256(verifier)
665 fk.expectCode = "code-1"
666 _, token, err := km.Exchange(context.Background(), "code-1", verifier)
667 if err != nil {
668 t.Fatal(err)
669 }
670 fk.send401 = true
671 if _, err := km.SendApp(context.Background(), token, "paul@keymail.dev", "s", "b"); !errors.Is(err, ErrGrantRevoked) {
672 t.Fatalf("err = %v, want ErrGrantRevoked", err)
673 }
674}
675
676func TestSendAppWithoutKey(t *testing.T) {
677 km := NewKeymail("https://keymail.dev", "https://platform.carlosframework.com")
678 if _, err := km.SendApp(context.Background(), "tok", "paul@keymail.dev", "s", "b"); err == nil {
679 t.Fatal("SendApp without a signing key should refuse")
680 }
681}
682