eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– OIDC provider-table tests (oidc.go, issue #1112): the Google and
4// Microsoft doors against a fake IdP β€” the fakeApple rig from siwa_test.go,
5// which is provider-agnostic already (one JWKS httptest server + self-minted
6// RS256 tokens); these helpers just re-default the claims per door. Apple's
7// own suite (siwa_test.go) is the proof the shared core didn't move under
8// it β€” nothing here re-tests /api/siwa/*.
9
10import (
11 "encoding/json"
12 "io"
13 "net/http"
14 "net/http/httptest"
15 "testing"
16 "time"
17)
18
19// msTenant is Microsoft's consumer-accounts tenant id β€” what a personal
20// account's v2.0 token carries in both the issuer and tid.
21const msTenant = "9188040d-6c67-4c5b-b112-36a304b66dad"
22
23func googleToken(t *testing.T, idp *fakeApple, overrides map[string]any) string {
24 t.Helper()
25 claims := map[string]any{"iss": googleIssuer, "aud": "google-client-1", "sub": "google-sub-1"}
26 for k, v := range overrides {
27 claims[k] = v
28 }
29 return idp.token(t, claims)
30}
31
32func microsoftToken(t *testing.T, idp *fakeApple, overrides map[string]any) string {
33 t.Helper()
34 claims := map[string]any{
35 "iss": microsoftIssuerPrefix + msTenant + microsoftIssuerSuffix,
36 "tid": msTenant,
37 "aud": "microsoft-client-1",
38 "sub": "microsoft-sub-1",
39 }
40 for k, v := range overrides {
41 claims[k] = v
42 }
43 return idp.token(t, claims)
44}
45
46// oidcServer is newTestServer with both provider doors enabled against one
47// fake IdP (each door keeps its OWN jwksCache, as in production β€” they just
48// share the test JWKS URL).
49func oidcServer(t *testing.T) (*httptest.Server, *Server, *fakeApple) {
50 t.Helper()
51 ts, s := newTestServer(t)
52 idp := newFakeApple(t)
53 s.oidc = map[string]*oidcProvider{}
54 for _, name := range oidcProviderNames {
55 p := newOIDCProvider(name, []string{name + "-client-1", name + "-client-2"}, []byte(name+"-pepper"))
56 p.jwks = &jwksCache{url: idp.ts.URL}
57 s.oidc[name] = p
58 }
59 return ts, s, idp
60}
61
62func oidcVerifyReq(t *testing.T, ts *httptest.Server, provider string, headers map[string]string, body map[string]any) *http.Response {
63 t.Helper()
64 b, _ := json.Marshal(body)
65 return do(t, "POST", ts.URL+"/api/oidc/"+provider+"/verify", "", headers, b)
66}
67
68func TestOidcDisabledIs404(t *testing.T) {
69 // No table at all: every door β€” and the trampoline β€” answers 404.
70 ts, _ := newTestServer(t)
71 for _, provider := range append([]string(nil), oidcProviderNames...) {
72 resp := oidcVerifyReq(t, ts, provider, nil, map[string]any{"identity_token": "x"})
73 if resp.StatusCode != http.StatusNotFound {
74 t.Fatalf("disabled %s: got %d, want 404", provider, resp.StatusCode)
75 }
76 }
77 if resp := do(t, "POST", ts.URL+"/oidc/return", "", nil, nil); resp.StatusCode != http.StatusNotFound {
78 t.Fatalf("disabled /oidc/return: got %d, want 404", resp.StatusCode)
79 }
80
81 // One door on, the other off: gating is per provider, and an unknown
82 // provider name β€” Apple included, it lives on /api/siwa/* β€” is the same
83 // indistinguishable 404.
84 ts2, s2, idp := oidcServer(t)
85 delete(s2.oidc, "microsoft")
86 if resp := oidcVerifyReq(t, ts2, "microsoft", nil, map[string]any{"identity_token": microsoftToken(t, idp, nil)}); resp.StatusCode != http.StatusNotFound {
87 t.Fatalf("disabled microsoft beside enabled google: got %d, want 404", resp.StatusCode)
88 }
89 for _, provider := range []string{"apple", "carrier-pigeon"} {
90 if resp := oidcVerifyReq(t, ts2, provider, nil, map[string]any{"identity_token": "x"}); resp.StatusCode != http.StatusNotFound {
91 t.Fatalf("unknown provider %q: got %d, want 404", provider, resp.StatusCode)
92 }
93 }
94 if resp := oidcVerifyReq(t, ts2, "google", nil, map[string]any{"identity_token": googleToken(t, idp, nil)}); resp.StatusCode != http.StatusOK {
95 t.Fatalf("enabled google: got %d, want 200", resp.StatusCode)
96 }
97}
98
99func TestOidcSignInCreatesThenResolvesAccount(t *testing.T) {
100 ts, _, idp := oidcServer(t)
101 for _, tc := range []struct {
102 provider string
103 token, second string
104 }{
105 {"google", googleToken(t, idp, nil),
106 // Google's legacy no-scheme issuer form must resolve the SAME
107 // account: the sub, not the issuer spelling, names the person.
108 googleToken(t, idp, map[string]any{"iss": "accounts.google.com"})},
109 {"microsoft", microsoftToken(t, idp, nil), microsoftToken(t, idp, nil)},
110 } {
111 t.Run(tc.provider, func(t *testing.T) {
112 resp := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.token})
113 if resp.StatusCode != http.StatusOK {
114 t.Fatalf("first sign-in: got %d", resp.StatusCode)
115 }
116 var first struct {
117 Token string `json:"token"`
118 Proof string `json:"proof"`
119 Created bool `json:"created"`
120 }
121 if err := json.NewDecoder(resp.Body).Decode(&first); err != nil {
122 t.Fatalf("decode: %v", err)
123 }
124 if first.Token == "" || first.Proof == "" || !first.Created {
125 t.Fatalf("first sign-in: token=%q proof=%q created=%v", first.Token, first.Proof, first.Created)
126 }
127
128 // The minted token is a real device link token: it writes the vault.
129 put := do(t, "PUT", ts.URL+"/api/blob", first.Token, map[string]string{"If-Match": "0"}, []byte("sealed-"+tc.provider))
130 if put.StatusCode != http.StatusOK {
131 t.Fatalf("blob put: got %d", put.StatusCode)
132 }
133
134 // Second sign-in, same sub: the SAME account (its blob is readable).
135 resp2 := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.second})
136 var second struct {
137 Token string `json:"token"`
138 Created bool `json:"created"`
139 }
140 if err := json.NewDecoder(resp2.Body).Decode(&second); err != nil {
141 t.Fatalf("decode second: %v", err)
142 }
143 if second.Created {
144 t.Fatalf("second sign-in claims a fresh account")
145 }
146 get := do(t, "GET", ts.URL+"/api/blob", second.Token, nil, nil)
147 if b, _ := io.ReadAll(get.Body); string(b) != "sealed-"+tc.provider {
148 t.Fatalf("second sign-in reads a different vault: %q", b)
149 }
150 })
151 }
152}
153
154func TestOidcRejectsBadTokens(t *testing.T) {
155 ts, s, idp := oidcServer(t)
156 // The matrix below fires more requests than the shared per-IP burst (10)
157 // allows; widen it β€” the limiter has its own tests, this one is about
158 // verification.
159 s.limiter = newRateLimiter(1000, 1000)
160 cases := map[string]struct {
161 provider string
162 token string
163 }{
164 "google wrong issuer": {"google", googleToken(t, idp, map[string]any{"iss": appleIssuer})},
165 "google wrong audience": {"google", googleToken(t, idp, map[string]any{"aud": "com.other.app"})},
166 "google expired": {"google", googleToken(t, idp, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})},
167 "google empty sub": {"google", googleToken(t, idp, map[string]any{"sub": ""})},
168 "google garbage": {"google", "not.a.jwt"},
169 // A provider's token never opens another provider's door: the aud
170 // (and, for microsoft, the issuer) belongs to the wrong row.
171 "google token at microsoft door": {"microsoft", googleToken(t, idp, nil)},
172
173 "microsoft apple issuer": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": appleIssuer})},
174 "microsoft empty tenant": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": microsoftIssuerPrefix + microsoftIssuerSuffix[1:]})},
175 "microsoft v1 issuer": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": "https://sts.windows.net/" + msTenant + "/"})},
176 "microsoft slash tenant": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": microsoftIssuerPrefix + "a/b" + microsoftIssuerSuffix})},
177 "microsoft tid mismatch": {"microsoft", microsoftToken(t, idp, map[string]any{"tid": "11111111-2222-3333-4444-555555555555"})},
178 "microsoft missing tid": {"microsoft", microsoftToken(t, idp, map[string]any{"tid": ""})},
179 "microsoft wrong aud": {"microsoft", microsoftToken(t, idp, map[string]any{"aud": "com.other.app"})},
180 "microsoft expired": {"microsoft", microsoftToken(t, idp, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})},
181 }
182 for name, tc := range cases {
183 resp := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.token})
184 if resp.StatusCode != http.StatusUnauthorized {
185 t.Errorf("%s: got %d, want 401", name, resp.StatusCode)
186 }
187 }
188 // Tampered signature: valid token, one flipped payload byte β€” per door.
189 for _, provider := range oidcProviderNames {
190 tok := googleToken(t, idp, nil)
191 if provider == "microsoft" {
192 tok = microsoftToken(t, idp, nil)
193 }
194 tampered := tok[:len(tok)-40] + "AAAA" + tok[len(tok)-36:]
195 resp := oidcVerifyReq(t, ts, provider, nil, map[string]any{"identity_token": tampered})
196 if resp.StatusCode != http.StatusUnauthorized {
197 t.Errorf("%s tampered: got %d, want 401", provider, resp.StatusCode)
198 }
199 }
200}
201
202func TestOidcNonceBinding(t *testing.T) {
203 ts, _, idp := oidcServer(t)
204 tok := googleToken(t, idp, map[string]any{"nonce": "expected-nonce"})
205 resp := oidcVerifyReq(t, ts, "google", nil, map[string]any{"identity_token": tok, "nonce": "expected-nonce"})
206 if resp.StatusCode != http.StatusOK {
207 t.Fatalf("matching nonce: got %d", resp.StatusCode)
208 }
209 resp = oidcVerifyReq(t, ts, "google", nil, map[string]any{"identity_token": tok, "nonce": "different"})
210 if resp.StatusCode != http.StatusUnauthorized {
211 t.Fatalf("mismatched nonce: got %d, want 401", resp.StatusCode)
212 }
213}
214
215func TestOidcLinkToExistingAccount(t *testing.T) {
216 ts, s, idp := oidcServer(t)
217 uid, token := seedUser(t, s)
218 proof := s.keys.mintCeremonyProof(uid)
219
220 // Link needs a fresh ceremony proof β€” same bar as adding a passkey.
221 resp := oidcVerifyReq(t, ts, "google", nil, map[string]any{
222 "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true})
223 if resp.StatusCode != http.StatusUnauthorized {
224 t.Fatalf("link without proof: got %d, want 401", resp.StatusCode)
225 }
226 resp = oidcVerifyReq(t, ts, "google", map[string]string{"X-Ceremony-Proof": proof}, map[string]any{
227 "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true})
228 if resp.StatusCode != http.StatusOK {
229 t.Fatalf("link with proof: got %d", resp.StatusCode)
230 }
231
232 // Signing in with that Google account now lands in the seeded account:
233 // its vault is the one the seeded link token wrote.
234 put := do(t, "PUT", ts.URL+"/api/blob", token, map[string]string{"If-Match": "0"}, []byte("seeded-vault"))
235 if put.StatusCode != http.StatusOK {
236 t.Fatalf("seed blob: got %d", put.StatusCode)
237 }
238 resp = oidcVerifyReq(t, ts, "google", nil, map[string]any{
239 "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"})})
240 var signin struct {
241 Token string `json:"token"`
242 Created bool `json:"created"`
243 }
244 if err := json.NewDecoder(resp.Body).Decode(&signin); err != nil {
245 t.Fatalf("decode: %v", err)
246 }
247 if signin.Created {
248 t.Fatalf("sign-in after link created a fresh account")
249 }
250 get := do(t, "GET", ts.URL+"/api/blob", signin.Token, nil, nil)
251 if b, _ := io.ReadAll(get.Body); string(b) != "seeded-vault" {
252 t.Fatalf("linked sign-in reads a different vault: %q", b)
253 }
254
255 // The same Google account cannot anchor a SECOND account.
256 uid2, _ := seedUser(t, s)
257 proof2 := s.keys.mintCeremonyProof(uid2)
258 resp = oidcVerifyReq(t, ts, "google", map[string]string{"X-Ceremony-Proof": proof2}, map[string]any{
259 "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true})
260 if resp.StatusCode != http.StatusConflict {
261 t.Fatalf("second-account link: got %d, want 409", resp.StatusCode)
262 }
263
264 // Cross-provider isolation: the SAME sub string through the Microsoft
265 // door is a different identity entirely (different provider, different
266 // pepper) β€” it mints a fresh account, never the linked one.
267 resp = oidcVerifyReq(t, ts, "microsoft", nil, map[string]any{
268 "identity_token": microsoftToken(t, idp, map[string]any{"sub": "link-sub"})})
269 var ms struct {
270 Created bool `json:"created"`
271 }
272 if err := json.NewDecoder(resp.Body).Decode(&ms); err != nil {
273 t.Fatalf("decode microsoft: %v", err)
274 }
275 if !ms.Created {
276 t.Fatalf("microsoft door with a google-linked sub resolved an existing account")
277 }
278}
279
280func TestOidcReturnTrampoline(t *testing.T) {
281 ts, _, _ := oidcServer(t)
282 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
283 return http.ErrUseLastResponse
284 }}
285 // A "web."-prefixed state (minted by the home page) bounces back to the
286 // page itself β€” token in the fragment, provider known to the page already
287 // (it minted the state when it opened the door).
288 resp, err := client.PostForm(ts.URL+"/oidc/return", map[string][]string{
289 "id_token": {"tok.en.value"}, "state": {"web.abc123"}})
290 if err != nil {
291 t.Fatalf("post: %v", err)
292 }
293 if resp.StatusCode != http.StatusSeeOther {
294 t.Fatalf("web trampoline: got %d, want 303", resp.StatusCode)
295 }
296 if loc, want := resp.Header.Get("Location"), "/#oidc=tok.en.value&state=web.abc123"; loc != want {
297 t.Fatalf("web trampoline location: got %q, want %q", loc, want)
298 }
299 // Any other state is a native app's ASWebAuthenticationSession, which
300 // intercepts the custom scheme.
301 resp, err = client.PostForm(ts.URL+"/oidc/return", map[string][]string{
302 "id_token": {"tok.en.value"}, "state": {"native state"}})
303 if err != nil {
304 t.Fatalf("post: %v", err)
305 }
306 if loc, want := resp.Header.Get("Location"), oidcReturnScheme+"#state=native+state&id_token=tok.en.value"; loc != want {
307 t.Fatalf("native trampoline location: got %q, want %q", loc, want)
308 }
309}
310
311func TestAnchorsListingIncludesOidcKinds(t *testing.T) {
312 ts, s := newTestServer(t)
313 uid, tok := seedUser(t, s)
314 if _, err := s.store.insertOidcAnchor(uid, "google", "google-hash-1"); err != nil {
315 t.Fatalf("insertOidcAnchor google: %v", err)
316 }
317 if _, err := s.store.insertOidcAnchor(uid, "microsoft", "microsoft-hash-1"); err != nil {
318 t.Fatalf("insertOidcAnchor microsoft: %v", err)
319 }
320
321 // The listing carries the new kinds beside the existing ones β€” note the
322 // doors are DISABLED on this server (no s.oidc): stored anchors still
323 // list and remove, exactly as siwa anchors do with SiwA off.
324 anchors := listAnchors(t, ts.URL, tok)
325 kinds := map[string]string{}
326 for _, a := range anchors {
327 kinds[a.Kind] = a.ID
328 if a.CreatedAt == 0 {
329 t.Fatalf("%s created_at missing: %+v", a.Kind, a)
330 }
331 }
332 if len(anchors) != 3 || kinds["google"] != "google-hash-1" || kinds["microsoft"] != "microsoft-hash-1" {
333 t.Fatalf("listing kinds: %+v", anchors)
334 }
335
336 // Removal routes through the same ceremony-gated door…
337 proof := s.keys.mintCeremonyProof(uid)
338 if r := do(t, "DELETE", ts.URL+"/api/anchors/google/google-hash-1", tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
339 t.Fatalf("delete with link token: want 401, got %d", r.StatusCode)
340 }
341 if r := do(t, "DELETE", ts.URL+"/api/anchors/google/google-hash-1", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
342 t.Fatalf("delete google anchor: want 200, got %d", r.StatusCode)
343 }
344 if _, ok := s.store.oidcUser("google", "google-hash-1"); ok {
345 t.Fatalf("removed google anchor still resolves")
346 }
347
348 // …and the last-method guard counts the new kinds: the passkey may go
349 // while the microsoft anchor holds the account, and then that anchor β€”
350 // the last way in β€” is refused.
351 creds, _ := s.store.credentialsFor(uid)
352 credID := b64CredID(string(creds[0].ID))
353 if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+credID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
354 t.Fatalf("shed passkey beside oidc anchor: want 200, got %d", r.StatusCode)
355 }
356 if r := do(t, "DELETE", ts.URL+"/api/anchors/microsoft/microsoft-hash-1", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusConflict {
357 t.Fatalf("delete last oidc anchor: want 409, got %d", r.StatusCode)
358 }
359}
360