| 1 | package 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 | |
| 10 | import ( |
| 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. |
| 21 | const msTenant = "9188040d-6c67-4c5b-b112-36a304b66dad" |
| 22 | |
| 23 | func 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 | |
| 32 | func 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). |
| 49 | func 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 | |
| 62 | func 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 | |
| 68 | func 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 | |
| 99 | func 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 | |
| 154 | func 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 | |
| 202 | func 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 | |
| 215 | func 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 | |
| 280 | func 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 | |
| 311 | func 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 | |