package main // πŸ€– OIDC provider-table tests (oidc.go, issue #1112): the Google and // Microsoft doors against a fake IdP β€” the fakeApple rig from siwa_test.go, // which is provider-agnostic already (one JWKS httptest server + self-minted // RS256 tokens); these helpers just re-default the claims per door. Apple's // own suite (siwa_test.go) is the proof the shared core didn't move under // it β€” nothing here re-tests /api/siwa/*. import ( "encoding/json" "io" "net/http" "net/http/httptest" "testing" "time" ) // msTenant is Microsoft's consumer-accounts tenant id β€” what a personal // account's v2.0 token carries in both the issuer and tid. const msTenant = "9188040d-6c67-4c5b-b112-36a304b66dad" func googleToken(t *testing.T, idp *fakeApple, overrides map[string]any) string { t.Helper() claims := map[string]any{"iss": googleIssuer, "aud": "google-client-1", "sub": "google-sub-1"} for k, v := range overrides { claims[k] = v } return idp.token(t, claims) } func microsoftToken(t *testing.T, idp *fakeApple, overrides map[string]any) string { t.Helper() claims := map[string]any{ "iss": microsoftIssuerPrefix + msTenant + microsoftIssuerSuffix, "tid": msTenant, "aud": "microsoft-client-1", "sub": "microsoft-sub-1", } for k, v := range overrides { claims[k] = v } return idp.token(t, claims) } // oidcServer is newTestServer with both provider doors enabled against one // fake IdP (each door keeps its OWN jwksCache, as in production β€” they just // share the test JWKS URL). func oidcServer(t *testing.T) (*httptest.Server, *Server, *fakeApple) { t.Helper() ts, s := newTestServer(t) idp := newFakeApple(t) s.oidc = map[string]*oidcProvider{} for _, name := range oidcProviderNames { p := newOIDCProvider(name, []string{name + "-client-1", name + "-client-2"}, []byte(name+"-pepper")) p.jwks = &jwksCache{url: idp.ts.URL} s.oidc[name] = p } return ts, s, idp } func oidcVerifyReq(t *testing.T, ts *httptest.Server, provider string, headers map[string]string, body map[string]any) *http.Response { t.Helper() b, _ := json.Marshal(body) return do(t, "POST", ts.URL+"/api/oidc/"+provider+"/verify", "", headers, b) } func TestOidcDisabledIs404(t *testing.T) { // No table at all: every door β€” and the trampoline β€” answers 404. ts, _ := newTestServer(t) for _, provider := range append([]string(nil), oidcProviderNames...) { resp := oidcVerifyReq(t, ts, provider, nil, map[string]any{"identity_token": "x"}) if resp.StatusCode != http.StatusNotFound { t.Fatalf("disabled %s: got %d, want 404", provider, resp.StatusCode) } } if resp := do(t, "POST", ts.URL+"/oidc/return", "", nil, nil); resp.StatusCode != http.StatusNotFound { t.Fatalf("disabled /oidc/return: got %d, want 404", resp.StatusCode) } // One door on, the other off: gating is per provider, and an unknown // provider name β€” Apple included, it lives on /api/siwa/* β€” is the same // indistinguishable 404. ts2, s2, idp := oidcServer(t) delete(s2.oidc, "microsoft") if resp := oidcVerifyReq(t, ts2, "microsoft", nil, map[string]any{"identity_token": microsoftToken(t, idp, nil)}); resp.StatusCode != http.StatusNotFound { t.Fatalf("disabled microsoft beside enabled google: got %d, want 404", resp.StatusCode) } for _, provider := range []string{"apple", "carrier-pigeon"} { if resp := oidcVerifyReq(t, ts2, provider, nil, map[string]any{"identity_token": "x"}); resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown provider %q: got %d, want 404", provider, resp.StatusCode) } } if resp := oidcVerifyReq(t, ts2, "google", nil, map[string]any{"identity_token": googleToken(t, idp, nil)}); resp.StatusCode != http.StatusOK { t.Fatalf("enabled google: got %d, want 200", resp.StatusCode) } } func TestOidcSignInCreatesThenResolvesAccount(t *testing.T) { ts, _, idp := oidcServer(t) for _, tc := range []struct { provider string token, second string }{ {"google", googleToken(t, idp, nil), // Google's legacy no-scheme issuer form must resolve the SAME // account: the sub, not the issuer spelling, names the person. googleToken(t, idp, map[string]any{"iss": "accounts.google.com"})}, {"microsoft", microsoftToken(t, idp, nil), microsoftToken(t, idp, nil)}, } { t.Run(tc.provider, func(t *testing.T) { resp := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.token}) if resp.StatusCode != http.StatusOK { t.Fatalf("first sign-in: got %d", resp.StatusCode) } var first struct { Token string `json:"token"` Proof string `json:"proof"` Created bool `json:"created"` } if err := json.NewDecoder(resp.Body).Decode(&first); err != nil { t.Fatalf("decode: %v", err) } if first.Token == "" || first.Proof == "" || !first.Created { t.Fatalf("first sign-in: token=%q proof=%q created=%v", first.Token, first.Proof, first.Created) } // The minted token is a real device link token: it writes the vault. put := do(t, "PUT", ts.URL+"/api/blob", first.Token, map[string]string{"If-Match": "0"}, []byte("sealed-"+tc.provider)) if put.StatusCode != http.StatusOK { t.Fatalf("blob put: got %d", put.StatusCode) } // Second sign-in, same sub: the SAME account (its blob is readable). resp2 := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.second}) var second struct { Token string `json:"token"` Created bool `json:"created"` } if err := json.NewDecoder(resp2.Body).Decode(&second); err != nil { t.Fatalf("decode second: %v", err) } if second.Created { t.Fatalf("second sign-in claims a fresh account") } get := do(t, "GET", ts.URL+"/api/blob", second.Token, nil, nil) if b, _ := io.ReadAll(get.Body); string(b) != "sealed-"+tc.provider { t.Fatalf("second sign-in reads a different vault: %q", b) } }) } } func TestOidcRejectsBadTokens(t *testing.T) { ts, s, idp := oidcServer(t) // The matrix below fires more requests than the shared per-IP burst (10) // allows; widen it β€” the limiter has its own tests, this one is about // verification. s.limiter = newRateLimiter(1000, 1000) cases := map[string]struct { provider string token string }{ "google wrong issuer": {"google", googleToken(t, idp, map[string]any{"iss": appleIssuer})}, "google wrong audience": {"google", googleToken(t, idp, map[string]any{"aud": "com.other.app"})}, "google expired": {"google", googleToken(t, idp, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})}, "google empty sub": {"google", googleToken(t, idp, map[string]any{"sub": ""})}, "google garbage": {"google", "not.a.jwt"}, // A provider's token never opens another provider's door: the aud // (and, for microsoft, the issuer) belongs to the wrong row. "google token at microsoft door": {"microsoft", googleToken(t, idp, nil)}, "microsoft apple issuer": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": appleIssuer})}, "microsoft empty tenant": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": microsoftIssuerPrefix + microsoftIssuerSuffix[1:]})}, "microsoft v1 issuer": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": "https://sts.windows.net/" + msTenant + "/"})}, "microsoft slash tenant": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": microsoftIssuerPrefix + "a/b" + microsoftIssuerSuffix})}, "microsoft tid mismatch": {"microsoft", microsoftToken(t, idp, map[string]any{"tid": "11111111-2222-3333-4444-555555555555"})}, "microsoft missing tid": {"microsoft", microsoftToken(t, idp, map[string]any{"tid": ""})}, "microsoft wrong aud": {"microsoft", microsoftToken(t, idp, map[string]any{"aud": "com.other.app"})}, "microsoft expired": {"microsoft", microsoftToken(t, idp, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})}, } for name, tc := range cases { resp := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.token}) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("%s: got %d, want 401", name, resp.StatusCode) } } // Tampered signature: valid token, one flipped payload byte β€” per door. for _, provider := range oidcProviderNames { tok := googleToken(t, idp, nil) if provider == "microsoft" { tok = microsoftToken(t, idp, nil) } tampered := tok[:len(tok)-40] + "AAAA" + tok[len(tok)-36:] resp := oidcVerifyReq(t, ts, provider, nil, map[string]any{"identity_token": tampered}) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("%s tampered: got %d, want 401", provider, resp.StatusCode) } } } func TestOidcNonceBinding(t *testing.T) { ts, _, idp := oidcServer(t) tok := googleToken(t, idp, map[string]any{"nonce": "expected-nonce"}) resp := oidcVerifyReq(t, ts, "google", nil, map[string]any{"identity_token": tok, "nonce": "expected-nonce"}) if resp.StatusCode != http.StatusOK { t.Fatalf("matching nonce: got %d", resp.StatusCode) } resp = oidcVerifyReq(t, ts, "google", nil, map[string]any{"identity_token": tok, "nonce": "different"}) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("mismatched nonce: got %d, want 401", resp.StatusCode) } } func TestOidcLinkToExistingAccount(t *testing.T) { ts, s, idp := oidcServer(t) uid, token := seedUser(t, s) proof := s.keys.mintCeremonyProof(uid) // Link needs a fresh ceremony proof β€” same bar as adding a passkey. resp := oidcVerifyReq(t, ts, "google", nil, map[string]any{ "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true}) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("link without proof: got %d, want 401", resp.StatusCode) } resp = oidcVerifyReq(t, ts, "google", map[string]string{"X-Ceremony-Proof": proof}, map[string]any{ "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true}) if resp.StatusCode != http.StatusOK { t.Fatalf("link with proof: got %d", resp.StatusCode) } // Signing in with that Google account now lands in the seeded account: // its vault is the one the seeded link token wrote. put := do(t, "PUT", ts.URL+"/api/blob", token, map[string]string{"If-Match": "0"}, []byte("seeded-vault")) if put.StatusCode != http.StatusOK { t.Fatalf("seed blob: got %d", put.StatusCode) } resp = oidcVerifyReq(t, ts, "google", nil, map[string]any{ "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"})}) var signin struct { Token string `json:"token"` Created bool `json:"created"` } if err := json.NewDecoder(resp.Body).Decode(&signin); err != nil { t.Fatalf("decode: %v", err) } if signin.Created { t.Fatalf("sign-in after link created a fresh account") } get := do(t, "GET", ts.URL+"/api/blob", signin.Token, nil, nil) if b, _ := io.ReadAll(get.Body); string(b) != "seeded-vault" { t.Fatalf("linked sign-in reads a different vault: %q", b) } // The same Google account cannot anchor a SECOND account. uid2, _ := seedUser(t, s) proof2 := s.keys.mintCeremonyProof(uid2) resp = oidcVerifyReq(t, ts, "google", map[string]string{"X-Ceremony-Proof": proof2}, map[string]any{ "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true}) if resp.StatusCode != http.StatusConflict { t.Fatalf("second-account link: got %d, want 409", resp.StatusCode) } // Cross-provider isolation: the SAME sub string through the Microsoft // door is a different identity entirely (different provider, different // pepper) β€” it mints a fresh account, never the linked one. resp = oidcVerifyReq(t, ts, "microsoft", nil, map[string]any{ "identity_token": microsoftToken(t, idp, map[string]any{"sub": "link-sub"})}) var ms struct { Created bool `json:"created"` } if err := json.NewDecoder(resp.Body).Decode(&ms); err != nil { t.Fatalf("decode microsoft: %v", err) } if !ms.Created { t.Fatalf("microsoft door with a google-linked sub resolved an existing account") } } func TestOidcReturnTrampoline(t *testing.T) { ts, _, _ := oidcServer(t) client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} // A "web."-prefixed state (minted by the home page) bounces back to the // page itself β€” token in the fragment, provider known to the page already // (it minted the state when it opened the door). resp, err := client.PostForm(ts.URL+"/oidc/return", map[string][]string{ "id_token": {"tok.en.value"}, "state": {"web.abc123"}}) if err != nil { t.Fatalf("post: %v", err) } if resp.StatusCode != http.StatusSeeOther { t.Fatalf("web trampoline: got %d, want 303", resp.StatusCode) } if loc, want := resp.Header.Get("Location"), "/#oidc=tok.en.value&state=web.abc123"; loc != want { t.Fatalf("web trampoline location: got %q, want %q", loc, want) } // Any other state is a native app's ASWebAuthenticationSession, which // intercepts the custom scheme. resp, err = client.PostForm(ts.URL+"/oidc/return", map[string][]string{ "id_token": {"tok.en.value"}, "state": {"native state"}}) if err != nil { t.Fatalf("post: %v", err) } if loc, want := resp.Header.Get("Location"), oidcReturnScheme+"#state=native+state&id_token=tok.en.value"; loc != want { t.Fatalf("native trampoline location: got %q, want %q", loc, want) } } func TestAnchorsListingIncludesOidcKinds(t *testing.T) { ts, s := newTestServer(t) uid, tok := seedUser(t, s) if _, err := s.store.insertOidcAnchor(uid, "google", "google-hash-1"); err != nil { t.Fatalf("insertOidcAnchor google: %v", err) } if _, err := s.store.insertOidcAnchor(uid, "microsoft", "microsoft-hash-1"); err != nil { t.Fatalf("insertOidcAnchor microsoft: %v", err) } // The listing carries the new kinds beside the existing ones β€” note the // doors are DISABLED on this server (no s.oidc): stored anchors still // list and remove, exactly as siwa anchors do with SiwA off. anchors := listAnchors(t, ts.URL, tok) kinds := map[string]string{} for _, a := range anchors { kinds[a.Kind] = a.ID if a.CreatedAt == 0 { t.Fatalf("%s created_at missing: %+v", a.Kind, a) } } if len(anchors) != 3 || kinds["google"] != "google-hash-1" || kinds["microsoft"] != "microsoft-hash-1" { t.Fatalf("listing kinds: %+v", anchors) } // Removal routes through the same ceremony-gated door… proof := s.keys.mintCeremonyProof(uid) if r := do(t, "DELETE", ts.URL+"/api/anchors/google/google-hash-1", tok, nil, nil); r.StatusCode != http.StatusUnauthorized { t.Fatalf("delete with link token: want 401, got %d", r.StatusCode) } 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 { t.Fatalf("delete google anchor: want 200, got %d", r.StatusCode) } if _, ok := s.store.oidcUser("google", "google-hash-1"); ok { t.Fatalf("removed google anchor still resolves") } // …and the last-method guard counts the new kinds: the passkey may go // while the microsoft anchor holds the account, and then that anchor β€” // the last way in β€” is refused. creds, _ := s.store.credentialsFor(uid) credID := b64CredID(string(creds[0].ID)) if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+credID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK { t.Fatalf("shed passkey beside oidc anchor: want 200, got %d", r.StatusCode) } 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 { t.Fatalf("delete last oidc anchor: want 409, got %d", r.StatusCode) } }