eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// 🤖 Sign in with Apple tests (siwa.go): a fake Apple — an httptest JWKS
4// server and self-minted RS256 identity tokens — exercises the whole path
5// with no Apple dependency, matching the seed-state-directly convention.
6
7import (
8 "crypto"
9 "crypto/rand"
10 "crypto/rsa"
11 "crypto/sha256"
12 "encoding/base64"
13 "encoding/json"
14 "io"
15 "net/http"
16 "net/http/httptest"
17 "testing"
18 "time"
19)
20
21// fakeApple serves a JWKS for a fresh RSA key and mints identity tokens
22// signed with it.
23type fakeApple struct {
24 key *rsa.PrivateKey
25 ts *httptest.Server
26}
27
28func newFakeApple(t *testing.T) *fakeApple {
29 t.Helper()
30 key, err := rsa.GenerateKey(rand.Reader, 2048)
31 if err != nil {
32 t.Fatalf("rsa: %v", err)
33 }
34 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35 writeJSON(w, http.StatusOK, map[string]any{
36 "keys": []map[string]string{{
37 "kty": "RSA", "kid": "test-kid",
38 "n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
39 "e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
40 }},
41 })
42 }))
43 t.Cleanup(ts.Close)
44 return &fakeApple{key: key, ts: ts}
45}
46
47// token mints an identity token; overrides patch the default valid claims.
48func (a *fakeApple) token(t *testing.T, overrides map[string]any) string {
49 t.Helper()
50 claims := map[string]any{
51 "iss": appleIssuer,
52 "aud": "com.elevenmessenger.messenger",
53 "sub": "apple-sub-1",
54 "exp": time.Now().Add(5 * time.Minute).Unix(),
55 "nonce": "",
56 }
57 for k, v := range overrides {
58 claims[k] = v
59 }
60 head, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "test-kid"})
61 body, _ := json.Marshal(claims)
62 signing := base64.RawURLEncoding.EncodeToString(head) + "." + base64.RawURLEncoding.EncodeToString(body)
63 digest := sha256.Sum256([]byte(signing))
64 sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, digest[:])
65 if err != nil {
66 t.Fatalf("sign: %v", err)
67 }
68 return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
69}
70
71// siwaServer is newTestServer with SiwA enabled against the fake Apple.
72func siwaServer(t *testing.T) (*httptest.Server, *Server, *fakeApple) {
73 t.Helper()
74 ts, s := newTestServer(t)
75 apple := newFakeApple(t)
76 s.siwaClientIDs = []string{"com.elevenmessenger.messenger", "com.elevenmessenger.signin"}
77 s.siwaPepper = []byte("test-pepper")
78 s.siwa = &jwksCache{url: apple.ts.URL}
79 return ts, s, apple
80}
81
82func siwaVerifyReq(t *testing.T, ts *httptest.Server, headers map[string]string, body map[string]any) *http.Response {
83 t.Helper()
84 b, _ := json.Marshal(body)
85 return do(t, "POST", ts.URL+"/api/siwa/verify", "", headers, b)
86}
87
88func TestSiwaDisabledIs404(t *testing.T) {
89 ts, _ := newTestServer(t) // no client ids, no pepper
90 resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": "x"})
91 if resp.StatusCode != http.StatusNotFound {
92 t.Fatalf("disabled siwa: got %d, want 404", resp.StatusCode)
93 }
94}
95
96func TestSiwaSignInCreatesThenResolvesAccount(t *testing.T) {
97 ts, _, apple := siwaServer(t)
98
99 // First sign-in: a fresh account, token + proof handed back.
100 resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": apple.token(t, nil)})
101 if resp.StatusCode != http.StatusOK {
102 t.Fatalf("first sign-in: got %d", resp.StatusCode)
103 }
104 var first struct {
105 Token string `json:"token"`
106 Proof string `json:"proof"`
107 Created bool `json:"created"`
108 }
109 if err := json.NewDecoder(resp.Body).Decode(&first); err != nil {
110 t.Fatalf("decode: %v", err)
111 }
112 if first.Token == "" || first.Proof == "" || !first.Created {
113 t.Fatalf("first sign-in: token=%q proof=%q created=%v", first.Token, first.Proof, first.Created)
114 }
115
116 // The minted token is a real device link token: it can write the vault.
117 put := do(t, "PUT", ts.URL+"/api/blob", first.Token, map[string]string{"If-Match": "0"}, []byte("sealed"))
118 if put.StatusCode != http.StatusOK {
119 t.Fatalf("blob put with siwa token: got %d", put.StatusCode)
120 }
121
122 // Second sign-in, same sub: the SAME account (its blob is readable).
123 resp2 := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": apple.token(t, nil)})
124 var second struct {
125 Token string `json:"token"`
126 Created bool `json:"created"`
127 }
128 if err := json.NewDecoder(resp2.Body).Decode(&second); err != nil {
129 t.Fatalf("decode second: %v", err)
130 }
131 if second.Created {
132 t.Fatalf("second sign-in claims a fresh account")
133 }
134 get := do(t, "GET", ts.URL+"/api/blob", second.Token, nil, nil)
135 if get.StatusCode != http.StatusOK {
136 t.Fatalf("blob get with second token: got %d", get.StatusCode)
137 }
138 if b, _ := io.ReadAll(get.Body); string(b) != "sealed" {
139 t.Fatalf("second sign-in reads a different vault: %q", b)
140 }
141}
142
143func TestSiwaRejectsBadTokens(t *testing.T) {
144 ts, _, apple := siwaServer(t)
145 cases := map[string]string{
146 "wrong issuer": apple.token(t, map[string]any{"iss": "https://evil.example"}),
147 "wrong audience": apple.token(t, map[string]any{"aud": "com.other.app"}),
148 "expired": apple.token(t, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()}),
149 "empty sub": apple.token(t, map[string]any{"sub": ""}),
150 "garbage": "not.a.jwt",
151 }
152 for name, tok := range cases {
153 resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": tok})
154 if resp.StatusCode != http.StatusUnauthorized {
155 t.Errorf("%s: got %d, want 401", name, resp.StatusCode)
156 }
157 }
158 // Tampered signature: valid token, one flipped payload byte.
159 tok := apple.token(t, nil)
160 tampered := tok[:len(tok)-40] + "AAAA" + tok[len(tok)-36:]
161 resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": tampered})
162 if resp.StatusCode != http.StatusUnauthorized {
163 t.Errorf("tampered: got %d, want 401", resp.StatusCode)
164 }
165}
166
167func TestSiwaNonceBinding(t *testing.T) {
168 ts, _, apple := siwaServer(t)
169 tok := apple.token(t, map[string]any{"nonce": "expected-nonce"})
170 resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": tok, "nonce": "expected-nonce"})
171 if resp.StatusCode != http.StatusOK {
172 t.Fatalf("matching nonce: got %d", resp.StatusCode)
173 }
174 resp = siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": tok, "nonce": "different"})
175 if resp.StatusCode != http.StatusUnauthorized {
176 t.Fatalf("mismatched nonce: got %d, want 401", resp.StatusCode)
177 }
178}
179
180func TestSiwaLinkToExistingAccount(t *testing.T) {
181 ts, s, apple := siwaServer(t)
182 uid, token := seedUser(t, s)
183 proof := s.keys.mintCeremonyProof(uid)
184
185 // Link needs a fresh ceremony proof — same bar as adding a passkey.
186 resp := siwaVerifyReq(t, ts, nil, map[string]any{
187 "identity_token": apple.token(t, map[string]any{"sub": "link-sub"}), "link": true})
188 if resp.StatusCode != http.StatusUnauthorized {
189 t.Fatalf("link without proof: got %d, want 401", resp.StatusCode)
190 }
191 resp = siwaVerifyReq(t, ts, map[string]string{"X-Ceremony-Proof": proof}, map[string]any{
192 "identity_token": apple.token(t, map[string]any{"sub": "link-sub"}), "link": true})
193 if resp.StatusCode != http.StatusOK {
194 t.Fatalf("link with proof: got %d", resp.StatusCode)
195 }
196
197 // Signing in with that Apple ID now lands in the seeded account: its
198 // vault is the one the seeded link token wrote.
199 put := do(t, "PUT", ts.URL+"/api/blob", token, map[string]string{"If-Match": "0"}, []byte("seeded-vault"))
200 if put.StatusCode != http.StatusOK {
201 t.Fatalf("seed blob: got %d", put.StatusCode)
202 }
203 resp = siwaVerifyReq(t, ts, nil, map[string]any{
204 "identity_token": apple.token(t, map[string]any{"sub": "link-sub"})})
205 var signin struct {
206 Token string `json:"token"`
207 Created bool `json:"created"`
208 }
209 if err := json.NewDecoder(resp.Body).Decode(&signin); err != nil {
210 t.Fatalf("decode: %v", err)
211 }
212 if signin.Created {
213 t.Fatalf("sign-in after link created a fresh account")
214 }
215 get := do(t, "GET", ts.URL+"/api/blob", signin.Token, nil, nil)
216 if b, _ := io.ReadAll(get.Body); string(b) != "seeded-vault" {
217 t.Fatalf("linked sign-in reads a different vault: %q", b)
218 }
219
220 // The same Apple ID cannot anchor a SECOND account.
221 uid2, _ := seedUser(t, s)
222 proof2 := s.keys.mintCeremonyProof(uid2)
223 resp = siwaVerifyReq(t, ts, map[string]string{"X-Ceremony-Proof": proof2}, map[string]any{
224 "identity_token": apple.token(t, map[string]any{"sub": "link-sub"}), "link": true})
225 if resp.StatusCode != http.StatusConflict {
226 t.Fatalf("second-account link: got %d, want 409", resp.StatusCode)
227 }
228}
229
230func TestSiwaReturnTrampoline(t *testing.T) {
231 ts, _, _ := siwaServer(t)
232 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
233 return http.ErrUseLastResponse
234 }}
235 resp, err := client.PostForm(ts.URL+"/siwa/return", map[string][]string{
236 "id_token": {"tok.en.value"}, "state": {"st ate"}})
237 if err != nil {
238 t.Fatalf("post: %v", err)
239 }
240 if resp.StatusCode != http.StatusSeeOther {
241 t.Fatalf("trampoline: got %d, want 303", resp.StatusCode)
242 }
243 loc := resp.Header.Get("Location")
244 want := siwaReturnScheme + "#state=st+ate&id_token=tok.en.value"
245 if loc != want {
246 t.Fatalf("trampoline location: got %q, want %q", loc, want)
247 }
248}
249
250func TestSiwaReturnWebState(t *testing.T) {
251 ts, _, _ := siwaServer(t)
252 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
253 return http.ErrUseLastResponse
254 }}
255 // A "web."-prefixed state (minted by the home page) bounces back to the
256 // page itself — a relative, same-origin redirect with the token in the
257 // fragment.
258 resp, err := client.PostForm(ts.URL+"/siwa/return", map[string][]string{
259 "id_token": {"tok.en.value"}, "state": {"web.abc123"}})
260 if err != nil {
261 t.Fatalf("post: %v", err)
262 }
263 if resp.StatusCode != http.StatusSeeOther {
264 t.Fatalf("web trampoline: got %d, want 303", resp.StatusCode)
265 }
266 if loc, want := resp.Header.Get("Location"), "/#siwa=tok.en.value&state=web.abc123"; loc != want {
267 t.Fatalf("web trampoline location: got %q, want %q", loc, want)
268 }
269 // Any other state keeps the native apps' custom-scheme bounce.
270 resp, err = client.PostForm(ts.URL+"/siwa/return", map[string][]string{
271 "id_token": {"tok.en.value"}, "state": {"native-state"}})
272 if err != nil {
273 t.Fatalf("post: %v", err)
274 }
275 if loc, want := resp.Header.Get("Location"), siwaReturnScheme+"#state=native-state&id_token=tok.en.value"; loc != want {
276 t.Fatalf("native trampoline location: got %q, want %q", loc, want)
277 }
278}
279