package wristband import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" ) // TestUserInfoUniform401 pins T-19.4-10: every failure of the userinfo // endpoint is the same 401 with an empty body, WWW-Authenticate: Bearer // error="invalid_token" and no-store; the success answer starts with sub // and lists the allowlisted claims of the token's scopes in allowlist order. func TestUserInfoUniform401(t *testing.T) { f := newOIDCFixture(t, testOIDCIssuer, nil) mint := func() string { return decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid profile email"), oidcTestVerifier)).AccessToken } call := func(header string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/oauth/userinfo", nil) if header != "" { req.Header.Set("Authorization", header) } rec := httptest.NewRecorder() f.mux().ServeHTTP(rec, req) return rec } tokenRow := func(raw string) *OIDCAccessTokenRecord { for _, tok := range f.backend.tokens { if tok.TokenHash == sha256Hex(raw) { return tok } } t.Fatal("no token row") return nil } good := mint() ok := call("Bearer " + good) if ok.Code != http.StatusOK || ok.Header().Get("Content-Type") != "application/json" || ok.Header().Get("Cache-Control") != "no-store, private" { t.Fatalf("userinfo = %d %v", ok.Code, ok.Header()) } sub := f.backend.subject(7, "") want := `{"sub":"` + sub + `","name":"Ada Example","groups":["editors"],"communities":[],"email":"ada@example.test","email_verified":true}` if ok.Body.String() != want { t.Fatalf("userinfo body = %s\nwant %s", ok.Body.String(), want) } // The scheme is case-insensitive and the last Bearer wins (Laravel). if rec := call("bearer " + good); rec.Code != http.StatusOK { t.Fatalf("lowercase scheme = %d", rec.Code) } if rec := call("Basic x, Bearer " + good); rec.Code != http.StatusOK { t.Fatalf("Bearer after another scheme = %d", rec.Code) } revoked, expired, inactive, wrongKind, gone := mint(), mint(), mint(), mint(), mint() now := time.Now() tokenRow(revoked).RevokedAt = &now tokenRow(expired).ExpiresAt = now.Add(-time.Second) failures := map[string]func() *httptest.ResponseRecorder{ "no header": func() *httptest.ResponseRecorder { return call("") }, "empty bearer": func() *httptest.ResponseRecorder { return call("Bearer ") }, "Basic scheme": func() *httptest.ResponseRecorder { return call("Basic " + good) }, "no prefix": func() *httptest.ResponseRecorder { return call("Bearer " + strings.TrimPrefix(good, "oas_at_")) }, "unknown token": func() *httptest.ResponseRecorder { return call("Bearer oas_at_unknown") }, "truncated token": func() *httptest.ResponseRecorder { return call("Bearer " + good[:len(good)-1]) }, "query parameter": func() *httptest.ResponseRecorder { return f.get("/oauth/userinfo?access_token=" + good) }, "revoked token": func() *httptest.ResponseRecorder { return call("Bearer " + revoked) }, "expired token": func() *httptest.ResponseRecorder { return call("Bearer " + expired) }, "inactive client": func() *httptest.ResponseRecorder { f.backend.clients[0].IsActive = false defer func() { f.backend.clients[0].IsActive = true }() return call("Bearer " + inactive) }, "MCP-kind client": func() *httptest.ResponseRecorder { f.backend.clients[0].Kind = ClientKindMCP defer func() { f.backend.clients[0].Kind = ClientKindOIDC }() return call("Bearer " + wrongKind) }, "deleted client": func() *httptest.ResponseRecorder { saved := f.backend.clients[0].ClientID f.backend.clients[0].ClientID = "oas_gone" defer func() { f.backend.clients[0].ClientID = saved }() return call("Bearer " + wrongKind) }, "gone user": func() *httptest.ResponseRecorder { saved := f.backend.identities[7] delete(f.backend.identities, 7) defer f.backend.setIdentity(7, saved) return call("Bearer " + gone) }, } for name, fn := range failures { rec := fn() if rec.Code != http.StatusUnauthorized || rec.Body.Len() != 0 || rec.Header().Get("WWW-Authenticate") != `Bearer error="invalid_token"` || rec.Header().Get("Cache-Control") != "no-store, private" || rec.Header().Get("Content-Type") != "" { t.Errorf("%s = %d %q %v", name, rec.Code, rec.Body.String(), rec.Header()) } } // The tokens that only failed for a client or user reason work again. for _, tok := range []string{inactive, wrongKind, gone} { if rec := call("Bearer " + tok); rec.Code != http.StatusOK { t.Fatalf("token after the condition cleared = %d", rec.Code) } } // The token's scopes decide the claims: openid alone gives sub only. narrow := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)).AccessToken var claims map[string]any if err := json.Unmarshal(call("Bearer "+narrow).Body.Bytes(), &claims); err != nil { t.Fatal(err) } if len(claims) != 1 || claims["sub"] != sub { t.Fatalf("openid-only claims = %v", claims) } // An unconfigured server answers 500. bare := NewOIDCServer(DefaultOptions(), DefaultOIDCOptions()) if rec := serve(bare.UserInfo, "/oauth/userinfo"); rec.Code != http.StatusInternalServerError { t.Fatalf("unconfigured userinfo = %d", rec.Code) } }